diff --git a/.clang-format b/.clang-format index eb3621f5c0..feca53dcdc 100644 --- a/.clang-format +++ b/.clang-format @@ -1,4 +1,4 @@ -Language: Cpp +--- BasedOnStyle: WebKit AccessModifierOffset: -4 @@ -121,7 +121,6 @@ SpacesInContainerLiterals: true SpacesInCStyleCastParentheses: false SpacesInParentheses: false SpacesInSquareBrackets: false -Standard: c++17 StatementMacros: - Q_UNUSED - QT_REQUIRE_VERSION @@ -129,3 +128,10 @@ TabWidth: 8 UseTab: Never #... +--- +Language: Cpp +Standard: c++17 + +--- +Language: ObjC +Standard: c++17 diff --git a/.gitignore b/.gitignore index a949abcaee..1517db02e1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ gastest.o /*.log +/build_* +/bld diff --git a/CMakeLists.txt b/CMakeLists.txt index bb5d9dbee9..06e2298423 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -285,6 +285,7 @@ if (OIIO_BUILD_TOOLS AND NOT BUILD_OIIOUTIL_ONLY) add_subdirectory (src/idiff) add_subdirectory (src/igrep) add_subdirectory (src/iinfo) + add_subdirectory (src/imiv) add_subdirectory (src/maketx) add_subdirectory (src/oiiotool) add_subdirectory (src/testtex) diff --git a/docs/dev/AI_Policy.md b/docs/dev/AI_Policy.md index 9dc03e9f07..6fd3d0bfd2 100644 --- a/docs/dev/AI_Policy.md +++ b/docs/dev/AI_Policy.md @@ -1,5 +1,4 @@ Policy on AI Coding Assistants -============================== - Initial policy merged 23-Mar-2026 diff --git a/src/build-scripts/run-clang-format.bash b/src/build-scripts/run-clang-format.bash index 19ca28ebb0..6f23d90849 100755 --- a/src/build-scripts/run-clang-format.bash +++ b/src/build-scripts/run-clang-format.bash @@ -12,7 +12,7 @@ CLANG_FORMAT_EXE=${CLANG_FORMAT_EXE:="clang-format"} echo "Running " `which clang-format` " version " `${CLANG_FORMAT_EXE} --version` files=`find ./{src,testsuite} \( -name '*.h' -o -name '*.cpp' \) -print \ - | grep -Ev 'pugixml|SHA1|farmhash.cpp|libdpx|libcineon|bcdec.h|gif.h|stb_sprintf.h'` + | grep -Ev 'pugixml|SHA1|farmhash.cpp|libdpx|libcineon|bcdec.h|gif.h|src/imiv/external/dnd_glfw/|stb_sprintf.h'` ${CLANG_FORMAT_EXE} -i -style=file $files diff --git a/src/cmake/compiler.cmake b/src/cmake/compiler.cmake index 6936ac51b9..ddf4c5dc82 100644 --- a/src/cmake/compiler.cmake +++ b/src/cmake/compiler.cmake @@ -617,6 +617,7 @@ if (PROJECT_IS_TOP_LEVEL) "src/cineon.imageio/libcineon/*" "src/dds.imageio/bcdec.h" "src/gif.imageio/gif.h" + "src/imiv/external/dnd_glfw/*" "src/libutil/stb_sprintf.h" CACHE STRING "Glob patterns to exclude for clang-format") find_program (CLANG_FORMAT_EXE diff --git a/src/cmake/dependency_utils.cmake b/src/cmake/dependency_utils.cmake index 7f0b9fd2c7..d26db0e534 100644 --- a/src/cmake/dependency_utils.cmake +++ b/src/cmake/dependency_utils.cmake @@ -51,6 +51,7 @@ set_option (${PROJECT_NAME}_DEPENDENCY_BUILD_ALLOW_UNVERIFIED_TAGS # Track all build deps we find with checked_find_package set (CFP_ALL_BUILD_DEPS_FOUND "") set (CFP_EXTERNAL_BUILD_DEPS_FOUND "") +set (CFP_CUSTOM_BUILD_DEPS_FOUND "") # Track all build deps we failed to find with checked_find_package set (CFP_ALL_BUILD_DEPS_NOTFOUND "") @@ -81,6 +82,44 @@ endfunction () +# Helper: record the final status of a dependency that is discovered outside +# checked_find_package(), so it still shows up in the dependency summary. +macro (record_build_dependency pkgname) + cmake_parse_arguments(_pkg + "FOUND;NOTFOUND;REQUIRED" + "VERSION;NOT_FOUND_EXPLANATION" + "" + ${ARGN}) + if ((_pkg_FOUND AND _pkg_NOTFOUND) + OR (NOT _pkg_FOUND AND NOT _pkg_NOTFOUND)) + message (FATAL_ERROR + "record_build_dependency(${pkgname}) requires exactly one of FOUND or NOTFOUND") + endif () + set (_pkg_version "") + set (_pkg_not_found_explanation "") + if (DEFINED _pkg_VERSION) + set (_pkg_version "${_pkg_VERSION}") + endif () + if (DEFINED _pkg_NOT_FOUND_EXPLANATION) + set (_pkg_not_found_explanation "${_pkg_NOT_FOUND_EXPLANATION}") + endif () + set (${pkgname}_VERSION "${_pkg_version}") + set (${pkgname}_VERSION "${_pkg_version}" CACHE INTERNAL + "Recorded dependency version for ${pkgname}" FORCE) + set (${pkgname}_NOT_FOUND_EXPLANATION "${_pkg_not_found_explanation}") + set (${pkgname}_NOT_FOUND_EXPLANATION "${_pkg_not_found_explanation}" + CACHE INTERNAL "Recorded dependency explanation for ${pkgname}" FORCE) + set (${pkgname}_REQUIRED ${_pkg_REQUIRED}) + set (${pkgname}_REQUIRED ${_pkg_REQUIRED} CACHE INTERNAL + "Recorded dependency required-ness for ${pkgname}" FORCE) + if (_pkg_FOUND) + set_property (GLOBAL APPEND PROPERTY OIIO_CFP_CUSTOM_BUILD_DEPS_FOUND ${pkgname}) + else () + set_property (GLOBAL APPEND PROPERTY OIIO_CFP_CUSTOM_BUILD_DEPS_NOTFOUND ${pkgname}) + endif () +endmacro () + + # Utility: if `condition` is true, append `addition` to variable `var` macro (string_append_if var condition addition) # message (STATUS "string_append_if ${var} ${condition}='${${condition}}' '${addition}'") @@ -94,11 +133,26 @@ endmacro() # Helper: Print a report about missing dependencies and give instructions on # how to turn on automatic local dependency building. function (print_package_notfound_report) + get_property(_cfp_custom_build_deps_found + GLOBAL PROPERTY OIIO_CFP_CUSTOM_BUILD_DEPS_FOUND) + get_property(_cfp_custom_build_deps_notfound + GLOBAL PROPERTY OIIO_CFP_CUSTOM_BUILD_DEPS_NOTFOUND) + set (_cfp_all_build_deps_found ${CFP_ALL_BUILD_DEPS_FOUND}) + foreach (_pkg IN LISTS _cfp_custom_build_deps_found) + string (STRIP "${_pkg} ${${_pkg}_VERSION}" _cfp_dep_with_version) + list (APPEND _cfp_all_build_deps_found "${_cfp_dep_with_version}") + endforeach () + foreach (_pkg IN LISTS _cfp_custom_build_deps_notfound) + list (APPEND _cfp_all_build_deps_found "${_pkg} NONE") + endforeach () + list (SORT _cfp_all_build_deps_found CASE INSENSITIVE) + list (REMOVE_DUPLICATES _cfp_all_build_deps_found) message (STATUS) message (STATUS "${ColorBoldYellow}=========================================================================${ColorReset}") message (STATUS "${ColorBoldYellow}= Dependency report =${ColorReset}") message (STATUS "${ColorBoldYellow}=========================================================================${ColorReset}") message (STATUS) + message (STATUS "All build dependencies: ${_cfp_all_build_deps_found}") if (CFP_EXTERNAL_BUILD_DEPS_FOUND) message (STATUS "${ColorBoldWhite}The following dependencies found externally:${ColorReset}") list (SORT CFP_EXTERNAL_BUILD_DEPS_FOUND CASE INSENSITIVE) @@ -109,6 +163,16 @@ function (print_package_notfound_report) message (STATUS " ${_msg}") endforeach () endif () + if (_cfp_custom_build_deps_found) + message (STATUS "${ColorBoldWhite}The following additional dependencies were found:${ColorReset}") + list (SORT _cfp_custom_build_deps_found CASE INSENSITIVE) + list (REMOVE_DUPLICATES _cfp_custom_build_deps_found) + foreach (_pkg IN LISTS _cfp_custom_build_deps_found) + set (_msg "${_pkg} ${${_pkg}_VERSION} ") + string_append_if (_msg ${_pkg}_REQUIRED " (REQUIRED)") + message (STATUS " ${_msg}") + endforeach () + endif () if (CFP_ALL_BUILD_DEPS_BADVERSION) message (STATUS "${ColorBoldWhite}The following dependencies were found but were too old:${ColorReset}") list (SORT CFP_ALL_BUILD_DEPS_BADVERSION CASE INSENSITIVE) @@ -116,21 +180,29 @@ function (print_package_notfound_report) foreach (_pkg IN LISTS CFP_ALL_BUILD_DEPS_BADVERSION) set (_msg "${_pkg}") string_append_if (_msg ${_pkg}_REQUIRED " (REQUIRED)") - string_append_if (_msg ${_pkg}_NOT_FOUND_EXPLANATION " ${_pkg}_NOT_FOUND_EXPLANATION") + if (DEFINED ${_pkg}_NOT_FOUND_EXPLANATION + AND NOT "${${_pkg}_NOT_FOUND_EXPLANATION}" STREQUAL "") + string (APPEND _msg " ${${_pkg}_NOT_FOUND_EXPLANATION}") + endif () if (_pkg IN_LIST CFP_LOCALLY_BUILT_DEPS) string (APPEND _msg " ${ColorMagenta}(${${_pkg}_VERSION} BUILT LOCALLY)${ColorReset} in ${${_pkg}_build_elapsed_time}s)${ColorReset}") endif () message (STATUS " ${_msg}") endforeach () endif () - if (CFP_ALL_BUILD_DEPS_NOTFOUND) + set (_cfp_all_build_deps_notfound + ${CFP_ALL_BUILD_DEPS_NOTFOUND} ${_cfp_custom_build_deps_notfound}) + if (_cfp_all_build_deps_notfound) message (STATUS "${ColorBoldWhite}The following dependencies were not found:${ColorReset}") - list (SORT CFP_ALL_BUILD_DEPS_NOTFOUND CASE INSENSITIVE) - list (REMOVE_DUPLICATES CFP_ALL_BUILD_DEPS_NOTFOUND) - foreach (_pkg IN LISTS CFP_ALL_BUILD_DEPS_NOTFOUND) + list (SORT _cfp_all_build_deps_notfound CASE INSENSITIVE) + list (REMOVE_DUPLICATES _cfp_all_build_deps_notfound) + foreach (_pkg IN LISTS _cfp_all_build_deps_notfound) set (_msg "${_pkg} ${_${_pkg}_version_range}") string_append_if (_msg ${_pkg}_REQUIRED " (REQUIRED)") - string_append_if (_msg ${_pkg}_NOT_FOUND_EXPLANATION " ${_pkg}_NOT_FOUND_EXPLANATION") + if (DEFINED ${_pkg}_NOT_FOUND_EXPLANATION + AND NOT "${${_pkg}_NOT_FOUND_EXPLANATION}" STREQUAL "") + string (APPEND _msg " ${${_pkg}_NOT_FOUND_EXPLANATION}") + endif () if (_pkg IN_LIST CFP_LOCALLY_BUILT_DEPS) string (APPEND _msg " ${ColorMagenta}(${${_pkg}_VERSION} BUILT LOCALLY in ${${_pkg}_build_elapsed_time}s)${ColorReset}") endif () diff --git a/src/doc/CMakeLists.txt b/src/doc/CMakeLists.txt index bf61e579b2..93c9db6187 100644 --- a/src/doc/CMakeLists.txt +++ b/src/doc/CMakeLists.txt @@ -26,6 +26,10 @@ if (UNIX AND TXT2MAN AND Python3_Interpreter_FOUND) message (STATUS "Unix man page documentation will be generated") set (cli_tools oiiotool iinfo maketx idiff igrep iconvert) + if (TARGET imiv) + list (APPEND cli_tools imiv) + endif() + if (TARGET iv) list (APPEND cli_tools iv) endif() diff --git a/src/doc/Doxyfile b/src/doc/Doxyfile index 64358b8ed3..16c28931ad 100644 --- a/src/doc/Doxyfile +++ b/src/doc/Doxyfile @@ -829,7 +829,7 @@ WARN_LOGFILE = # spaces. See also FILE_PATTERNS and EXTENSION_MAPPING # Note: If this tag is empty the current directory is searched. -INPUT = ../../src +INPUT = ../../src/include/OpenImageIO # This tag can be used to specify the character encoding of the source files # that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses @@ -854,51 +854,11 @@ INPUT_ENCODING = UTF-8 # *.m, *.markdown, *.md, *.mm, *.dox, *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, # *.f, *.for, *.tcl, *.vhd, *.vhdl, *.ucf, *.qsf and *.ice. -FILE_PATTERNS = *.c \ - *.cc \ - *.cxx \ - *.cpp \ - *.c++ \ - *.java \ - *.ii \ - *.ixx \ - *.ipp \ - *.i++ \ - *.inl \ - *.idl \ - *.ddl \ - *.odl \ - *.h \ +FILE_PATTERNS = *.h \ *.hh \ *.hxx \ *.hpp \ - *.h++ \ - *.cs \ - *.d \ - *.php \ - *.php4 \ - *.php5 \ - *.phtml \ - *.inc \ - *.m \ - *.markdown \ - *.md \ - *.mm \ - *.dox \ - *.py \ - *.pyw \ - *.f90 \ - *.f95 \ - *.f03 \ - *.f08 \ - *.f \ - *.for \ - *.tcl \ - *.vhd \ - *.vhdl \ - *.ucf \ - *.qsf \ - *.ice + *.h++ # The RECURSIVE tag can be used to specify whether or not subdirectories should # be searched for input files as well. @@ -1158,7 +1118,7 @@ IGNORE_PREFIX = # If the GENERATE_HTML tag is set to YES, doxygen will generate HTML output # The default value is: YES. -GENERATE_HTML = YES +GENERATE_HTML = NO # The HTML_OUTPUT tag is used to specify where the HTML docs will be put. If a # relative path is entered the value of OUTPUT_DIRECTORY will be put in front of @@ -1713,7 +1673,7 @@ EXTRA_SEARCH_MAPPINGS = # If the GENERATE_LATEX tag is set to YES, doxygen will generate LaTeX output. # The default value is: YES. -GENERATE_LATEX = YES +GENERATE_LATEX = NO # The LATEX_OUTPUT tag is used to specify where the LaTeX docs will be put. If a # relative path is entered the value of OUTPUT_DIRECTORY will be put in front of @@ -2157,7 +2117,8 @@ SEARCH_INCLUDES = YES # preprocessor. # This tag requires that the tag SEARCH_INCLUDES is set to YES. -INCLUDE_PATH = ../../src +INCLUDE_PATH = ../../src/include \ + ../../build/include # You can use the INCLUDE_FILE_PATTERNS tag to specify one or more wildcard # patterns (like *.h and *.hpp) to filter out the header-files in the @@ -2587,4 +2548,4 @@ GENERATE_LEGEND = YES DOT_CLEANUP = YES -CLANG_ASSISTED_PARSING = YES +CLANG_ASSISTED_PARSING = NO diff --git a/src/doc/imiv.rst b/src/doc/imiv.rst new file mode 100644 index 0000000000..f0a6afa7c4 --- /dev/null +++ b/src/doc/imiv.rst @@ -0,0 +1,60 @@ +.. + Copyright Contributors to the OpenImageIO project. + SPDX-License-Identifier: CC-BY-4.0 + + +.. _chap-imiv: + +`imiv`: ImGui Image Viewer +################################ + +.. highlight:: bash + + +Overview +======== + +The :program:`imiv` program is an in-progress Dear ImGui-based replacement +for the legacy :program:`iv` image viewer. It uses GLFW for windowing and +currently supports renderer backends built around Vulkan, Metal, or OpenGL. + +ImIv port is still under active development, and several of the most +valuable workflows are developer-facing: + +* understanding the shared app/viewer/UI architecture; +* keeping behavior aligned across multiple renderer backends; +* extending the automated GUI regression suite built on Dear ImGui Test + Engine. + + +Current status +============== + +:program:`imiv` already covers the core image-viewing loop, OCIO display +controls, backend selection, persistent preferences, and a growing automated +regression suite. But it does **not** yet claim full parity with +:program:`iv`. + +In particular: + +* Vulkan is currently the reference backend for renderer-side feature work. +* Current macOS multi-backend verification is green on Vulkan, Metal, and + OpenGL in the shared backend suite. +* Multi-backend builds can expose backend selection through both the command + line and the Preferences window, with restart-on-next-launch semantics. +* Some legacy :program:`iv` workflows are still intentionally marked as + incomplete or behaviorally different. + +For that reason, this chapter is organized into a compact user guide and +deeper developer/testing guides. + + +Documentation map +================= + +.. toctree:: + :maxdepth: 1 + + imiv_user + imiv_dev + imiv_tests diff --git a/src/doc/imiv_dev.rst b/src/doc/imiv_dev.rst new file mode 100644 index 0000000000..52da1aa315 --- /dev/null +++ b/src/doc/imiv_dev.rst @@ -0,0 +1,1264 @@ +.. + Copyright Contributors to the OpenImageIO project. + SPDX-License-Identifier: CC-BY-4.0 + + +.. _chap-imiv-dev: + +`imiv` Developer Guide +###################### + +.. highlight:: bash + + +Overview +======== + +The main :program:`imiv` task today is not feature count. It is keeping the +viewer understandable while it grows toward :program:`iv` parity. + +The design rules are simple: + +* keep application, viewer, and UI code backend-agnostic; +* keep renderer-specific code behind a small contract; +* make backend gaps explicit rather than silent; +* keep visible behavior observable through tests, layout dumps, or state dumps; +* prefer Dear ImGui public API calls over internal helpers. + +The rest of this page explains how that is done in the current code. + + +Source layout +============= + +The current codebase is split by responsibility first, then by backend. +That keeps most feature work in shared code and leaves backend files focused +on GPU upload, preview rendering, and platform integration. + +Core entry points +----------------- + +* `src/imiv/imiv_main.cpp` + parses CLI arguments in `getargs()`, builds `AppConfig`, handles small + process-level options such as `--list-backends`, and calls `Imiv::run()`. +* `src/imiv/imiv_app.cpp` + owns startup, backend resolution, GLFW bootstrap, Dear ImGui context setup, + the main frame loop, and shutdown. +* `src/imiv/imiv_frame.cpp` + assembles one UI frame. This is the highest-value file for understanding + how menus, actions, the image window, docking, and auxiliary windows fit + together. + +Shared viewer/UI code +--------------------- + +* `src/imiv/imiv_viewer.h` and `src/imiv/imiv_viewer.cpp` + define the persistent viewer state, preference storage, image metadata + extraction, and config-file handling. +* `src/imiv/imiv_actions.cpp` + contains state-changing actions such as load, reload, save, navigation, and + orientation edits. Most mutations that survive beyond the current frame + should land here instead of being buried inside window drawing code. +* `src/imiv/imiv_menu.cpp` + defines the main menu, keyboard shortcuts, and the frame-action queue used + to defer mutations until after UI collection. +* `src/imiv/imiv_aux_windows.cpp` + draws auxiliary windows such as Preferences, Info, and Preview. +* `src/imiv/imiv_image_view.cpp`, `src/imiv/imiv_navigation.cpp`, + `src/imiv/imiv_overlays.cpp`, and `src/imiv/imiv_ui.cpp` + handle the image viewport, coordinate transforms, mouse interaction, + overlays, sampling helpers, and small reusable UI helpers. + +Renderer seam +------------- + +* `src/imiv/imiv_renderer.h` + is the renderer-neutral API used by the rest of the viewer. +* `src/imiv/imiv_renderer_backend.h` + defines the backend vtable boundary. +* `src/imiv/imiv_renderer.cpp` + dispatches shared renderer calls to the selected backend. + +Backend implementations +----------------------- + +* Vulkan: + `imiv_renderer_vulkan.cpp` plus `imiv_vulkan_*` modules. +* Metal: + `imiv_renderer_metal.mm`. +* OpenGL: + `imiv_renderer_opengl.cpp`. + +Supporting subsystems +--------------------- + +* `src/imiv/imiv_ocio.cpp` + holds renderer-agnostic OCIO selection logic and target-specific shader + generation. +* `src/imiv/imiv_file_dialog.cpp` + wraps optional nativefiledialog integration. +* `src/imiv/imiv_test_engine.cpp` + integrates Dear ImGui Test Engine and automation helpers. +* `src/imiv/shaders/` + contains the static Vulkan shader sources used for upload and preview. + + +Startup and shutdown flow +========================= + +The easiest way to understand :program:`imiv` is to follow +`imiv_main.cpp` -> `Imiv::run()` -> `draw_viewer_ui()`. + +Launch path +----------- + +The startup sequence in `src/imiv/imiv_app.cpp` is: + +1. `imiv_main.cpp` parses CLI arguments into `AppConfig`. +2. `run()` loads persistent app state with `load_persistent_state()`. +3. `run()` initializes GLFW and refreshes runtime backend availability. +4. `run()` resolves the requested backend with + `requested_backend_for_launch()` and `resolve_backend_request()`. +5. The main window is created for the resolved backend. +6. The Dear ImGui context is created and configured: + + * `ImGuiConfigFlags_NavEnableKeyboard` + * `ImGuiConfigFlags_DockingEnable` + * `ImGuiConfigFlags_ViewportsEnable` + +7. Fonts and the base application style are loaded. +8. Dear ImGui layout data is loaded from disk with + `ImGui::LoadIniSettingsFromDisk()`. +9. The selected renderer backend is initialized: + + * instance/device setup; + * swapchain or drawable setup; + * backend-specific Dear ImGui renderer bootstrap. + +10. Optional OCIO preview support is preflighted for the active backend. +11. Startup images are loaded with `load_viewer_image()`. +12. Drag and drop and optional Dear ImGui Test Engine hooks are installed. + +Main loop +--------- + +The loop in `run()` keeps the order narrow and explicit: + +* poll GLFW events; +* resize the backend main window if needed; +* start a new Dear ImGui frame: + + * `renderer_imgui_new_frame()` + * `platform_glfw_imgui_new_frame()` + * `ImGui::NewFrame()` + +* call `draw_viewer_ui()`; +* apply any style preset change requested by the UI; +* render Dear ImGui draw data with `ImGui::Render()`; +* render the main window through the selected backend; +* if multi-viewports are enabled, call + `ImGui::UpdatePlatformWindows()` and + `ImGui::RenderPlatformWindowsDefault()`; +* execute delayed developer actions such as screenshot capture; +* present the frame; +* save settings if Dear ImGui requests it. + +Shutdown path +------------- + +Before shutdown, `run()`: + +* serializes Dear ImGui settings with `ImGui::SaveIniSettingsToMemory()`; +* writes combined settings through `save_persistent_state()`; +* waits for the active renderer to go idle; +* destroys the loaded viewer texture; +* stops test-engine integration; +* shuts down the renderer backend, Dear ImGui, GLFW, and the main window. + +This ordering matters. The code avoids destroying Dear ImGui state while the +renderer still owns textures or platform windows that may reference it. + + +State ownership and persistence +=============================== + +Shared state is split into a few clear buckets. Keeping this split intact makes +new work easier to reason about. + +`ViewerState` +------------- + +`ViewerState` in `src/imiv/imiv_viewer.h` is the per-view runtime model for the +currently displayed image and its interaction state. It owns: + +* the current `LoadedImage`; +* the current `ViewRecipe`; +* status and error text; +* zoom, scroll, zoom pivot, and fit behavior; +* selection and area-probe state; +* the current loaded-image index within the shared library; +* windowed/fullscreen placement; +* the current `RendererTexture`. + +If state is tied to one image pane and its navigation state, it usually +belongs here. + +`ViewRecipe` +------------ + +`ViewRecipe` in `src/imiv/imiv_viewer.h` is the per-view preview/export recipe. +It currently owns the presentation settings that should travel with one image +view: + +* exposure, gamma, and offset; +* interpolation mode; +* channel and color-mode selection; +* OCIO enable state; +* OCIO display, view, and image-color-space choices. + +This is the current source of truth for per-view preview state. At runtime, +the active view's recipe is copied into the UI editing state before menus and +tool windows are drawn, then copied back into the active `ViewerState` after +UI edits are applied. + +That mirror step is intentional. It keeps most existing Dear ImGui code +procedural while establishing one durable place for future `Save View As...` +or CPU-side export processing. + +The first full view-recipe CPU export path is now `Export As...`. + +It currently: + +* reconstructs an oriented RGBA image from the loaded source pixels; +* applies the current view recipe for exposure, gamma, offset, channel/color + display, and OCIO display/view state; +* writes the resulting oriented RGBA view image through OIIO. + +That makes `ViewRecipe` an actual preview/export seam rather than only runtime +UI state. + +`Export Selection As...` is the matching cropped variant. It uses the current +selection rectangle in source pixel space, builds a cropped source `ImageBuf`, +then runs the same view-recipe export path on that cropped image. + +`Save Selection As...` remains intentionally narrower: + +* it reconstructs an `ImageBuf` from the loaded source pixels; +* crops the selected ROI; +* bakes source orientation with `ImageBufAlgo::reorient()`; +* writes the result through OIIO. + +That keeps one export path source-oriented and one export path view-oriented. + +`ImageLibraryState` and `MultiViewWorkspace` +-------------------------------------------- + +The first multi-view slice introduces two more shared state buckets in +`src/imiv/imiv_viewer.h`: + +* `ImageLibraryState` + owns the shared loaded-image queue, recent-image history, and sort mode; +* `MultiViewWorkspace` + owns the open image windows, the active view id, and Image List visibility. + +Each `ImageViewWindow` owns one `ViewerState`. The main `Image` window is the +primary view. Additional `Image N` windows are created from +`File -> New view from current image` or by double-clicking entries in the +Image List window. Folder-open startup and `File -> Open Folder...` also feed +the same shared loaded-image library rather than creating a separate browsing +mode. + +This split matters: queue history is now global to the workspace, but image +interaction state remains per view. + +That same shared library path now covers startup multi-open, `Open Folder...`, +and multi-file drag/drop. There is one queue model, not separate browsing and +drop-import modes. + +The `Image List` window now also exposes per-row workspace state rather than +being a passive history view: + +* `>` marks the image shown in the active image view; +* `[N]` reports how many open image views currently show that path; +* a small inline `x` button removes that path from the session queue; +* the row popup menu routes the shared-library actions: + `Open in active view`, `Open in new view`, `Close in active view`, + `Close in all views`, and `Remove from session`. + +Those actions are implemented against the shared `ImageLibraryState` plus the +current `MultiViewWorkspace`. `Close` mutates view bindings only. `Remove` +edits the shared session queue and then retargets any affected views to the +next surviving queue item, or clears them if the queue becomes empty. + +Folder-open path filtering +-------------------------- + +The current folder-open path is intentionally cheap: + +* it scans one directory non-recursively; +* it filters candidate files by the readable extension set reported by OIIO's + plugin registry; +* it does not open every file just to decide whether it belongs in the queue. + +That is the right default for folders containing many ordinary still images. +It is only a queue-building filter, not a guarantee that every accepted file +will decode successfully later. + +`PlaceholderUiState` +-------------------- + +`PlaceholderUiState` is the persistent global UI configuration plus the +active-view editing mirror. It owns: + +* visibility toggles for auxiliary windows; +* global presentation and app settings such as fit-to-window; +* global OCIO config-source selection and user-config path; +* saved backend preference for the next launch; +* docking policy state such as `image_window_force_dock`. + +If state describes how the UI should look or how preview rendering should be +configured across launches for the application as a whole, it usually belongs +here. + +Preview controls such as exposure, gamma, offset, interpolation, channel +selection, and OCIO display/view no longer live here as the source of truth. +They are mirrored into `PlaceholderUiState` only while editing the active +view, then written back to that view's `ViewRecipe`. + +The focused `imiv_view_recipe_regression.py` regression exists specifically to +lock that behavior down: it opens multiple image views, edits one view's +recipe, switches active views, and verifies that the inactive view's recipe +state stays unchanged. + +`DeveloperUiState` +------------------ + +`DeveloperUiState` is more temporary. It drives the runtime-controlled +`Developer` menu, its auxiliary Dear ImGui diagnostic windows, and delayed +actions such as the manual screenshot flow. + +The effective developer-mode policy is resolved in `imiv_app.cpp`: + +* Debug builds default to developer mode enabled; +* Release builds default to developer mode disabled; +* `OIIO_DEVMODE` overrides the build default when set to a boolean value; +* `--devmode` overrides both and forces developer mode on for that launch. + +Combined settings file +---------------------- + +The current design intentionally stores application settings and Dear ImGui +layout state together in a single `imiv.inf` file. + +`src/imiv/imiv_viewer.cpp` writes: + +* the Dear ImGui `.ini` text first, straight from + `ImGui::SaveIniSettingsToMemory()`; +* then an `ImivApp` settings section with `PlaceholderUiState`, + the primary view's `ViewRecipe`, `ImageLibraryState`, and a small amount of + `ViewerState`. + +This is worth preserving. It gives :program:`imiv` one file for: + +* dock/layout state; +* window placement; +* renderer preference; +* preview defaults from the primary view recipe; +* recent-image history. + +The `IMIV_CONFIG_HOME` environment variable exists mainly for isolated local +repros and tests. + + +Frame composition +================= + +`draw_viewer_ui()` in `src/imiv/imiv_frame.cpp` is the per-frame assembly +point. That function is intentionally procedural. It is easier to maintain than +spreading frame ownership across many windows. + +The current order is: + +1. reset per-frame test and layout-dump helpers; +2. apply any test-engine overrides; +3. collect keyboard shortcuts with `collect_viewer_shortcuts()`; +4. draw the main menu with `draw_viewer_main_menu()`; +5. queue any developer screenshot work; +6. execute queued state mutations with `execute_viewer_frame_actions()`; +7. process drag and drop and auto-subimage work; +8. sync the active view recipe into the UI mirror and clamp state; +9. build the dockspace host window; +10. draw the main image window and any secondary `Image N` windows, each with + its own copied `ViewRecipe`; +11. draw the Image List window, auxiliary windows, and popups; +12. write any UI edits from the active view back into its `ViewRecipe`; +13. draw developer-mode Dear ImGui diagnostic windows and the drag overlay. + +Two design choices here are important: + +* visible state changes are not scattered through menu items and shortcut + handlers; +* per-view preview texture updates happen inside the image-window loop using + each view's own recipe, so the window code can stay renderer-neutral while + still supporting independent view settings. + + +Docking, windows, and viewports +=============================== + +Dockspace host +-------------- + +The docking model lives in `begin_main_dockspace_host()` and +`setup_image_window_policy()` in `src/imiv/imiv_frame.cpp`. + +The host window: + +* covers the main viewport work area; +* removes title bar, borders, and padding; +* disables docking into the host itself; +* creates one root dockspace with `ImGui::DockSpace()`. + +The main image window: + +* is named `Image`; +* is assigned to the dockspace with `ImGui::SetNextWindowDockID()`; +* uses `ImGuiWindowClass` to request `ImGuiDockNodeFlags_AutoHideTabBar`. + +Secondary image windows: + +* are named `Image N`; +* are created as ordinary Dear ImGui windows with the same image-window class; +* are forced into the main dockspace on first creation; +* currently use `ImGuiDockNodeFlags_NoUndocking`; +* share the same renderer backend as the primary view, but keep their own + `ViewerState` and `ViewRecipe`. + +Initial dock layout policy +-------------------------- + +:program:`imiv` still avoids programmatic dock trees for most windows, but the +current multi-view slice makes one deliberate exception: + +* if `Image List` becomes visible for a multi-file load and no saved layout for + that window exists yet, `imiv` uses a small `DockBuilder` split to create a + right-side pane of roughly 200 pixels; +* the primary `Image` window is docked into the remaining left-side node; +* later layout changes are still owned by Dear ImGui settings persistence. + +This keeps the default presentation usable for multi-image loads without +turning the whole application into a hard-coded dock tree. + +Window model +------------ + +The main windows are: + +* the dockspace host window; +* the `Image` window; +* zero or more `Image N` windows; +* `Image List`; +* `iv Info`; +* `iv Preferences`; +* `Preview`; +* the `About imiv` window; +* optional Dear ImGui diagnostics from the runtime-enabled `Developer` menu. + +Auxiliary windows in `src/imiv/imiv_aux_windows.cpp` use +`ImGuiCond_FirstUseEver` defaults. After that, Dear ImGui layout persistence +takes over. + +Multi-viewports +--------------- + +`run()` enables `ImGuiConfigFlags_ViewportsEnable`, but keeps the feature +conditional at runtime. + +If the active GLFW platform backend or the active Dear ImGui renderer backend +does not report viewport support through `io.BackendFlags`, +:program:`imiv` disables detached auxiliary windows for that run and prints a +clear message. This is better than exposing a half-working multi-window path. + + +Menus, shortcuts, and actions +============================= + +The menu and command path is split into three layers. + +Collection layer +---------------- + +`src/imiv/imiv_menu.cpp` gathers user intent through: + +* `collect_viewer_shortcuts()`; +* `draw_viewer_main_menu()`. + +Both functions mostly write to `ViewerFrameActions` instead of mutating state +directly. + +Execution layer +--------------- + +`execute_viewer_frame_actions()` applies those requests once per frame. This +keeps file I/O, texture uploads, fullscreen changes, and navigation changes +out of the immediate menu-building code. + +The same layer now also handles two window-management actions: + +* `Always on Top` + persists a simple boolean flag in `imiv.inf` and applies it to the main + GLFW window and any detached GLFW-backed ImGui viewport windows with + `GLFW_FLOATING`; +* `Reset Windows` + clears live Dear ImGui ini settings, rebuilds the main dockspace, and + reapplies the standard auxiliary-window defaults for the current frame. + +Behavior layer +-------------- + +`src/imiv/imiv_actions.cpp` contains the actual operations: + +* open/reload/close/save; +* navigation through image lists and subimages; +* fullscreen and slideshow actions; +* delete/replace/toggle operations; +* texture lifetime management around image reloads. + +This split is worth keeping. It helps in three ways: + +* menu code stays readable; +* state mutations happen in one place per frame; +* tests can assert frame actions and end state more easily. + +When adding a feature, the normal path is: + +1. collect intent in menu or shortcut code; +2. execute it in `execute_viewer_frame_actions()`; +3. put the durable behavior in `imiv_actions.cpp` or shared helpers. + + +Native dialog wrapper +===================== + +`src/imiv/imiv_file_dialog.cpp` owns all native open/save/folder dialog calls. + +It also owns the temporary topmost suppression used by the `Always on Top` +feature: + +* the app installs a lightweight begin/end callback once it has a live GLFW + window; +* the file-dialog wrapper enters that scope before opening an NFD dialog and + leaves it afterward; +* while the scope is active, the main GLFW window and any detached GLFW-backed + ImGui viewport windows have `GLFW_FLOATING` temporarily cleared; +* after the dialog returns, the wrapper restores the persistent + `window_always_on_top` state. + +Keeping that logic in the dialog wrapper avoids threading platform window +details through every individual open/save action. + + +Image window and helper modules +=============================== + +The image viewport is not a stock Dear ImGui widget. It is a small composed +system built from public Dear ImGui primitives. + +Image window composition +------------------------ + +`draw_image_window_contents()` in `src/imiv/imiv_image_view.cpp` is the core +viewer widget. + +It is responsible for: + +* computing the viewport and status-bar split; +* handling fit-to-window and recenter requests; +* creating the scrollable child region with `ImGui::BeginChild()`; +* requesting renderer-neutral texture references with + `renderer_get_viewer_texture_refs()`; +* defining the mouse interaction area with + `ImGui::InvisibleButton("##image_canvas", ...)`; +* drawing the actual image with `ImDrawList::AddImage()`; +* updating probe, selection, area-sample, and zoom state. + +Why this is built as a composition +---------------------------------- + +Dear ImGui does not ship a native image-viewer widget with the behaviors +:program:`imiv` needs. The current implementation keeps that custom behavior on +top of public API building blocks instead of introducing a private widget +framework. + +Current custom compositions include: + +* the image canvas itself: + `BeginChild` + `InvisibleButton` + draw-list image rendering; +* preview mode buttons in `src/imiv/imiv_aux_windows.cpp`, which are regular + `ImGui::Button()` calls with temporary styling; +* `input_text_string()`, a small wrapper that adapts `ImGui::InputText()` to + `std::string`; +* overlay drawing in `src/imiv/imiv_overlays.cpp`, which uses draw-list + primitives rather than custom Dear ImGui internals. + +These are fine. The rule is not "never build a custom control." The rule is +"build it from public Dear ImGui pieces unless there is no practical option." + +Navigation helpers +------------------ + +`src/imiv/imiv_navigation.cpp` keeps geometry and coordinate transforms out of +window code. It owns: + +* orientation-aware source/display UV transforms; +* screen <-> source mapping; +* viewport layout and scrollbar calculations; +* zoom pivot and recenter behavior; +* fit zoom and scroll synchronization. + +This separation matters because the same coordinate math is used by: + +* image drawing; +* probe and selection logic; +* overlays; +* automated layout and interaction tests. + +Overlay helpers +--------------- + +`src/imiv/imiv_overlays.cpp` draws: + +* the pixel closeup; +* the area-probe overlay; +* the selection overlay; +* the embedded status bar. + +It also registers synthetic layout-dump items when a visible element needs a +stable identifier for regression tests. + +Small UI helpers +---------------- + +`src/imiv/imiv_ui.cpp` contains small, sharp helpers that would otherwise +clutter the window code. Examples include pixel sampling, padded status +messages, and formatting for probe output. + + +Renderer contract and backend rules +=================================== + +The shared code does not know about `VkImage`, `id`, or raw GL +object names. That is the main boundary to defend. + +Shared renderer contract +------------------------ + +`src/imiv/imiv_renderer.h` defines the types shared UI code is allowed to use: + +* `RendererState` +* `RendererTexture` +* `RendererPreviewControls` +* renderer lifecycle and frame functions + +The viewer asks the backend for: + +* a texture upload from a `LoadedImage`; +* preview refresh from the current preview controls; +* renderer-neutral texture references for Dear ImGui display; +* platform-window rendering and screen capture. + +Everything else stays inside the backend. + +Rules worth preserving +---------------------- + +* Shared headers should not expose backend-native types. +* UI code should not branch on backend internals unless the feature really is + backend-specific. +* Unsupported behavior should fail or fall back explicitly. +* New shared renderer API should describe a concept every backend can + understand. + +Texture lifetime +---------------- + +`load_viewer_image()` in `src/imiv/imiv_actions.cpp` makes the texture +lifetime rule explicit: + +* load CPU-side image data first; +* create the new backend texture; +* quiesce the old texture lifetime with `renderer_wait_idle()` if needed; +* destroy the old backend texture; +* swap in the new viewer state. + +This is intentionally conservative. It avoids dangling GPU work during rapid +reloads and backend switches. + + +Backend rules +============= + +The shared/backend split is an intentional design constraint, not an accident. + +Rules that should continue to hold: + +* Shared headers must not expose backend-native types such as `Vk*`, Metal + Objective-C objects, or raw GL object identifiers. +* Backend-specific work belongs behind the renderer seam, not in general UI or + viewer code. +* Platform-specific bootstrap work belongs in the GLFW platform layer, not in + per-feature UI code. +* Backend gaps should fail or degrade explicitly rather than silently + pretending to be supported. + + +Backend model and status +======================== + +Runtime selection is currently: + +* platform: GLFW +* renderer: Vulkan, Metal, or OpenGL + +Build-time selection is controlled by: + +* `OIIO_IMIV_DEFAULT_RENDERER` +* `OIIO_IMIV_ENABLE_VULKAN` +* `OIIO_IMIV_ENABLE_METAL` +* `OIIO_IMIV_ENABLE_OPENGL` + +The current development roles are: + +* Vulkan: + reference backend for renderer-side feature parity and regression coverage. +* Metal: + native macOS backend using a Metal/MSL path and participating in the shared + backend verifier. +* OpenGL: + native GLSL, non-compute backend that also participates in the shared + backend verifier rather than reusing the Vulkan compute/SPIR-V pipeline. + +When a new feature requires renderer work, the safest default is to land it on +Vulkan first, then bring the other backends to matching behavior. + + +Preview pipeline and shader design +================================== + +All backends follow the same high-level model: + +1. OIIO loads source pixels into `LoadedImage`. +2. The backend owns a source texture representation. +3. The backend produces one or more preview textures from that source. +4. Shared UI code displays only the preview textures through Dear ImGui. + +This keeps image loading, GPU upload, preview shading, and UI presentation +separate. + +Why Vulkan normalizes upload to RGBA +------------------------------------ + +The Vulkan path in `src/imiv/imiv_vulkan_texture.cpp` uses the compute shader +`src/imiv/shaders/imiv_upload_to_rgba.comp` to convert the source image into a +normalized RGBA image on upload. + +That choice is deliberate. + +The compute upload path handles: + +* 1, 2, 3, or 4+ channel inputs; +* integer, half, float, and optionally double source types; +* conversion to one backend-friendly sampled format. + +The point is not only convenience. It simplifies the rest of the renderer: + +* preview shaders sample one predictable RGBA source contract; +* channel-count branching happens once during upload instead of in every + preview pass; +* later preview changes such as exposure, gamma, heatmap, orientation, and + OCIO do not require re-uploading source pixels. + +When double-precision input arrives and the fp64 compute pipeline is +unavailable, the Vulkan backend falls back to CPU conversion to float. That +fallback is explicit in `create_texture()`. + +Why preview output is separate from the source texture +------------------------------------------------------ + +Vulkan keeps both: + +* `source_image` for normalized uploaded data; +* `image` as the preview render target shown through Dear ImGui. + +OpenGL and Metal follow the same idea in their own way: + +* both keep a source texture; +* both render into separate preview textures rather than sampling the source + texture directly in the UI path. + +This is important because preview parameters change much more often than source +content. + +Separate preview textures mean: + +* exposure, gamma, offset, color-mode, orientation, and OCIO changes stay + cheap; +* the UI always displays a ready-to-sample preview result; +* source upload and preview rendering can evolve independently. + +Linear and nearest preview textures +----------------------------------- + +OpenGL and Metal keep both linear and nearest preview textures. The Vulkan +path also exposes separate sampling behavior for the main view and closeup. + +This is not duplication for its own sake. It lets :program:`imiv` choose: + +* smoother filtered display for the main image view; +* nearest-neighbor display for pixel inspection and closeup. + +That avoids re-rendering just to swap sampling behavior for inspection tools. + +Static preview shader responsibilities +-------------------------------------- + +The static Vulkan preview shaders in `src/imiv/shaders/` are a useful summary +of what every backend preview path needs to do. + +`imiv_preview.vert` + builds the fullscreen triangle used to render the preview pass. + +`imiv_preview.frag` + handles display-to-source orientation mapping, color-mode switching, + single-channel and luma display, heatmap display, exposure, gamma, and + offset. + +That shader also keeps the OCIO branch explicit with a TODO rather than +pretending the static shader path already applies OCIO. Actual OCIO preview is +provided through the runtime-generated backend-specific OCIO programs. + +Backend-specific shader paths +----------------------------- + +Vulkan + uses build-time SPIR-V for the static upload and preview shaders from + `src/imiv/shaders/`, embedded into generated headers at build time so the + final binary does not depend on external ``.spv`` files at runtime. OCIO + preview still uses optional runtime shader compilation support for the + generated OCIO fragment shader. + +OpenGL + stays on native GLSL. It uploads supported source formats directly as GL + textures, using native ``R/RG/RGB/RGBA`` uploads where possible, and + renders preview textures through a small fullscreen-triangle program. + +Metal + stays on native MSL. It uploads typed source pixel data into a Metal + buffer, runs a compute upload pass to normalize the source into the + backend sampling format, and renders preview textures through a native + Metal pipeline. + +The OpenGL and Metal paths are intentionally not copies of the Vulkan compute +pipeline. That keeps each backend aligned with its native toolchain and makes +backend failures easier to diagnose. + + +Huge images and proxy recommendation +==================================== + +The current load path is still a full-image viewer path, not a true proxy or +tile-on-demand design. + +Today, `read_image_file()` in `src/imiv/imiv_viewer.cpp` does create an +`ImageCache`, but it then reads the whole image into `LoadedImage`, builds +metadata/long-info rows from that loaded image, and the renderer uploads a +full source texture for the active backend. + +Implications: + +* `max_memory_ic_mb` is useful as an ImageCache tuning knob, but it does not + make :program:`imiv` a true huge-image viewer; +* the current path is appropriate for ordinary still images and regression + work, but it is not the right long-term design for very large plates, + stitched panoramas, or other images that should stay sparse on the CPU and + GPU. + +Recommendation for future work: + +* do not extend the current `LoadedImage -> full backend texture` path to + chase huge-image support; +* introduce a dedicated proxy/tiled path instead, backed by OIIO + `ImageCache`/ImageBuf proxy access or a similar sparse-image abstraction; +* keep `ViewRecipe` independent from that storage choice so the same per-view + recipe can drive either full-image preview or a future tile/proxy backend; +* keep CPU export and `Save View As...` built on `ViewRecipe`, not on backend + texture state. + +That separation is the important design guardrail: image storage strategy for +huge files should change independently from per-view preview/export semantics. + +The first implementation step toward that model is now in place for +Vulkan/OpenGL/Metal: + +* `src/imiv/imiv_vulkan_texture.cpp` no longer binds the whole raw source + payload as one storage-buffer descriptor range; +* the Vulkan upload path now pads row pitch to a device-safe alignment, + dispatches the compute upload in row stripes, and binds those stripes with + dynamic storage-buffer offsets; +* `src/imiv/imiv_renderer_opengl.cpp` now allocates the source texture first + and uploads large images in row stripes via `glTexSubImage2D`, using the + same stripe planner to avoid one monolithic upload call; and +* `src/imiv/imiv_renderer_metal.mm` now keeps the existing compute + normalization shader but dispatches it in row stripes, feeding it one + stripe-sized `MTLBuffer` at a time instead of one monolithic source buffer; +* GPU normalization stays intact, including the current Vulkan RGB-to-RGBA + compute conversion path, the current Metal compute conversion path, and the + current OpenGL native-channel upload path. + +This is intentionally still a partial step: + +* the viewer still loads full source pixels into `LoadedImage`; +* visible-region tile caching is still future work. + +So the immediate large-image Vulkan crash is addressed, OpenGL and Metal now +use the same striped-upload seam, and the broader cross-backend tile/proxy +architecture remains the next major renderer task. + +OCIO integration +---------------- + +`src/imiv/imiv_ocio.cpp` is shared logic, not a backend implementation dump. + +It handles: + +* config-source selection and fallback; +* display/view/image-color-space resolution; +* target-specific shader text generation for Vulkan, OpenGL, and Metal. + +At startup, `run()` preflights OCIO for the active backend. If the runtime +shader path is unavailable, :program:`imiv` disables OCIO preview for that run +and keeps the basic preview working. + + +Configuring the build +===================== + +Important cache variables from `src/imiv/CMakeLists.txt` include: + +* `OIIO_IMIV_IMGUI_ROOT` + path to the Dear ImGui checkout used by :program:`imiv`. +* `OIIO_IMIV_TEST_ENGINE_ROOT` + path to the Dear ImGui Test Engine checkout. +* `OIIO_IMIV_DEFAULT_RENDERER` + runtime default backend (`auto`, `vulkan`, `metal`, `opengl`). +* `OIIO_IMIV_ENABLE_VULKAN`, `OIIO_IMIV_ENABLE_METAL`, + `OIIO_IMIV_ENABLE_OPENGL` + per-backend build switches (`AUTO`, `ON`, `OFF`). +* `OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE` + compile test-engine support when sources are available. +* `OIIO_IMIV_USE_NATIVEFILEDIALOG` + enable native file-open/save integration. +* `OIIO_IMIV_EMBED_FONTS` + embed the `DroidSans.ttf` and `DroidSansMono.ttf` runtime fonts into the + :program:`imiv` binary. This is enabled by default. +* `OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST` + add the longer per-backend verification CTest entries. + +Common examples: + +Linux or WSL multi-backend build:: + + cmake -S . -B build \ + -D OIIO_IMIV_ENABLE_VULKAN=AUTO \ + -D OIIO_IMIV_ENABLE_OPENGL=AUTO \ + -D OIIO_IMIV_ENABLE_METAL=OFF \ + -D OIIO_IMIV_DEFAULT_RENDERER=vulkan \ + -D OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE=ON + +macOS build with Metal default and OpenGL also compiled:: + + cmake -S . -B build \ + -D OIIO_IMIV_ENABLE_METAL=AUTO \ + -D OIIO_IMIV_ENABLE_OPENGL=AUTO \ + -D OIIO_IMIV_ENABLE_VULKAN=AUTO \ + -D OIIO_IMIV_DEFAULT_RENDERER=metal \ + -D OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE=ON + +When `OIIO_IMIV_EMBED_FONTS=ON`, :program:`imiv` uses the embedded UI and mono +fonts first and does not need an external `fonts/` directory at runtime. +When it is `OFF`, :program:`imiv` tries to load those same fonts from the +runtime `fonts/` directory and then falls back to Dear ImGui's default font if +they are missing. + + +Embedded binary assets +====================== + +:program:`imiv` now uses two build-time embedding paths for runtime assets that +used to be external files. + +Fonts +----- + +The `OIIO_IMIV_EMBED_FONTS` option controls whether the two fonts actually used +by :program:`imiv` are compiled into the binary: + +* `src/fonts/Droid_Sans/DroidSans.ttf` +* `src/fonts/Droid_Sans_Mono/DroidSansMono.ttf` + +`src/imiv/CMakeLists.txt` generates binary headers for those files through +`src/imiv/embed_binary_header.cmake`. The generated headers live in the build +directory and are included from `src/imiv/imiv_app.cpp` when +`IMIV_EMBED_FONTS` is enabled in `imiv_build_config.h`. + +Runtime font loading order is: + +1. embedded font data, if the build enabled it; +2. `fonts/` next to the executable; +3. Dear ImGui's default font for the UI font, then the UI font again for the + mono slot if the mono font also could not be loaded. + +Static Vulkan shaders +--------------------- + +The static Vulkan upload and preview shaders are always embedded when the +Vulkan backend is compiled. `src/imiv/CMakeLists.txt` first builds the SPIR-V +files, then converts them into generated headers with +`src/imiv/embed_spirv_header.cmake`. + +Those embedded headers cover the fixed-function Vulkan shader set in +`src/imiv/shaders/`: + +* the upload compute shader variants; +* the static preview vertex shader; +* the static preview fragment shader. + +`src/imiv/imiv_vulkan_setup.cpp` and `src/imiv/imiv_vulkan_ocio.cpp` use the +embedded SPIR-V words first and only fall back to `IMIV_SHADER_DIR` if needed. +That fallback keeps unusual build layouts working, but normal packaged builds +should no longer depend on external `.spv` files at runtime. + +What is not embedded +-------------------- + +Not every renderer-side asset is a static binary blob: + +* Vulkan OCIO preview still generates its fragment shader at runtime because it + depends on the active OCIO configuration and selected display/view. +* OpenGL still compiles native GLSL source strings with the GL driver. +* Metal still compiles embedded MSL source text at runtime. + +So the embedded-binary policy is intentionally narrow: + +* fonts are embedded to remove the runtime `fonts/` dependency by default; +* static Vulkan SPIR-V is embedded to remove the runtime `.spv` dependency by + default; +* dynamic OCIO and backend-native runtime shader generation remain runtime + features. + +At the time of this writing, the shared backend verifier is green on macOS for +all three compiled backends: + +* Vulkan +* Metal +* OpenGL + +The test-engine sources are discovered either from +`OIIO_IMIV_TEST_ENGINE_ROOT` or from the `IMGUI_TEST_ENGINE_ROOT` +environment variable. + + +Runtime selection and preferences +================================= + +The launch-time backend resolution order is: + +1. the `--backend` command-line option, if supplied; +2. the saved `renderer_backend` preference from `imiv.inf`; +3. `auto`, which resolves to the first compiled backend available to the + current binary. + +Backend preference changes made in the Preferences window are persistent but +take effect on the next launch. This is intentional and should remain true for +all backends. + +For isolated local repros and tests, set `IMIV_CONFIG_HOME` so preference +changes do not bleed into your normal user config. + + +Dear ImGui API policy +===================== + +Production :program:`imiv` code is written against Dear ImGui public API first. +That is an explicit maintenance choice. + +Public Dear ImGui API used throughout `imiv` +-------------------------------------------- + +Common examples in the current code are: + +* context and frame lifecycle: + `ImGui::CreateContext()`, `ImGui::NewFrame()`, `ImGui::Render()`; +* docking and platform windows: + `ImGui::DockSpace()`, `ImGui::SetNextWindowDockID()`, + `ImGui::UpdatePlatformWindows()`, + `ImGui::RenderPlatformWindowsDefault()`; +* settings persistence: + `ImGui::LoadIniSettingsFromDisk()`, + `ImGui::SaveIniSettingsToMemory()`; +* standard windows and widgets: + `ImGui::Begin()`, `ImGui::End()`, `ImGui::BeginChild()`, + `ImGui::BeginTable()`, `ImGui::BeginMainMenuBar()`, + `ImGui::BeginPopupModal()`, `ImGui::Button()`, + `ImGui::Checkbox()`, `ImGui::InputText()`; +* interaction helpers: + `ImGui::Shortcut()`, `ImGui::InvisibleButton()`; +* draw-list rendering: + `ImDrawList::AddImage()`. + +Current production `src/imiv` sources still use Dear ImGui public API first. +There is now one explicit exception in `imiv_frame.cpp` for the initial +`Image List` dock split, which uses `imgui_internal.h` `DockBuilder` helpers. +That exception should stay narrow. + +Maintenance-sensitive integrations +---------------------------------- + +Some parts of :program:`imiv` still depend on APIs or helper layers that are +outside the plain `imgui.h` application surface. These are valid choices, but +they need extra care when updating Dear ImGui. + +Vulkan backend helper types +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Vulkan backend uses `ImGui_ImplVulkanH_Window` and related +`ImGui_ImplVulkanH_*` helpers from the Dear ImGui Vulkan backend support code. + +Files using this include: + +* `src/imiv/imiv_types.h` +* `src/imiv/imiv_vulkan_runtime.cpp` +* `src/imiv/imiv_vulkan_setup.cpp` +* `src/imiv/imiv_capture.cpp` + +These helpers are practical, but they are not the same stability level as the +core public UI API. When updating Dear ImGui, check them first. + +Local Metal backend fork +^^^^^^^^^^^^^^^^^^^^^^^^ + +`src/imiv/external/imgui_impl_metal_imiv.mm` is an intentional local fork of +the Dear ImGui Metal backend. + +It adds: + +* `ImGui_ImplMetal_CreateUserTextureID()` +* `ImGui_ImplMetal_DestroyUserTextureID()` + +That extension exists because :program:`imiv` needs per-texture sampler choice +for the linear and nearest preview textures. This is a reasonable design, but +it is not upstream vanilla Dear ImGui backend code. Treat it as local +maintenance surface and re-check it whenever upstream Metal backend code +changes. + +Dear ImGui Test Engine internals +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +`src/imiv/imiv_test_engine.cpp` integrates Dear ImGui Test Engine and uses +test-engine hooks and types such as: + +* `ImGuiContext*` +* `ImGuiWindow*` +* `ImGuiTestEngineHook_ItemAdd()` + +This is expected for test-engine integration, but it should stay isolated to +the test layer. Do not spread test-engine internal assumptions through normal +viewer code. + +Practical rule +-------------- + +If a new feature can be built with `imgui.h` and the normal backend interfaces, +do that. If it cannot, isolate the non-public dependency in one file and +document why it exists. + + +Adding or changing UI +===================== + +The easiest way to make :program:`imiv` harder to maintain is to mix viewer +state, renderer details, and automation plumbing in one patch. + +The safer workflow is: + +1. Put new durable state in `ViewerState` or `PlaceholderUiState`. +2. Implement the behavior change in shared actions/navigation code unless it + truly requires renderer work. +3. Expose the feature through menu/UI code. +4. Add regression visibility through state dumps, layout markers, or both. + +Practical guidance: + +* Keep shortcut handling in sync with menu actions. +* Keep the `ViewerFrameActions` split intact instead of mutating durable state + directly inside menu drawing code. +* Prefer small helper wrappers over ad hoc widget copies when the same UI + pattern appears in several places. +* Preserve current behavior across backends unless the limitation is + intentional and documented. +* When a visible element matters to automated layout dumps, add a synthetic + marker with `register_layout_dump_synthetic_item()` or + `register_layout_dump_synthetic_rect()`. +* When a behavior change is better asserted structurally than visually, extend + the viewer-state JSON written from `imiv_frame.cpp`. +* If a control needs custom drawing, build it from public Dear ImGui pieces + first. The image canvas model in `imiv_image_view.cpp` is the current + pattern to follow. + + +Adding renderer work +==================== + +If a feature needs backend-specific implementation, treat `imiv_renderer.h` +and `imiv_renderer_backend.h` as the contract boundary. + +Recommendations: + +* Add shared renderer API only when the concept belongs in all backends. +* Keep unsupported behavior explicit; do not silently emulate a Vulkan-only + assumption in shared code. +* Do not push backend-native types upward into viewer/UI layers. +* Match Vulkan behavior on other backends rather than inventing divergent UI + semantics. +* Keep the source-texture/preview-texture split unless there is a strong, + measured reason to change it. +* Treat linear-vs-nearest inspection behavior as user-facing functionality, + not an implementation accident. + +Backend-specific constraints worth preserving: + +* OpenGL should remain a native GLSL, non-compute path. +* Metal should stay on a native Metal/MSL path. +* Vulkan remains the primary reference path for compute upload, runtime shader + compilation, and parity-driven regression work. + + +Debugging and local development +=============================== + +Useful local tools and switches: + +* `imiv -v image.exr` + prints verbose startup and backend-selection logs. +* `IMIV_VULKAN_VERBOSE_VALIDATION=1` + enables noisier Vulkan validation logging. +* `IMIV_DEBUG_IMGUI_TEXTURES=1` + helps debug Dear ImGui texture update behavior. +* Debug builds expose the `Developer` menu, including Dear ImGui diagnostic + windows and a manual main-window capture action on `F12`. +* `IMIV_CONFIG_HOME=/tmp/imiv-dev` + isolates config, layout, and backend preference changes during local + debugging and test authoring. + +For broader regression coverage across compiled backends, see the +:doc:`test-suite guide `. diff --git a/src/doc/imiv_tests.rst b/src/doc/imiv_tests.rst new file mode 100644 index 0000000000..7cee4941f8 --- /dev/null +++ b/src/doc/imiv_tests.rst @@ -0,0 +1,507 @@ +.. + Copyright Contributors to the OpenImageIO project. + SPDX-License-Identifier: CC-BY-4.0 + + +.. _chap-imiv-tests: + +`imiv` Test Suite and ImGui Test Engine +####################################### + +.. highlight:: bash + + +Overview +======== + +:program:`imiv` test automation is built around Dear ImGui Test Engine, with a +thin layer of :program:`imiv`-specific helpers for: + +* screenshots of the main viewport; +* layout dumps of relevant Dear ImGui windows and items; +* viewer-state JSON dumps with backend and OCIO state; +* XML-driven multi-step UI scenarios; +* Python wrappers that generate fixtures, run focused regressions, and collect + artifacts. + +The intent is not only to catch regressions, but also to make new UI work easy +to observe and extend. + + +Build requirements +================== + +To compile the automation hooks, configure :program:`imiv` with Dear ImGui +Test Engine available and enable: + +.. code-block:: + + -D OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE=ON + +The build discovers the test-engine sources from either: + +* `OIIO_IMIV_TEST_ENGINE_ROOT`, or +* the `IMGUI_TEST_ENGINE_ROOT` environment variable. + +When the test engine is not compiled in, :program:`imiv` will warn if +automation is requested through `IMIV_IMGUI_TEST_ENGINE*` environment +variables. + +With the default build settings, focused regressions do not depend on an +external `fonts/` directory or static Vulkan `.spv` files at runtime because +those assets are embedded into the :program:`imiv` binary. OCIO preview +regressions still depend on the runtime OCIO environment because the OCIO +shader path is generated from the active configuration. + + +Quick start +=========== + +The simplest entry point is the Python runner: + + `python src/imiv/tools/imiv_gui_test_run.py ...` + +Screenshot only:: + + python3 src/imiv/tools/imiv_gui_test_run.py \ + --bin build_u/bin/imiv \ + --open ASWF/logos/openimageio-stacked-gradient.png \ + --screenshot-out build_u/test_captures/smoke.png + +Screenshot + layout + state + JUnit:: + + python3 src/imiv/tools/imiv_gui_test_run.py \ + --bin build_u/bin/imiv \ + --open ASWF/logos/openimageio-stacked-gradient.png \ + --screenshot-out build_u/test_captures/smoke.png \ + --layout-json-out build_u/test_captures/smoke.layout.json \ + --layout-items \ + --state-json-out build_u/test_captures/smoke.state.json \ + --junit-out build_u/test_captures/imiv_tests.junit.xml + +Scenario-driven run:: + + python3 src/imiv/tools/imiv_gui_test_run.py \ + --bin build_u/bin/imiv \ + --open build_u/imiv_captures/ux_actions_regression/ux_actions_input.png \ + --scenario Testing/verify_opengl/runtime_ux/ux_actions.scenario.xml + +Layout JSON to SVG:: + + python3 src/imiv/tools/imiv_gui_test_run.py \ + --bin build_u/bin/imiv \ + --open ASWF/logos/openimageio-stacked-gradient.png \ + --layout-json-out build_u/test_captures/layout.json \ + --layout-items \ + --svg-out build_u/test_captures/layout.svg \ + --svg-items --svg-labels + +The runner translates command-line flags into the `IMIV_IMGUI_TEST_ENGINE_*` +environment variables understood by the C++ integration layer. + + +Built-in test modes +=================== + +The C++ side registers several built-in Dear ImGui Test Engine tests: + +* `imiv/smoke_screenshot` + screenshot capture with optional synthetic input. +* `imiv/dump_layout_json` + layout JSON export. +* `imiv/dump_viewer_state` + viewer-state JSON export. +* `imiv/scenario` + XML-driven multi-step scenario execution. +* `imiv/developer_menu_metrics_window` + focused regression around the runtime-enabled `Developer` menu. + +Most users should drive those modes through the Python wrappers, but it is +useful to know that the wrappers are not inventing separate automation paths; +they are exercising these built-in test registrations. + + +Scenario XML +============ + +Scenario files are rooted at `` and must provide an `out_dir` +attribute. Optional root attributes: + +* `layout_items` + default boolean for per-step layout item capture. +* `layout_depth` + default gather depth for per-step layout capture. + +Each `` must provide a non-empty `name`. + +Supported step attributes +------------------------- + +Timing: + +* `delay_frames` +* `post_action_delay_frames` + +Keyboard and item actions: + +* `key_chord` +* `set_ref` +* `item_click` +* `item_double_click` + +Mouse positioning and input: + +* `mouse_pos` +* `mouse_pos_window_rel` +* `mouse_pos_image_rel` +* `mouse_click_button` +* `mouse_wheel` +* `mouse_drag` +* `mouse_drag_button` +* `mouse_drag_hold` +* `mouse_drag_hold_button` +* `mouse_drag_hold_frames` + +OCIO overrides: + +* `ocio_use` +* `ocio_display` +* `ocio_view` +* `ocio_image_color_space` +* `linear_interpolation` + +Per-view recipe overrides: + +* `view_activate_index` +* `exposure` +* `gamma` +* `offset` + +Image List actions: + +* `image_list_visible` +* `image_list_select_index` +* `image_list_open_new_view_index` +* `image_list_close_active_index` +* `image_list_remove_index` + +Capture flags: + +* `screenshot` +* `layout` +* `state` +* `layout_items` +* `layout_depth` + +Minimal example:: + + + + + + + +Real examples live in: + +* `Testing/verify_opengl/runtime_ux/ux_actions.scenario.xml` +* `Testing/verify_metal/runtime_ocio_live/ocio_live.scenario.xml` + +Key-chord strings use tokens such as `ctrl+a`, `alt+f`, `ctrl+period`, +`pageup`, `f12`, `kpadd`, and similar spellings understood by the parser in +`imiv_test_engine.cpp`. + + +Focused regressions +=================== + +Recent focused GUI regressions in `src/imiv/tools/` include: + +* `imiv_multiview_regression.py` + secondary image-view creation and docked multi-view behavior; +* `imiv_image_list_regression.py` + default Image List visibility and docked layout on multi-image startup; +* `imiv_image_list_interaction_regression.py` + Image List single-click, open-in-new-view, close-in-active-view, and + remove-from-session behavior, including keeping the list visible with one + remaining queue item; +* `imiv_image_list_center_regression.py` + opening `Image List` after a single-image load preserves centered scroll + instead of snapping the image to the top-left; +* `imiv_open_folder_regression.py` + startup folder-open queue filtering for supported image files only; +* `imiv_drag_drop_regression.py` + multi-file drag/drop into the shared loaded-image queue; +* `imiv_view_recipe_regression.py` + per-view recipe isolation for exposure, gamma, offset, interpolation, and + OCIO state with multiple image views open. +* `imiv_opengl_multiopen_ocio_regression.py` + OpenGL multi-image startup with OCIO enabled, with a hard failure if the + runtime log reports `OpenGL OCIO preview draw failed`. +* `imiv_save_selection_regression.py` + GUI-driven `Save Selection As...` crop export, including selected ROI and + orientation-baked CPU output validation. +* `imiv_export_selection_regression.py` + GUI-driven `Export Selection As...` export, including selection crop and + view-recipe baking for channel/color mode and exposure/gamma/offset. +* `imiv_save_window_regression.py` + GUI-driven `Export As...` export, including view-recipe baking for + channel/color mode and exposure/gamma/offset. +* `imiv_save_window_ocio_regression.py` + GUI-driven `Export As...` OCIO export, including view-baked display/view + validation against `oiiotool --ociodisplay`. +* `imiv_large_image_switch_regression.py` + GPU-backend large-image queue switching. It currently has focused `ctest` + entries for Vulkan, OpenGL, and Metal where those backends are enabled, + forcing the striped upload path and verifying that next/previous image + navigation does not regress on large multi-image sessions. + + +Direct Dear ImGui Test Engine usage +=================================== + +Not every regression needs an XML scenario. For focused UI interactions, +direct test-engine calls in C++ are often clearer. + +Example pattern used by the developer-menu regression: + +.. code-block:: cpp + + const ImGuiTestItemInfo developer_menu = + ctx->ItemInfo("##MainMenuBar##MenuBar/Developer", + ImGuiTestOpFlags_NoError); + ctx->ItemClick("##MainMenuBar##MenuBar/Developer"); + ctx->Yield(1); + ctx->ItemClick("//$FOCUSED/ImGui Demo"); + ctx->Yield(2); + +That style is useful when: + +* the flow is short and deterministic; +* the test needs direct control over ImGui references; +* you are building a reusable primitive before deciding whether it belongs in + the higher-level XML scenario format. + +The Python wrapper for this regression enables developer mode explicitly with +`OIIO_DEVMODE=1`, so the same test path is valid in both Debug and Release +builds when Dear ImGui Test Engine support is compiled in. + + +Automation artifacts +==================== + +The automation layer intentionally produces both visual and structural output. + +Screenshots +----------- + +PNG screenshots capture the main Dear ImGui viewport. They are useful for: + +* smoke testing a backend; +* comparing sampling or OCIO output; +* producing cropped diffs with tools such as :program:`idiff`. + +Layout dumps +------------ + +Layout dumps are JSON files containing the active windows and, optionally, the +gathered items within them. They are useful for: + +* checking whether a window or control exists at all; +* comparing window placement and hierarchy; +* producing SVG overlays with `imiv_layout_json_to_svg.py`. + +Viewer-state dumps +------------------ + +Viewer-state JSON is often the best assertion surface for new regressions. It +already includes fields such as: + +* `image_loaded`, `image_path`, `zoom`, and `fit_image_to_window` +* selection and Area Sample state +* multi-view state (`view_count`, `active_view_id`, `active_view_docked`, + `image_list_visible`, `image_list_drawn`, `image_list_docked`, + `image_list_size`) +* backend state (`active`, `requested`, `next_launch`, compiled backends) +* OCIO state (requested source, resolved source, resolved config path, + display/view, available menus) + +Example excerpt:: + + { + "image_loaded": true, + "zoom": 1.000000, + "selection_active": false, + "view_count": 2, + "active_view_id": 2, + "active_view_docked": true, + "image_list_visible": true, + "image_list_drawn": true, + "image_list_docked": true, + "image_list_size": [200.000, 878.000], + "backend": { + "active": "vulkan", + "requested": "auto", + "next_launch": "vulkan" + } + } + +JUnit XML +--------- + +JUnit XML export is useful when automation is launched outside CTest and you +still want CI-friendly pass/fail reporting. + + +Backend-wide verification +========================= + +The canonical multi-check wrapper is: + + `python src/imiv/tools/imiv_backend_verify.py ...` + +Examples: + +Vulkan:: + + python3 src/imiv/tools/imiv_backend_verify.py \ + --backend vulkan \ + --build-dir build_u \ + --out-dir Testing/verify_vulkan \ + --trace + +OpenGL:: + + python3 src/imiv/tools/imiv_backend_verify.py \ + --backend opengl \ + --build-dir build_u \ + --out-dir Testing/verify_opengl \ + --trace + +Metal:: + + python3 src/imiv/tools/imiv_backend_verify.py \ + --backend metal \ + --build-dir build \ + --out-dir Testing/verify_metal \ + --trace + +This wrapper fans out into the focused regression scripts for smoke, RGB-input +coverage, UX, nearest-vs-linear sampling, and OCIO +fallback/config-source/live-update coverage, and stores the resulting logs in +files such as: + +* `verify_smoke.log` +* `verify_rgb.log` +* `verify_ux.log` +* `verify_sampling.log` +* `verify_ocio_missing.log` +* `verify_ocio_config_source.log` +* `verify_ocio_live.log` +* `verify_ocio_live_display.log` + +Backend-specific specialized regressions such as Metal screenshot and +orientation remain available, but they are not part of the common shared suite. + +Current status: + +* the shared backend verifier is green on macOS for Vulkan, OpenGL, and Metal +* focused backend-specific regressions remain useful when debugging a renderer + issue outside the common suite + + +CTest integration +================= + +When :program:`imiv` is built with tests enabled and Dear ImGui Test Engine is +available, focused regressions are added to CTest. Useful commands: + +List the :program:`imiv` tests:: + + ctest --test-dir build_u -N | rg imiv + +Run a focused UI regression:: + + ctest --test-dir build_u -V -R '^imiv_ux_actions_regression$' + +Run the focused multi-view regression:: + + ctest --test-dir build_u -V -R '^imiv_multiview_regression$' + +Run the focused multi-file Image List regression:: + + ctest --test-dir build_u -V -R '^imiv_image_list_regression$' + +Run the focused Image List interaction regression:: + + ctest --test-dir build_u -V -R '^imiv_image_list_interaction_regression$' + +Run the focused drag/drop regression:: + + ctest --test-dir build_u -V -R '^imiv_drag_drop_regression$' + +Run the focused per-view recipe regression:: + + ctest --test-dir build_u -V -R '^imiv_view_recipe_regression$' + +Run the focused Vulkan/OpenGL/Metal large-image switch regressions:: + + ctest --test-dir build_u -V -R '^imiv_large_image_switch_regression_(vulkan|opengl|metal)$' + +Run the backend-preference regression:: + + ctest --test-dir build_u -V -R '^imiv_backend_preferences_regression$' + +If `OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST=ON` was enabled at configure time, the +longer backend-wide verification entries are also added: + +* `imiv_backend_verify_vulkan` +* `imiv_backend_verify_opengl` +* `imiv_backend_verify_metal` + + +Extending the suite +=================== + +When adding new UI or UX behavior, the most durable workflow is usually: + +1. Make the feature observable. + Add stable UI labels, a layout-dump synthetic marker, a viewer-state JSON + field, or some combination of the three. +2. Prefer scenario-driven interactions in a single :program:`imiv` run. + The test engine supports attributes such as ``item_click`` and + ``item_double_click`` for stable, named widget interactions. It also + supports per-step overrides such as ``view_activate_index``, ``exposure``, + ``gamma``, and ``offset`` for view-local recipe tests. +3. Prefer state assertions over screenshot-only assertions. + Screenshots are valuable, but state dumps usually fail more clearly. +4. Keep tests isolated. + Set `IMIV_CONFIG_HOME` in wrappers so preferences and recent-file history do + not leak between runs. +5. Reuse existing helpers. + Generate fixtures with OIIO tools when practical, and build on + `imiv_gui_test_run.py` or the existing focused regression scripts before + inventing a new runner. +6. Add backend coverage intentionally. + The large-image GPU regressions use + `IMIV_VULKAN_MAX_STORAGE_BUFFER_RANGE_OVERRIDE` and + `IMIV_OPENGL_MAX_UPLOAD_CHUNK_BYTES_OVERRIDE` and + `IMIV_METAL_MAX_UPLOAD_CHUNK_BYTES_OVERRIDE` internally so the striped + upload paths are exercised deterministically even on devices with larger + native limits. + If a feature is backend-sensitive, decide whether it needs a backend-wide + verification entry, a focused regression, or both. + +One subtle but important rule: the Python runner rewrites output paths +relative to the working directory used to launch :program:`imiv`. That is the +recommended way to drive automation. It keeps path handling consistent, and it +avoids the stricter absolute-path restrictions enforced by the C++ hooks in +release builds. diff --git a/src/doc/imiv_user.rst b/src/doc/imiv_user.rst new file mode 100644 index 0000000000..49752f7484 --- /dev/null +++ b/src/doc/imiv_user.rst @@ -0,0 +1,414 @@ +.. + Copyright Contributors to the OpenImageIO project. + SPDX-License-Identifier: CC-BY-4.0 + + +.. _chap-imiv-user: + +`imiv` User Guide +################# + +.. highlight:: bash + + +Overview +======== + +:program:`imiv` is an interactive image viewer aimed at modernizing +:program:`iv` on top of Dear ImGui. The current build already supports: + +* opening one or more images at startup; +* switching between compiled renderer backends; +* navigation, zoom, channel and color-mode controls; +* OCIO display/view selection; +* pixel closeup and Area Sample inspection tools; +* persistent preferences and recent-file history. + +The user-facing workflow is intentionally still smaller than the developer and +test material, because not every historical :program:`iv` feature is wired up +yet. + +Current multi-backend builds can include Vulkan, Metal, and OpenGL in the same +binary. On supported platforms, the active backend may be selected per-launch +with ``--backend`` or persistently from the Preferences window. + + +Using `imiv` +============ + +The :program:`imiv` utility is invoked as follows: + + `imiv` *options* *filename* ... + +Any filenames listed on the command line are queued as the initial loaded +image list. For example:: + + imiv frame.exr + + imiv shotA.exr shotB.exr shotC.exr + + imiv --backend vulkan frame.exr + + imiv --display "sRGB - Display" \ + --view "ACES 2.0 - SDR 100 nits (Rec.709)" \ + --image-color-space ACEScg image.exr + +To see which renderer backends were compiled into the current binary and which +of those are currently usable on this machine:: + + imiv --list-backends + +Use :program:`imiv --help` to print the option summary for the current build. + + +`imiv` command-line options +=========================== + +.. option:: -v + + Enable verbose startup logging, including backend selection and dependency + information. + +.. option:: -F + + Run in foreground mode. This is primarily useful for debugging and + automation. + +.. option:: --backend BACKEND + + Request a renderer backend at launch time. Valid values are `auto`, + `vulkan`, `metal`, and `opengl`. + + The command-line request takes precedence over the saved backend + preference. If the requested backend was not compiled into the current + binary, or was compiled but is not currently available at runtime, + :program:`imiv` falls back to the resolved default backend and prints a + message. + +.. option:: --list-backends + + Print the backend support compiled into the current :program:`imiv` + binary, including runtime availability and any unavailability reason, then + exit. + +.. option:: --devmode + + Enable the `Developer` menu and its auxiliary Dear ImGui diagnostic tools + for this launch. + + In Debug builds, developer mode is enabled by default. In Release builds, + it is disabled by default unless requested explicitly. + +.. option:: --display NAME + + Set the initial OCIO display selection. + +.. option:: --view NAME + + Set the initial OCIO view selection. + +.. option:: --image-color-space NAME + + Set the initial OCIO image color-space selection. + +.. option:: --rawcolor + + Disable automatic conversion to RGB on image load. + +.. option:: --no-autopremult + + Disable the automatic premultiplication path used for images with + unassociated alpha. + +.. option:: --open-dialog + + Open the native file-open dialog and report the result to the terminal. + This is also a quick way to verify whether native file dialog support is + configured in the current build. + +.. option:: --save-dialog + + Open the native file-save dialog and report the result to the terminal. + + +Opening and browsing images +=========================== + +Images may be opened in several ways: + +* by listing files on the command line; +* through `File -> Open...`; +* by drag-and-drop onto the main window; +* through `File -> Open recent...`. + +:program:`imiv` keeps an explicit loaded-image list rather than relying only on +directory siblings. At the moment, the list deduplicates paths after +normalization, so repeated selection of the same file is not treated as a +separate loaded image entry. + +Useful browsing actions: + +* `PgUp` / `PgDown` moves to the previous or next loaded image. +* `T` toggles between the current and previously viewed image. +* `<` / `>` moves between subimages and mip levels where available. +* `Ctrl+R` reloads the current image from disk. +* `Ctrl+W` closes the current image. + + +Exporting images +================ + +The current export actions are still smaller than :program:`iv`, but +:program:`imiv` now has three real GUI-driven CPU export paths: + +* `File -> Export As...` + writes the current image pane as an oriented RGBA view export; +* `Ctrl+Shift+S` + invokes the same action directly; + +* `File -> Save Selection As...` + writes the current pixel selection to a new file; +* `Ctrl+Alt+S` + invokes the same action directly. +* `File -> Export Selection As...` + writes the selected pixel region as an oriented RGBA view export; +* `Ctrl+Shift+Alt+S` + invokes the same action directly. + +`Export As...` currently exports: + +* the current image with orientation baked to the saved output; +* the active view recipe for exposure, gamma, offset, channel/color display, + and OCIO display/view if OCIO is enabled. + +It is intentionally a view export, not a source-preserving rewrite. The saved +image is written as an oriented RGBA result and may differ in channel count and +numeric type from the original source image. + +`Save Selection As...` currently exports: + +* the selected pixel rectangle from the loaded source image; and +* the image with its stored orientation baked to the saved output. + +It does not yet bake the full per-view recipe. In particular, exposure, gamma, +offset, channel/color display choices, and OCIO display/view state are still +preview-only at export time. + +`Export Selection As...` currently exports: + +* the selected pixel rectangle from the current image; +* the image with its stored orientation baked to the saved output; and +* the active view recipe for exposure, gamma, offset, channel/color display, + and OCIO display/view if OCIO is enabled. + + +Viewing, navigation, and inspection +=================================== + +The main viewing controls are centered in the `View` and `Tools` menus. + +Common shortcuts: + +* `Ctrl++`, `Ctrl+-`, `Ctrl+0` zoom in, zoom out, and return to 1:1. +* `Ctrl+.` re-centers the image. +* `F` fits the window to the image; `Alt+F` fits the image to the window. +* `Ctrl+F` toggles fullscreen mode. +* `C`, `R`, `G`, `B`, `A`, `1`, `L`, and `H` switch channel and color modes. +* `[`, `]`, `{`, `}`, `(`, and `)` adjust exposure and gamma. +* `Ctrl+I` opens the image information window. +* `P` opens the pixel closeup view. +* `Ctrl+A` toggles Area Sample. +* `Ctrl+Shift+A` selects the whole image; `Ctrl+D` clears the current + selection. + +The `Tools` menu also exposes slideshow, sort-order, and orientation actions. + + +Multiple views and image list +============================= + +The current multi-view workflow is centered on shared loaded-image history and +per-window image panes. + +Useful actions: + +* `File -> Open Folder...` + scans one directory and queues the supported image files it contains. +* `File -> New view from current image` + duplicates the current image into a new `Image N` window. +* `View -> Image List` + opens a dockable window showing the current loaded-image queue. + +`Open Folder...` is currently a non-recursive scan. It uses the readable file +extensions reported by the OpenImageIO plugin registry as a cheap prefilter, +so large flat folders do not need to be probed by opening every file just to +build the queue. + +The `Image List` window currently supports: + +* single-click + load the chosen image into the active image view; +* double-click + open the chosen image in a new image view window. +* active-view marker + `>` marks the image currently shown in the active image window; +* open-count badge + `[N]` shows how many open image views currently display that image; +* inline close button + each row shows a small `x` button that removes that image from the current + session queue; +* right-click context menu + provides `Open in active view`, `Open in new view`, + `Close in active view`, `Close in all views`, and + `Remove from session`. + +`Close` and `Remove` are intentionally different: + +* `Close in active view` + only clears that image from the active image window; +* `Close in all views` + clears it from every currently open image window; +* `Remove from session` + removes it from the shared loaded-image queue without touching the file on + disk. + +The main `Image` window remains the primary docked image pane. Additional +`Image N` windows are currently created docked into the main dockspace. +Undocking those image views is intentionally disabled in this first slice. + +Current builds embed the `imiv` UI and mono fonts by default. If a build is +configured without font embedding, :program:`imiv` first looks for the same +fonts in its runtime `fonts/` directory and then falls back to Dear ImGui's +default font if they are not present. + +Static Vulkan upload and preview shaders are also embedded into the binary at +build time. That means normal Vulkan launches do not need separate `.spv` +files next to the executable. OpenGL and Metal continue to compile their +native shader source at runtime, and Vulkan OCIO preview still generates its +backend shader at runtime because it depends on the active OCIO configuration. + +When the queue first grows beyond one image, `Image List` becomes visible +automatically and defaults to a narrow docked pane on the right side of the +main image area. If it is already open and the queue later shrinks to one +remaining image, it stays open so the last session item can still be managed +from the list. It hides automatically only when the queue becomes empty. Its +dock position and size may still be saved by Dear ImGui layout persistence, +but its open/closed visibility is not treated as a persistent preference. + +This is the first multi-view milestone. View windows already have independent +loaded images, zoom, scroll, selection state, and preview recipe state. +Exposure, gamma, offset, interpolation, channel/color display, and OCIO +display/view/image-color-space choices are now stored per window at runtime. + +The current persistence model is still simpler than the runtime model: + +* :program:`imiv` saves the primary/default view recipe in `imiv.inf`; +* it does not yet persist the full multi-window workspace or every secondary + view's recipe across launches. + +Multi-file drag and drop feeds the same shared loaded-image queue as startup +multi-open and `Open Folder...`. That means dropped files immediately appear +in `Image List` and participate in the same per-view open/close/remove +workflow. + +The `Window` menu provides: + +* `Always on Top` + keeps the main :program:`imiv` window and detached auxiliary windows above + ordinary desktop windows; +* `Reset Windows` + clears the saved Dear ImGui layout in the current session and restores the + default dock/tool-window placement so hidden or off-screen auxiliary windows + can be recovered. + +When native open/save/folder dialogs are used, :program:`imiv` temporarily +disables the topmost window hint while the dialog is open and restores it +after the dialog closes. This avoids the main window covering the native file +dialog on platforms where an always-on-top GLFW window would otherwise stay in +front. + + +Color management +================ + +OCIO controls are available from `View -> OCIO` and from the preferences +window. + +The current implementation supports: + +* enabling or disabling OCIO preview; +* choosing the image color space; +* choosing the display and view; +* selecting the OCIO config source from the saved preferences; +* resolving `auto` image color space from image metadata when possible. + +If the requested OCIO configuration is unavailable, :program:`imiv` falls back +according to the current config-source rules and reports the resolved state in +automation/state-dump output. + + +Preferences and persistent state +================================ + +:program:`imiv` saves both Dear ImGui window layout data and application state +to an `imiv.inf` file. The file location is: + +* Windows: `%APPDATA%/OpenImageIO/imiv/imiv.inf` (or `%LOCALAPPDATA%/...`) +* macOS: `~/Library/Application Support/OpenImageIO/imiv/imiv.inf` +* Linux and other Unix-like platforms: + `"$XDG_CONFIG_HOME"/OpenImageIO/imiv/imiv.inf` or + `~/.config/OpenImageIO/imiv/imiv.inf` + +For automation or isolated local experiments, the base config directory may be +overridden with the `IMIV_CONFIG_HOME` environment variable. In that case, +:program:`imiv` stores its files under: + + `"$IMIV_CONFIG_HOME"/OpenImageIO/imiv/imiv.inf` + +Saved state currently includes: + +* Dear ImGui docking/window layout; +* viewer and preview defaults for the primary view; +* backend preference (`renderer_backend`); +* whether the main window is `Always on Top`; +* OCIO settings; +* recent images and sort mode. + +Example:: + + [ImivApp][State] + renderer_backend=vulkan + fit_image_to_window=1 + window_always_on_top=0 + use_ocio=1 + ocio_display=default + ocio_view=default + recent_image=/shots/plate.0001.exr + +The Preferences window exposes backend selection as equal-width buttons for +the compiled backends, plus ``Auto``. Changing the backend preference updates +the next-launch backend and shows a restart-required note. The active backend +for the current process does not change until the next launch. + +Developer mode may also be controlled by the ``OIIO_DEVMODE`` environment +variable. Acceptable values include ``1``, ``0``, ``true``, ``false``, ``on``, +``off``, ``yes``, and ``no``. The command-line ``--devmode`` flag takes +precedence over the environment variable. + + +Current limitations +=================== + +At the time of this writing, notable differences from :program:`iv` include: + +* Fullscreen behavior does not yet exactly match :program:`iv`. +* Opening the same file repeatedly does not currently create duplicate loaded + image entries. +* Multi-view image windows have per-view preview controls at runtime, but + :program:`imiv` does not yet persist a full multi-window workspace across + launches. +* Some older :program:`iv` mouse modes are not yet a priority for the Dear + ImGui port. + +For the current backend matrix and development priorities, see the +:doc:`developer guide `. diff --git a/src/doc/index.rst b/src/doc/index.rst index 54f81cc4e8..78cfdcb84a 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -66,6 +66,7 @@ OpenImageIO |version| :maxdepth: 2 oiiotool + imiv iinfo iconvert igrep @@ -98,4 +99,3 @@ OpenImageIO |version| sphinx-tabs plugin for Sphinx: https://pypi.org/project/sphinx-tabs/ https://sphinx-tabs.readthedocs.io/en/latest/# - diff --git a/src/doc/notes.txt b/src/doc/notes.txt index 16de21535e..9fc3aa7b11 100644 --- a/src/doc/notes.txt +++ b/src/doc/notes.txt @@ -7,7 +7,7 @@ x Open ^O Open Recent Save ^S Save As... - Save Window As... + Export As... Save Selection As... x Reload x Close ^W diff --git a/src/imiv/CMakeLists.txt b/src/imiv/CMakeLists.txt new file mode 100644 index 0000000000..11c513a850 --- /dev/null +++ b/src/imiv/CMakeLists.txt @@ -0,0 +1,813 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +set (OIIO_IMIV_IMGUI_ROOT "${PROJECT_SOURCE_DIR}/../imgui" CACHE PATH + "Path to Dear ImGui repository checkout used by imiv") +set (OIIO_IMIV_TEST_ENGINE_ROOT "${PROJECT_SOURCE_DIR}/../imgui_test_engine" CACHE PATH + "Path to Dear ImGui Test Engine repository checkout used by imiv tests") +set (OIIO_IMIV_NFD_ROOT "$ENV{OIIO_IMIV_NFD_ROOT}" CACHE PATH + "Path hint for nativefiledialog-extended source/build/install root used by imiv") +set (OIIO_IMIV_NFD_INCLUDE_DIR "" CACHE PATH + "Optional explicit include directory containing nfd.h") +set (OIIO_IMIV_NFD_LIBRARY_RELEASE "" CACHE FILEPATH + "Optional explicit nativefiledialog release library path") +set (OIIO_IMIV_NFD_LIBRARY_DEBUG "" CACHE FILEPATH + "Optional explicit nativefiledialog debug library path") +set (OIIO_IMIV_GLSLANG_INCLUDE_DIR "" CACHE PATH + "Optional explicit include directory containing glslang headers for imiv runtime shader compilation") +set (OIIO_IMIV_GLSLANG_LIBRARY "" CACHE FILEPATH + "Optional explicit glslang library path for imiv runtime shader compilation") +set (OIIO_IMIV_GLSLANG_DEFAULT_LIMITS_LIBRARY "" CACHE FILEPATH + "Optional explicit glslang-default-resource-limits library path for imiv runtime shader compilation") +option (OIIO_IMIV_USE_NATIVEFILEDIALOG + "Use nativefiledialog-extended for imiv file open/save actions" ON) +option (OIIO_IMIV_EMBED_FONTS + "Embed imiv UI and mono fonts into the binary" ON) +option (OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE + "Build imiv with Dear ImGui Test Engine integration when sources are available" ON) +option (OIIO_IMIV_ADD_UPLOAD_SMOKE_CTEST + "Add an optional CTest entry that generates/upload-tests the imiv corpus" OFF) +option (OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST + "Add per-backend shared verification CTest entries for each compiled imiv backend" OFF) +set (OIIO_IMIV_VULKAN_SDK "$ENV{VULKAN_SDK}" CACHE PATH + "Optional Vulkan SDK root path used to locate Vulkan package for imiv") +set (_imiv_default_renderer "vulkan") +if (APPLE) + set (_imiv_default_renderer "metal") +endif () +set (OIIO_IMIV_RENDERER "" CACHE STRING + "Deprecated alias for OIIO_IMIV_DEFAULT_RENDERER") +set_property (CACHE OIIO_IMIV_RENDERER PROPERTY STRINGS + "" auto vulkan metal opengl) +set (OIIO_IMIV_DEFAULT_RENDERER "auto" CACHE STRING + "Default renderer backend for imiv (auto, vulkan, metal, opengl)") +set_property (CACHE OIIO_IMIV_DEFAULT_RENDERER PROPERTY STRINGS + auto vulkan metal opengl) +set (OIIO_IMIV_ENABLE_VULKAN "AUTO" CACHE STRING + "Enable Vulkan backend for imiv (AUTO, ON, OFF)") +set (OIIO_IMIV_ENABLE_METAL "AUTO" CACHE STRING + "Enable Metal backend for imiv (AUTO, ON, OFF)") +set (OIIO_IMIV_ENABLE_OPENGL "AUTO" CACHE STRING + "Enable OpenGL backend for imiv (AUTO, ON, OFF)") +set_property (CACHE OIIO_IMIV_ENABLE_VULKAN PROPERTY STRINGS AUTO ON OFF) +set_property (CACHE OIIO_IMIV_ENABLE_METAL PROPERTY STRINGS AUTO ON OFF) +set_property (CACHE OIIO_IMIV_ENABLE_OPENGL PROPERTY STRINGS AUTO ON OFF) + +check_is_enabled (imiv imiv_enabled) +if (NOT imiv_enabled) + message (STATUS "Disabling imiv") + return () +endif () + +string (TOLOWER "${OIIO_IMIV_RENDERER}" _imiv_renderer_compat_request) +string (TOLOWER "${OIIO_IMIV_DEFAULT_RENDERER}" _imiv_default_renderer_request) +if (NOT _imiv_renderer_compat_request STREQUAL "") + if (_imiv_default_renderer_request STREQUAL "" + OR _imiv_default_renderer_request STREQUAL "auto") + set (_imiv_default_renderer_request "${_imiv_renderer_compat_request}") + message (STATUS + "imiv: OIIO_IMIV_RENDERER is deprecated; treating it as OIIO_IMIV_DEFAULT_RENDERER") + endif () +endif () +if (_imiv_default_renderer_request STREQUAL "") + set (_imiv_default_renderer_request "auto") +endif () +if (NOT (_imiv_default_renderer_request STREQUAL "auto" + OR _imiv_default_renderer_request STREQUAL "vulkan" + OR _imiv_default_renderer_request STREQUAL "metal" + OR _imiv_default_renderer_request STREQUAL "opengl")) + message (FATAL_ERROR + "imiv: unsupported OIIO_IMIV_DEFAULT_RENDERER='${OIIO_IMIV_DEFAULT_RENDERER}' " + "(expected auto, vulkan, metal, or opengl)") +endif () + +string (TOUPPER "${OIIO_IMIV_ENABLE_VULKAN}" _imiv_enable_vulkan_mode) +string (TOUPPER "${OIIO_IMIV_ENABLE_METAL}" _imiv_enable_metal_mode) +string (TOUPPER "${OIIO_IMIV_ENABLE_OPENGL}" _imiv_enable_opengl_mode) +foreach (_imiv_mode_var + _imiv_enable_vulkan_mode + _imiv_enable_metal_mode + _imiv_enable_opengl_mode) + if (NOT ("${${_imiv_mode_var}}" STREQUAL "AUTO" + OR "${${_imiv_mode_var}}" STREQUAL "ON" + OR "${${_imiv_mode_var}}" STREQUAL "OFF")) + message (FATAL_ERROR + "imiv: ${_imiv_mode_var} must be AUTO, ON, or OFF") + endif () +endforeach () + +set (_imiv_want_vulkan OFF) +set (_imiv_want_metal OFF) +set (_imiv_want_opengl OFF) +if (NOT _imiv_enable_vulkan_mode STREQUAL "OFF") + set (_imiv_want_vulkan ON) +endif () +if (NOT _imiv_enable_metal_mode STREQUAL "OFF") + set (_imiv_want_metal ON) +endif () +if (NOT _imiv_enable_opengl_mode STREQUAL "OFF") + set (_imiv_want_opengl ON) +endif () +if (_imiv_enable_metal_mode STREQUAL "ON" AND NOT APPLE) + message (FATAL_ERROR + "imiv: OIIO_IMIV_ENABLE_METAL=ON requires APPLE") +endif () +if (NOT APPLE AND _imiv_enable_metal_mode STREQUAL "AUTO") + set (_imiv_want_metal OFF) +endif () +set (_imiv_enabled_vulkan OFF) +set (_imiv_enabled_metal OFF) +set (_imiv_enabled_opengl OFF) + +set (_imiv_imgui_required_files + "${OIIO_IMIV_IMGUI_ROOT}/imgui.h" + "${OIIO_IMIV_IMGUI_ROOT}/imgui.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_draw.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_tables.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_widgets.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_glfw.h" + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_glfw.cpp") +set (_imiv_imgui_renderer_sources) +if (_imiv_want_vulkan) + list (APPEND _imiv_imgui_required_files + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_vulkan.h" + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_vulkan.cpp") +endif () +if (_imiv_want_metal) + list (APPEND _imiv_imgui_required_files + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_metal.h" + "${CMAKE_CURRENT_SOURCE_DIR}/external/imgui_impl_metal_imiv.mm") +endif () +if (_imiv_want_opengl) + list (APPEND _imiv_imgui_required_files + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_opengl3.h" + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_opengl3.cpp") +endif () +set (_imiv_imgui_found ON) +foreach (_imiv_required_file ${_imiv_imgui_required_files}) + if (NOT EXISTS "${_imiv_required_file}") + set (_imiv_imgui_found OFF) + break () + endif () +endforeach () + +if (NOT _imiv_imgui_found) + record_build_dependency ( + ImGui NOTFOUND + NOT_FOUND_EXPLANATION + "(sources not found under ${OIIO_IMIV_IMGUI_ROOT})") + message (STATUS + "\n\n WARNING: Dear ImGui not found at '${OIIO_IMIV_IMGUI_ROOT}' -- 'imiv' will not be built!\n") + return () +endif () +record_build_dependency (ImGui FOUND) + +include ("${CMAKE_CURRENT_LIST_DIR}/cmake/imiv_sources.cmake") + +set (_imiv_link_libs + OpenImageIO + $ + $) +find_package (glfw3 CONFIG QUIET) +if (TARGET glfw) + list (APPEND _imiv_link_libs glfw) +else () + message (STATUS + "\n\n WARNING: glfw not found -- 'imiv' will not be built!\n") + return () +endif () + +set (_imiv_has_runtime_glslang OFF) +if (_imiv_want_vulkan) + find_package (Vulkan QUIET) + if (Vulkan_FOUND) + list (APPEND _imiv_link_libs Vulkan::Vulkan) + set (_imiv_enabled_vulkan ON) + record_build_dependency (Vulkan FOUND VERSION "${Vulkan_VERSION}") + else () + find_path (OIIO_IMIV_VULKAN_INCLUDE_DIR + NAMES vulkan/vulkan.h + HINTS + "${OIIO_IMIV_VULKAN_SDK}/include") + find_library (OIIO_IMIV_VULKAN_LIBRARY + NAMES vulkan libvulkan.so + HINTS + "${OIIO_IMIV_VULKAN_SDK}/lib") + if (OIIO_IMIV_VULKAN_INCLUDE_DIR AND OIIO_IMIV_VULKAN_LIBRARY) + add_library (imiv_vulkan_external UNKNOWN IMPORTED) + set_target_properties ( + imiv_vulkan_external PROPERTIES + IMPORTED_LOCATION "${OIIO_IMIV_VULKAN_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES + "${OIIO_IMIV_VULKAN_INCLUDE_DIR}") + list (APPEND _imiv_link_libs imiv_vulkan_external) + set (_imiv_enabled_vulkan ON) + record_build_dependency (Vulkan FOUND) + message (STATUS "imiv: using Vulkan from explicit SDK include/library paths") + elseif (_imiv_enable_vulkan_mode STREQUAL "ON") + message (FATAL_ERROR + "imiv: Vulkan backend was requested but Vulkan was not found") + else () + record_build_dependency ( + Vulkan NOTFOUND + NOT_FOUND_EXPLANATION + "(Vulkan backend not enabled; dependency discovery failed in AUTO mode)") + endif () + endif () + + if (_imiv_enabled_vulkan) + set (_imiv_runtime_glslang_explanation "") + set (_imiv_glslang_search_hints) + set (_imiv_glslang_package_roots) + foreach (_imiv_prefix IN LISTS CMAKE_PREFIX_PATH) + if (_imiv_prefix) + list (APPEND _imiv_glslang_package_roots "${_imiv_prefix}") + list (APPEND _imiv_glslang_search_hints + "${_imiv_prefix}" + "${_imiv_prefix}/include" + "${_imiv_prefix}/Include" + "${_imiv_prefix}/lib" + "${_imiv_prefix}/Lib" + "${_imiv_prefix}/lib64") + endif () + endforeach () + set (_imiv_windows_static_crt OFF) + if (WIN32 AND MSVC) + string (TOUPPER "${CMAKE_BUILD_TYPE}" _imiv_build_type_upper) + set (_imiv_msvc_runtime_flags + "${CMAKE_MSVC_RUNTIME_LIBRARY} ${CMAKE_CXX_FLAGS} ${CMAKE_CXX_FLAGS_${_imiv_build_type_upper}}") + if (_imiv_msvc_runtime_flags MATCHES "MultiThreaded(Debug)?" + OR _imiv_msvc_runtime_flags MATCHES "(^|[ /;])MTd?($|[ /;])") + set (_imiv_windows_static_crt ON) + endif () + endif () + if (OIIO_IMIV_VULKAN_SDK) + list (APPEND _imiv_glslang_package_roots "${OIIO_IMIV_VULKAN_SDK}") + list (APPEND _imiv_glslang_search_hints + "${OIIO_IMIV_VULKAN_SDK}/include" + "${OIIO_IMIV_VULKAN_SDK}/Include" + "${OIIO_IMIV_VULKAN_SDK}/lib" + "${OIIO_IMIV_VULKAN_SDK}/Lib" + "${OIIO_IMIV_VULKAN_SDK}/lib64") + endif () + + set (_imiv_glslang_include_dir "${OIIO_IMIV_GLSLANG_INCLUDE_DIR}") + set (_imiv_glslang_library "${OIIO_IMIV_GLSLANG_LIBRARY}") + set (_imiv_glslang_default_limits_library + "${OIIO_IMIV_GLSLANG_DEFAULT_LIMITS_LIBRARY}") + set (_imiv_glslang_manual_override OFF) + if (_imiv_glslang_include_dir + AND _imiv_glslang_library + AND _imiv_glslang_default_limits_library) + set (_imiv_glslang_manual_override ON) + endif () + + set (_imiv_glslang_package_found OFF) + if (NOT _imiv_glslang_manual_override) + find_package (glslang CONFIG QUIET + PATHS ${_imiv_glslang_package_roots}) + if (glslang_FOUND + AND TARGET glslang::glslang + AND TARGET glslang::glslang-default-resource-limits) + set (_imiv_glslang_package_found ON) + endif () + endif () + + set (_imiv_glslang_from_vulkan_sdk OFF) + if (_imiv_glslang_package_found AND OIIO_IMIV_VULKAN_SDK) + file (TO_CMAKE_PATH "${OIIO_IMIV_VULKAN_SDK}" _imiv_vulkan_sdk_path) + file (TO_CMAKE_PATH "${glslang_DIR}" _imiv_glslang_dir_norm) + string (FIND "${_imiv_glslang_dir_norm}" "${_imiv_vulkan_sdk_path}" + _imiv_glslang_dir_pos) + if (_imiv_glslang_dir_pos EQUAL 0) + set (_imiv_glslang_from_vulkan_sdk ON) + endif () + elseif (OIIO_IMIV_VULKAN_SDK + AND _imiv_glslang_include_dir + AND _imiv_glslang_library + AND _imiv_glslang_default_limits_library) + file (TO_CMAKE_PATH "${OIIO_IMIV_VULKAN_SDK}" _imiv_vulkan_sdk_path) + set (_imiv_glslang_from_vulkan_sdk ON) + foreach (_imiv_glslang_path + "${_imiv_glslang_include_dir}" + "${_imiv_glslang_library}" + "${_imiv_glslang_default_limits_library}") + file (TO_CMAKE_PATH "${_imiv_glslang_path}" _imiv_glslang_path_norm) + string (FIND "${_imiv_glslang_path_norm}" "${_imiv_vulkan_sdk_path}" + _imiv_glslang_path_pos) + if (NOT _imiv_glslang_path_pos EQUAL 0) + set (_imiv_glslang_from_vulkan_sdk OFF) + break () + endif () + endforeach () + endif () + + if (WIN32 AND _imiv_windows_static_crt AND _imiv_glslang_from_vulkan_sdk) + set (_imiv_runtime_glslang_explanation + "(runtime shader compilation disabled; Vulkan SDK glslang libraries use /MD and are incompatible with this /MT build; use /MD or provide custom glslang libraries built with the same MSVC runtime via CMAKE_PREFIX_PATH or OIIO_IMIV_GLSLANG_* paths)") + elseif (_imiv_glslang_package_found) + list (APPEND _imiv_link_libs + glslang::glslang + glslang::glslang-default-resource-limits) + set (_imiv_has_runtime_glslang ON) + message (STATUS "imiv: using glslang package from ${glslang_DIR}") + if (DEFINED glslang_VERSION) + record_build_dependency (GLSLANG_RUNTIME FOUND VERSION "${glslang_VERSION}") + else () + record_build_dependency (GLSLANG_RUNTIME FOUND) + endif () + elseif (_imiv_glslang_include_dir + AND _imiv_glslang_library + AND _imiv_glslang_default_limits_library) + add_library (imiv_glslang_external UNKNOWN IMPORTED) + set_target_properties ( + imiv_glslang_external PROPERTIES + IMPORTED_LOCATION "${_imiv_glslang_library}" + INTERFACE_INCLUDE_DIRECTORIES + "${_imiv_glslang_include_dir}") + add_library (imiv_glslang_resource_limits_external UNKNOWN IMPORTED) + set_target_properties ( + imiv_glslang_resource_limits_external PROPERTIES + IMPORTED_LOCATION + "${_imiv_glslang_default_limits_library}" + INTERFACE_INCLUDE_DIRECTORIES + "${_imiv_glslang_include_dir}") + list (APPEND _imiv_link_libs + imiv_glslang_external + imiv_glslang_resource_limits_external) + set (_imiv_has_runtime_glslang ON) + message (STATUS "imiv: using glslang from explicit include/library paths") + record_build_dependency (GLSLANG_RUNTIME FOUND) + else () + find_path (_imiv_glslang_include_dir + NAMES glslang/Include/glslang_c_interface.h + HINTS ${_imiv_glslang_search_hints} + PATH_SUFFIXES include Include + NO_CACHE) + set (_imiv_glslang_saved_library_suffixes "${CMAKE_FIND_LIBRARY_SUFFIXES}") + if (WIN32) + set (CMAKE_FIND_LIBRARY_SUFFIXES .lib .dll.a) + elseif (APPLE) + set (CMAKE_FIND_LIBRARY_SUFFIXES .dylib .so) + else () + set (CMAKE_FIND_LIBRARY_SUFFIXES .so) + endif () + find_library (_imiv_glslang_library + NAMES glslang glslangd libglslang + HINTS ${_imiv_glslang_search_hints} + PATH_SUFFIXES lib Lib lib64 + NO_CACHE) + find_library (_imiv_glslang_default_limits_library + NAMES glslang-default-resource-limits + glslang-default-resource-limitsd + libglslang-default-resource-limits + HINTS ${_imiv_glslang_search_hints} + PATH_SUFFIXES lib Lib lib64 + NO_CACHE) + set (CMAKE_FIND_LIBRARY_SUFFIXES "${_imiv_glslang_saved_library_suffixes}") + if (_imiv_glslang_include_dir + AND _imiv_glslang_library + AND _imiv_glslang_default_limits_library) + add_library (imiv_glslang_external UNKNOWN IMPORTED) + set_target_properties ( + imiv_glslang_external PROPERTIES + IMPORTED_LOCATION "${_imiv_glslang_library}" + INTERFACE_INCLUDE_DIRECTORIES + "${_imiv_glslang_include_dir}") + add_library (imiv_glslang_resource_limits_external UNKNOWN IMPORTED) + set_target_properties ( + imiv_glslang_resource_limits_external PROPERTIES + IMPORTED_LOCATION + "${_imiv_glslang_default_limits_library}" + INTERFACE_INCLUDE_DIRECTORIES + "${_imiv_glslang_include_dir}") + list (APPEND _imiv_link_libs + imiv_glslang_external + imiv_glslang_resource_limits_external) + set (_imiv_has_runtime_glslang ON) + message (STATUS "imiv: using glslang discovered from include/library search paths") + record_build_dependency (GLSLANG_RUNTIME FOUND) + endif () + endif () + + if (NOT _imiv_has_runtime_glslang) + if (NOT _imiv_glslang_include_dir) + list (APPEND _imiv_runtime_glslang_missing + "glslang/Include/glslang_c_interface.h") + endif () + if (NOT _imiv_glslang_library) + list (APPEND _imiv_runtime_glslang_missing "glslang library") + endif () + if (NOT _imiv_glslang_default_limits_library) + list (APPEND _imiv_runtime_glslang_missing + "glslang-default-resource-limits library") + endif () + string (REPLACE ";" ", " _imiv_runtime_glslang_missing_text + "${_imiv_runtime_glslang_missing}") + if (_imiv_runtime_glslang_explanation STREQUAL "") + if (CMAKE_PREFIX_PATH) + string (REPLACE ";" ", " _imiv_glslang_prefix_path_text + "${CMAKE_PREFIX_PATH}") + set (_imiv_runtime_glslang_explanation + "(runtime shader compilation disabled; missing ${_imiv_runtime_glslang_missing_text}; searched CMAKE_PREFIX_PATH=${_imiv_glslang_prefix_path_text}, Vulkan SDK hints, and default paths)") + elseif (OIIO_IMIV_VULKAN_SDK) + set (_imiv_runtime_glslang_explanation + "(runtime shader compilation disabled; missing ${_imiv_runtime_glslang_missing_text}; searched ${OIIO_IMIV_VULKAN_SDK} and default paths)") + else () + set (_imiv_runtime_glslang_explanation + "(runtime shader compilation disabled; missing ${_imiv_runtime_glslang_missing_text}; set CMAKE_PREFIX_PATH, OIIO_IMIV_VULKAN_SDK, or OIIO_IMIV_GLSLANG_* paths)") + endif () + endif () + record_build_dependency ( + GLSLANG_RUNTIME NOTFOUND + NOT_FOUND_EXPLANATION "${_imiv_runtime_glslang_explanation}") + endif () + else () + record_build_dependency ( + GLSLANG_RUNTIME NOTFOUND + NOT_FOUND_EXPLANATION + "(runtime shader compilation not checked because Vulkan backend is disabled)") + endif () +else () + record_build_dependency ( + Vulkan NOTFOUND + NOT_FOUND_EXPLANATION + "(disabled by OIIO_IMIV_ENABLE_VULKAN=OFF)") + record_build_dependency ( + GLSLANG_RUNTIME NOTFOUND + NOT_FOUND_EXPLANATION + "(runtime shader compilation disabled because Vulkan backend is off)") +endif () + +if (_imiv_want_metal) + find_library (OIIO_IMIV_COCOA_FRAMEWORK Cocoa) + find_library (OIIO_IMIV_METAL_FRAMEWORK Metal) + find_library (OIIO_IMIV_QUARTZCORE_FRAMEWORK QuartzCore) + if (OIIO_IMIV_COCOA_FRAMEWORK + AND OIIO_IMIV_METAL_FRAMEWORK + AND OIIO_IMIV_QUARTZCORE_FRAMEWORK) + list (APPEND _imiv_link_libs + ${OIIO_IMIV_COCOA_FRAMEWORK} + ${OIIO_IMIV_METAL_FRAMEWORK} + ${OIIO_IMIV_QUARTZCORE_FRAMEWORK}) + set (_imiv_enabled_metal ON) + record_build_dependency (Metal FOUND) + elseif (_imiv_enable_metal_mode STREQUAL "ON") + message (FATAL_ERROR + "imiv: Metal backend was requested but required frameworks were not found") + endif () +endif () + +if (_imiv_want_opengl) + find_package (OpenGL QUIET) + if (TARGET OpenGL::GL) + list (APPEND _imiv_link_libs OpenGL::GL) + set (_imiv_enabled_opengl ON) + record_build_dependency (OpenGL FOUND) + elseif (TARGET OpenGL::OpenGL) + list (APPEND _imiv_link_libs OpenGL::OpenGL) + set (_imiv_enabled_opengl ON) + record_build_dependency (OpenGL FOUND) + elseif (_imiv_enable_opengl_mode STREQUAL "ON") + message (FATAL_ERROR + "imiv: OpenGL backend was requested but OpenGL was not found") + endif () +endif () + +if (NOT _imiv_enabled_vulkan + AND NOT _imiv_enabled_metal + AND NOT _imiv_enabled_opengl) + message (STATUS + "\n\n WARNING: no renderer backends were enabled -- 'imiv' will not be built!\n") + return () +endif () + +set (_imiv_selected_renderer "") +if (_imiv_default_renderer_request STREQUAL "auto") + if (APPLE) + if (_imiv_enabled_metal) + set (_imiv_selected_renderer "metal") + elseif (_imiv_enabled_vulkan) + set (_imiv_selected_renderer "vulkan") + elseif (_imiv_enabled_opengl) + set (_imiv_selected_renderer "opengl") + endif () + else () + if (_imiv_enabled_vulkan) + set (_imiv_selected_renderer "vulkan") + elseif (_imiv_enabled_opengl) + set (_imiv_selected_renderer "opengl") + elseif (_imiv_enabled_metal) + set (_imiv_selected_renderer "metal") + endif () + endif () +elseif (_imiv_default_renderer_request STREQUAL "vulkan" AND _imiv_enabled_vulkan) + set (_imiv_selected_renderer "vulkan") +elseif (_imiv_default_renderer_request STREQUAL "metal" AND _imiv_enabled_metal) + set (_imiv_selected_renderer "metal") +elseif (_imiv_default_renderer_request STREQUAL "opengl" AND _imiv_enabled_opengl) + set (_imiv_selected_renderer "opengl") +endif () +if (_imiv_selected_renderer STREQUAL "") + if (APPLE) + if (_imiv_enabled_metal) + set (_imiv_selected_renderer "metal") + elseif (_imiv_enabled_vulkan) + set (_imiv_selected_renderer "vulkan") + else () + set (_imiv_selected_renderer "opengl") + endif () + else () + if (_imiv_enabled_vulkan) + set (_imiv_selected_renderer "vulkan") + elseif (_imiv_enabled_opengl) + set (_imiv_selected_renderer "opengl") + else () + set (_imiv_selected_renderer "metal") + endif () + endif () + if (NOT _imiv_default_renderer_request STREQUAL "auto") + message (STATUS + "imiv: requested build default backend '${_imiv_default_renderer_request}' is not enabled; falling back to ${_imiv_selected_renderer}") + endif () +endif () + +set (_imiv_enabled_backend_count 0) +if (_imiv_enabled_vulkan) + math (EXPR _imiv_enabled_backend_count + "${_imiv_enabled_backend_count} + 1") +endif () +if (_imiv_enabled_metal) + math (EXPR _imiv_enabled_backend_count + "${_imiv_enabled_backend_count} + 1") +endif () +if (_imiv_enabled_opengl) + math (EXPR _imiv_enabled_backend_count + "${_imiv_enabled_backend_count} + 1") +endif () + +set (_imiv_renderer_is_vulkan OFF) +set (_imiv_renderer_is_metal OFF) +set (_imiv_renderer_is_opengl OFF) +if (_imiv_selected_renderer STREQUAL "vulkan") + set (_imiv_renderer_is_vulkan ON) +elseif (_imiv_selected_renderer STREQUAL "metal") + set (_imiv_renderer_is_metal ON) +elseif (_imiv_selected_renderer STREQUAL "opengl") + set (_imiv_renderer_is_opengl ON) +endif () + +set (IMIV_WITH_VULKAN 0) +set (IMIV_WITH_METAL 0) +set (IMIV_WITH_OPENGL 0) +set (IMIV_EMBED_FONTS 0) +set (IMIV_BUILD_DEFAULT_BACKEND_KIND -1) +if (_imiv_enabled_vulkan) + set (IMIV_WITH_VULKAN 1) +endif () +if (_imiv_enabled_metal) + set (IMIV_WITH_METAL 1) +endif () +if (_imiv_enabled_opengl) + set (IMIV_WITH_OPENGL 1) +endif () +if (OIIO_IMIV_EMBED_FONTS) + set (IMIV_EMBED_FONTS 1) +endif () +if (_imiv_renderer_is_vulkan) + set (IMIV_BUILD_DEFAULT_BACKEND_KIND 0) +elseif (_imiv_renderer_is_metal) + set (IMIV_BUILD_DEFAULT_BACKEND_KIND 1) +elseif (_imiv_renderer_is_opengl) + set (IMIV_BUILD_DEFAULT_BACKEND_KIND 2) +endif () + +configure_file ( + "${CMAKE_CURRENT_SOURCE_DIR}/imiv_build_config.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/imiv_build_config.h" + @ONLY) + +message (STATUS + "imiv: enabled renderer backends =" + " vulkan=${_imiv_enabled_vulkan}" + " metal=${_imiv_enabled_metal}" + " opengl=${_imiv_enabled_opengl}") +message (STATUS + "imiv: build default renderer backend = glfw+${_imiv_selected_renderer}") + +set (_imiv_renderer_enabled_sources) +if (_imiv_enabled_vulkan) + list (APPEND _imiv_renderer_enabled_sources ${_imiv_renderer_vulkan_sources}) +endif () +if (_imiv_enabled_metal) + list (APPEND _imiv_renderer_enabled_sources ${_imiv_renderer_metal_sources}) +endif () +if (_imiv_enabled_opengl) + list (APPEND _imiv_renderer_enabled_sources ${_imiv_renderer_opengl_sources}) +endif () +set (_imiv_core_sources + ${_imiv_shared_sources} + ${_imiv_test_engine_integration_sources} + ${_imiv_platform_glfw_sources} + ${_imiv_renderer_enabled_sources} + ${_imiv_embedded_font_headers}) + +set (_imiv_imgui_renderer_sources) +if (_imiv_enabled_vulkan) + list (APPEND _imiv_imgui_renderer_sources + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_vulkan.cpp") +endif () +if (_imiv_enabled_metal) + list (APPEND _imiv_imgui_renderer_sources + "${CMAKE_CURRENT_SOURCE_DIR}/external/imgui_impl_metal_imiv.mm") +endif () +if (_imiv_enabled_opengl) + list (APPEND _imiv_imgui_renderer_sources + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_opengl3.cpp") +endif () + +set (_imiv_imgui_sources + "${OIIO_IMIV_IMGUI_ROOT}/imgui.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_demo.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_draw.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_tables.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_widgets.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/misc/cpp/imgui_stdlib.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_glfw.cpp" + ${_imiv_imgui_renderer_sources}) + +if (MSVC) + set_source_files_properties (${_imiv_imgui_sources} + PROPERTIES COMPILE_OPTIONS "/w") +else () + set_source_files_properties (${_imiv_imgui_sources} + PROPERTIES COMPILE_OPTIONS "-w") +endif () + +fancy_add_executable ( + NAME imiv + SRC + ${_imiv_core_sources} + ${_imiv_imgui_sources} + ${_imiv_test_engine_sources} + INCLUDE_DIRS + "${CMAKE_CURRENT_BINARY_DIR}" + "${OIIO_IMIV_IMGUI_ROOT}" + "${OIIO_IMIV_IMGUI_ROOT}/backends" + "${_imiv_test_engine_dir}" + LINK_LIBRARIES + ${_imiv_link_libs} +) + +source_group ("imgui" FILES ${_imiv_imgui_sources}) +source_group ("imgui_te" FILES + ${_imiv_test_engine_sources} + ${_imiv_test_engine_integration_sources} + "${CMAKE_CURRENT_SOURCE_DIR}/imiv_test_engine.h") +source_group ("Source Files" FILES ${_imiv_shared_sources}) +source_group ("Source Files\\platform\\glfw" FILES ${_imiv_platform_glfw_sources}) +source_group ("Source Files\\renderer\\vulkan" FILES ${_imiv_renderer_vulkan_sources}) +if (_imiv_renderer_metal_sources) + source_group ("Source Files\\renderer\\metal" FILES ${_imiv_renderer_metal_sources}) +endif () +if (_imiv_renderer_opengl_sources) + source_group ("Source Files\\renderer\\opengl" FILES ${_imiv_renderer_opengl_sources}) +endif () + +if (TARGET imiv) + target_compile_definitions (imiv PRIVATE IMGUI_DISABLE_OBSOLETE_FUNCTIONS) + target_compile_definitions (imiv PRIVATE IMIV_SHADER_DIR="${CMAKE_CURRENT_BINARY_DIR}") + if (_imiv_shader_outputs) + target_compile_definitions (imiv PRIVATE IMIV_HAS_COMPUTE_UPLOAD_SHADERS=1) + add_dependencies (imiv imiv_shaders) + else () + target_compile_definitions (imiv PRIVATE IMIV_HAS_COMPUTE_UPLOAD_SHADERS=0) + endif () + if (_imiv_embedded_shader_headers) + target_compile_definitions (imiv PRIVATE IMIV_HAS_EMBEDDED_VULKAN_SHADERS=1) + else () + target_compile_definitions (imiv PRIVATE IMIV_HAS_EMBEDDED_VULKAN_SHADERS=0) + endif () + if (_imiv_renderer_is_vulkan) + target_compile_definitions (imiv PRIVATE IMIV_BACKEND_VULKAN_GLFW=1 + $<$:IMIV_VULKAN_VALIDATION=1>) + elseif (_imiv_renderer_is_metal) + target_compile_definitions (imiv PRIVATE IMIV_BACKEND_METAL_GLFW=1) + elseif (_imiv_renderer_is_opengl) + target_compile_definitions (imiv PRIVATE IMIV_BACKEND_OPENGL_GLFW=1) + endif () + if (APPLE AND _imiv_enabled_opengl) + target_compile_definitions (imiv PRIVATE GL_SILENCE_DEPRECATION) + endif () + if (_imiv_has_runtime_glslang) + target_compile_definitions (imiv PRIVATE IMIV_HAS_GLSLANG_RUNTIME=1) + else () + target_compile_definitions (imiv PRIVATE IMIV_HAS_GLSLANG_RUNTIME=0) + endif () + if (_imiv_test_engine_sources) + target_compile_definitions ( + imiv PRIVATE + IMGUI_ENABLE_TEST_ENGINE + IMGUI_TEST_ENGINE_ENABLE_IMPLOT=0 + IMGUI_TEST_ENGINE_ENABLE_CAPTURE=1 + IMGUI_TEST_ENGINE_ENABLE_STD_FUNCTION=0 + IMGUI_TEST_ENGINE_ENABLE_COROUTINE_STDTHREAD_IMPL=1) + endif () + + if (OIIO_IMIV_USE_NATIVEFILEDIALOG) + if (OIIO_IMIV_NFD_ROOT) + find_package (nfd CONFIG QUIET + PATHS + "${OIIO_IMIV_NFD_ROOT}" + "${OIIO_IMIV_NFD_ROOT}/build" + "${OIIO_IMIV_NFD_ROOT}/install" + "${OIIO_IMIV_NFD_ROOT}/dist") + else () + find_package (nfd CONFIG QUIET) + endif () + if (TARGET nfd::nfd) + target_link_libraries (imiv PRIVATE nfd::nfd) + target_compile_definitions (imiv PRIVATE IMIV_HAS_NFD=1) + message (STATUS "imiv: using nativefiledialog target nfd::nfd") + else () + if (NOT OIIO_IMIV_NFD_INCLUDE_DIR) + find_path (OIIO_IMIV_NFD_INCLUDE_DIR nfd.h + HINTS + "${OIIO_IMIV_NFD_ROOT}/src/include") + endif () + if (NOT OIIO_IMIV_NFD_LIBRARY_RELEASE) + find_library (OIIO_IMIV_NFD_LIBRARY_RELEASE + NAMES nfd nfd_static + HINTS + "${OIIO_IMIV_NFD_ROOT}" + "${OIIO_IMIV_NFD_ROOT}/build" + "${OIIO_IMIV_NFD_ROOT}/lib" + "${OIIO_IMIV_NFD_ROOT}/dist/lib") + endif () + if (OIIO_IMIV_NFD_INCLUDE_DIR AND OIIO_IMIV_NFD_LIBRARY_RELEASE) + add_library (imiv_nfd_external UNKNOWN IMPORTED) + set_target_properties (imiv_nfd_external PROPERTIES + IMPORTED_LOCATION "${OIIO_IMIV_NFD_LIBRARY_RELEASE}" + IMPORTED_LOCATION_RELEASE "${OIIO_IMIV_NFD_LIBRARY_RELEASE}" + INTERFACE_INCLUDE_DIRECTORIES "${OIIO_IMIV_NFD_INCLUDE_DIR}") + if (OIIO_IMIV_NFD_LIBRARY_DEBUG) + set_target_properties (imiv_nfd_external PROPERTIES + IMPORTED_LOCATION_DEBUG "${OIIO_IMIV_NFD_LIBRARY_DEBUG}") + endif () + target_link_libraries (imiv PRIVATE imiv_nfd_external) + target_compile_definitions (imiv PRIVATE IMIV_HAS_NFD=1) + message (STATUS "imiv: using nativefiledialog from explicit include/library paths") + else () + target_compile_definitions (imiv PRIVATE IMIV_HAS_NFD=0) + message (STATUS + "imiv: nativefiledialog requested but not found yet; file dialogs will stay disabled") + endif () + endif () + else () + target_compile_definitions (imiv PRIVATE IMIV_HAS_NFD=0) + endif () + + if (NOT OIIO_IMIV_EMBED_FONTS) + add_custom_command ( + TARGET imiv POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/fonts/Droid_Sans" + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/fonts/Droid_Sans_Mono" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans/DroidSans.ttf" + "$/fonts/Droid_Sans/DroidSans.ttf" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans/droid-lic.txt" + "$/fonts/Droid_Sans/droid-lic.txt" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans_Mono/DroidSansMono.ttf" + "$/fonts/Droid_Sans_Mono/DroidSansMono.ttf" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans_Mono/droid-lic.txt" + "$/fonts/Droid_Sans_Mono/droid-lic.txt" + VERBATIM) + + install (FILES + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans/DroidSans.ttf" + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans/droid-lic.txt" + DESTINATION + "${CMAKE_INSTALL_BINDIR}/fonts/Droid_Sans") + install (FILES + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans_Mono/DroidSansMono.ttf" + "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans_Mono/droid-lic.txt" + DESTINATION + "${CMAKE_INSTALL_BINDIR}/fonts/Droid_Sans_Mono") + endif () +endif () + +if (MSVC) + set_source_files_properties (${_imiv_test_engine_sources} + PROPERTIES COMPILE_OPTIONS "/w") +else () + set_source_files_properties (${_imiv_test_engine_sources} + PROPERTIES COMPILE_OPTIONS "-w") +endif () + +include ("${CMAKE_CURRENT_LIST_DIR}/cmake/imiv_tests.cmake") diff --git a/src/imiv/README.md b/src/imiv/README.md new file mode 100644 index 0000000000..c208615c76 --- /dev/null +++ b/src/imiv/README.md @@ -0,0 +1,251 @@ +# imiv Verification + +Canonical cross-platform verifier: + +```bash +python src/imiv/tools/imiv_backend_verify.py ... +``` + +Use the same runner everywhere. It selects the right regression set for the +requested backend. + +Linux / WSL Vulkan: + +```bash +python src/imiv/tools/imiv_backend_verify.py \ + --backend vulkan \ + --build-dir build_u \ + --out-dir verify_vulkan \ + --trace +``` + +Linux / WSL OpenGL: + +```bash +python src/imiv/tools/imiv_backend_verify.py \ + --backend opengl \ + --build-dir build_u \ + --out-dir verify_opengl \ + --trace +``` + +macOS Metal: + +```bash +python3 src/imiv/tools/imiv_backend_verify.py \ + --backend metal \ + --build-dir build \ + --out-dir verify_metal \ + --trace +``` + +macOS OpenGL: + +```bash +python3 src/imiv/tools/imiv_backend_verify.py \ + --backend opengl \ + --build-dir build \ + --out-dir verify_opengl \ + --trace +``` + +macOS Vulkan, if MoltenVK is available: + +```bash +python3 src/imiv/tools/imiv_backend_verify.py \ + --backend vulkan \ + --build-dir build \ + --out-dir verify_vulkan \ + --trace +``` + +Windows Vulkan: + +```bat +python src\imiv\tools\imiv_backend_verify.py ^ + --backend vulkan ^ + --build-dir build ^ + --config Debug ^ + --out-dir verify_vulkan ^ + --trace +``` + +Windows OpenGL: + +```bat +python src\imiv\tools\imiv_backend_verify.py ^ + --backend opengl ^ + --build-dir build ^ + --config Debug ^ + --out-dir verify_opengl ^ + --trace +``` + +If the build is already up to date, add: + +```text +--skip-configure --skip-build +``` + +If you run through `uv` from the repo root, use: + +```bash +uv run --no-project python src/imiv/tools/imiv_backend_verify.py ... +``` + +Optional backend-wide `ctest` entries from one multi-backend build: + +```bash +cmake -S . -B build_u \ + -D OIIO_IMIV_ENABLE_VULKAN=AUTO \ + -D OIIO_IMIV_ENABLE_OPENGL=AUTO \ + -D OIIO_IMIV_ENABLE_METAL=OFF \ + -D OIIO_IMIV_DEFAULT_RENDERER=vulkan \ + -D OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST=ON +``` + +```bash +ctest --test-dir build_u -N | rg imiv_backend_verify +``` + +Embedded binary assets: + +- `OIIO_IMIV_EMBED_FONTS=ON` is the default and embeds `DroidSans.ttf` and + `DroidSansMono.ttf` into the `imiv` binary. +- `OIIO_IMIV_EMBED_FONTS=OFF` keeps the older runtime `fonts/` directory + behavior, with fallback to Dear ImGui's default font if those files are not + present. +- Vulkan static upload and preview shaders are embedded into the binary at + build time from `src/imiv/shaders/`. +- Vulkan OCIO preview shaders are still generated at runtime from the active + OCIO configuration. +- OpenGL and Metal continue to compile their native shader source at runtime; + they do not use embedded SPIR-V blobs. + +Focused backend-selector regression from a multi-backend build: + +```bash +ctest --test-dir build_u -V -R '^imiv_backend_preferences_regression$' +``` + +Focused multi-view regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_multiview_regression$' +``` + +Focused multi-file Image List regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_image_list_regression$' +``` + +Focused Image List interaction regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_image_list_interaction_regression$' +``` + +This covers: + +- Image List single-click into the active view +- open-in-new-view +- close-in-active-view +- remove-from-session, including the one-image-remaining case + +Focused Image List centering regression: + +```bash +python3 src/imiv/tools/imiv_image_list_center_regression.py \ + --bin build/bin/imiv \ + --cwd build/bin \ + --backend opengl \ + --oiiotool build/bin/oiiotool \ + --env-script build/imiv_env.sh \ + --out-dir build/imiv_captures/image_list_center_regression +``` + +Focused OpenGL multi-open OCIO regression: + +```bash +python3 src/imiv/tools/imiv_opengl_multiopen_ocio_regression.py \ + --bin build/bin/imiv \ + --cwd build/bin \ + --backend opengl \ + --env-script build/imiv_env.sh \ + --out-dir build/imiv_captures/opengl_multiopen_ocio_regression +``` + +Focused folder-open regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_open_folder_regression$' +``` + +Focused drag/drop regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_drag_drop_regression$' +``` + +Focused per-view recipe regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_view_recipe_regression$' +``` + +Focused Vulkan/OpenGL/Metal large-image switch regressions: + +```bash +ctest --test-dir build_u -V -R '^imiv_large_image_switch_regression_(vulkan|opengl|metal)$' +``` + +Focused Save Selection export regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_save_selection_regression$' +``` + +Focused Export Selection As regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_export_selection_regression$' +``` + +Focused Export As regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_save_window_regression$' +``` + +Focused Export As OCIO regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_save_window_ocio_regression$' +``` + +Focused developer-menu regression: + +```bash +ctest --test-dir build -V -R '^imiv_developer_menu_regression$' +``` + +Runtime developer mode controls: + +- `--devmode` enables the `Developer` menu for the current launch +- `OIIO_DEVMODE=1|0|true|false|on|off|yes|no` overrides the default +- Debug builds default to developer mode on + +Main output logs: + +- `verify_smoke.log` +- `verify_rgb.log` +- `verify_ux.log` +- `verify_sampling.log` +- `verify_ocio_missing.log` +- `verify_ocio_config_source.log` +- `verify_ocio_live.log` +- `verify_ocio_live_display.log` + +Backend-specific specialized regressions such as Metal orientation still write +their own dedicated logs outside the shared verifier. diff --git a/src/imiv/cmake/imiv_sources.cmake b/src/imiv/cmake/imiv_sources.cmake new file mode 100644 index 0000000000..e3b086d8f8 --- /dev/null +++ b/src/imiv/cmake/imiv_sources.cmake @@ -0,0 +1,306 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +set (_imiv_shared_sources + imiv_actions.cpp + imiv_action_dispatch.cpp + imiv_app.cpp + imiv_aux_windows.cpp + imiv_developer_tools.cpp + imiv_file_actions.cpp + imiv_file_dialog.cpp + imiv_frame.cpp + imiv_image_library.cpp + imiv_image_view.cpp + imiv_loaded_image.cpp + imiv_menu.cpp + imiv_navigation.cpp + imiv_ocio.cpp + imiv_parse.cpp + imiv_persistence.cpp + imiv_overlays.cpp + imiv_probe_overlay.cpp + imiv_preview_shader_text.cpp + imiv_renderer.cpp + imiv_shader_compile.cpp + imiv_style.cpp + imiv_tiling.cpp + imiv_upload_types.cpp + imiv_ui.cpp + imiv_viewer.cpp + imiv_workspace_ui.cpp + imiv_main.cpp) + +set (_imiv_test_engine_integration_sources + imiv_test_engine.cpp) + +set (_imiv_platform_glfw_sources + imiv_drag_drop.cpp + imiv_platform_glfw.cpp) +if (APPLE) + list (APPEND _imiv_platform_glfw_sources + external/dnd_glfw/dnd_glfw_macos.mm) +endif () + +set (_imiv_renderer_vulkan_sources + imiv_renderer_vulkan.cpp + imiv_capture.cpp + imiv_vulkan_setup.cpp + imiv_vulkan_resource_utils.cpp + imiv_vulkan_shader_utils.cpp + imiv_vulkan_ocio.cpp + imiv_vulkan_preview.cpp + imiv_vulkan_runtime.cpp + imiv_vulkan_texture.cpp) + +set (_imiv_renderer_metal_sources + imiv_renderer_metal.mm) +set (_imiv_renderer_opengl_sources + imiv_renderer_opengl.cpp) +set (_imiv_renderer_enabled_sources) +set (_imiv_core_sources) + +set (_imiv_shader_src "${CMAKE_CURRENT_SOURCE_DIR}/shaders/imiv_upload_to_rgba.comp") +set (_imiv_preview_vert_src "${CMAKE_CURRENT_SOURCE_DIR}/shaders/imiv_preview.vert") +set (_imiv_preview_frag_src "${CMAKE_CURRENT_SOURCE_DIR}/shaders/imiv_preview.frag") +set (_imiv_shader_spv_16f "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba16f.comp.spv") +set (_imiv_shader_spv_32f "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba32f.comp.spv") +set (_imiv_shader_spv_16f_fp64 "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba16f_fp64.comp.spv") +set (_imiv_shader_spv_32f_fp64 "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba32f_fp64.comp.spv") +set (_imiv_preview_vert_spv "${CMAKE_CURRENT_BINARY_DIR}/imiv_preview.vert.spv") +set (_imiv_preview_frag_spv "${CMAKE_CURRENT_BINARY_DIR}/imiv_preview.frag.spv") +set (_imiv_shader_hdr_16f "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba16f_spv.h") +set (_imiv_shader_hdr_32f "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba32f_spv.h") +set (_imiv_shader_hdr_16f_fp64 "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba16f_fp64_spv.h") +set (_imiv_shader_hdr_32f_fp64 "${CMAKE_CURRENT_BINARY_DIR}/imiv_upload_to_rgba32f_fp64_spv.h") +set (_imiv_preview_vert_hdr "${CMAKE_CURRENT_BINARY_DIR}/imiv_preview_vert_spv.h") +set (_imiv_preview_frag_hdr "${CMAKE_CURRENT_BINARY_DIR}/imiv_preview_frag_spv.h") +set (_imiv_shader_outputs) +set (_imiv_embedded_shader_headers) +set (_imiv_font_ui_ttf "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans/DroidSans.ttf") +set (_imiv_font_mono_ttf "${PROJECT_SOURCE_DIR}/src/fonts/Droid_Sans_Mono/DroidSansMono.ttf") +set (_imiv_font_ui_hdr "${CMAKE_CURRENT_BINARY_DIR}/imiv_font_droidsans_ttf.h") +set (_imiv_font_mono_hdr "${CMAKE_CURRENT_BINARY_DIR}/imiv_font_droidsansmono_ttf.h") +set (_imiv_embedded_font_headers) + +function (_imiv_add_embedded_spirv_header input_spv output_hdr symbol_name) + add_custom_command ( + OUTPUT "${output_hdr}" + COMMAND ${CMAKE_COMMAND} + -DINPUT="${input_spv}" + -DOUTPUT="${output_hdr}" + -DSYMBOL_NAME="${symbol_name}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/embed_spirv_header.cmake" + DEPENDS + "${input_spv}" + "${CMAKE_CURRENT_SOURCE_DIR}/embed_spirv_header.cmake" + COMMENT "imiv: embedding Vulkan shader ${symbol_name}") +endfunction () + +function (_imiv_add_embedded_binary_header input_bin output_hdr symbol_name) + add_custom_command ( + OUTPUT "${output_hdr}" + COMMAND ${CMAKE_COMMAND} + -DINPUT="${input_bin}" + -DOUTPUT="${output_hdr}" + -DSYMBOL_NAME="${symbol_name}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/embed_binary_header.cmake" + DEPENDS + "${input_bin}" + "${CMAKE_CURRENT_SOURCE_DIR}/embed_binary_header.cmake" + COMMENT "imiv: embedding binary asset ${symbol_name}") +endfunction () + +if (OIIO_IMIV_EMBED_FONTS) + if (NOT EXISTS "${_imiv_font_ui_ttf}" OR NOT EXISTS "${_imiv_font_mono_ttf}") + message (FATAL_ERROR + "imiv: OIIO_IMIV_EMBED_FONTS=ON requires ${_imiv_font_ui_ttf} and ${_imiv_font_mono_ttf}") + endif () + _imiv_add_embedded_binary_header ("${_imiv_font_ui_ttf}" + "${_imiv_font_ui_hdr}" + "g_imiv_font_droidsans_ttf") + _imiv_add_embedded_binary_header ("${_imiv_font_mono_ttf}" + "${_imiv_font_mono_hdr}" + "g_imiv_font_droidsansmono_ttf") + list (APPEND _imiv_embedded_font_headers + "${_imiv_font_ui_hdr}" + "${_imiv_font_mono_hdr}") +endif () + +if (_imiv_want_vulkan) + find_program (OIIO_IMIV_GLSLC_EXECUTABLE + NAMES glslc + HINTS + "${OIIO_IMIV_VULKAN_SDK}/bin") + if (OIIO_IMIV_GLSLC_EXECUTABLE AND EXISTS "${_imiv_shader_src}") + add_custom_command ( + OUTPUT "${_imiv_shader_spv_16f}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_CURRENT_BINARY_DIR}" + COMMAND ${OIIO_IMIV_GLSLC_EXECUTABLE} + -fshader-stage=comp + -DIMIV_OUTPUT_16F=1 + -DIMIV_ENABLE_FP64=0 + -O + "${_imiv_shader_src}" + -o "${_imiv_shader_spv_16f}" + DEPENDS "${_imiv_shader_src}" + COMMENT "imiv: compiling Vulkan compute shader (rgba16f)") + add_custom_command ( + OUTPUT "${_imiv_shader_spv_32f}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_CURRENT_BINARY_DIR}" + COMMAND ${OIIO_IMIV_GLSLC_EXECUTABLE} + -fshader-stage=comp + -DIMIV_OUTPUT_16F=0 + -DIMIV_ENABLE_FP64=0 + -O + "${_imiv_shader_src}" + -o "${_imiv_shader_spv_32f}" + DEPENDS "${_imiv_shader_src}" + COMMENT "imiv: compiling Vulkan compute shader (rgba32f)") + add_custom_command ( + OUTPUT "${_imiv_shader_spv_16f_fp64}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_CURRENT_BINARY_DIR}" + COMMAND ${OIIO_IMIV_GLSLC_EXECUTABLE} + -fshader-stage=comp + -DIMIV_OUTPUT_16F=1 + -DIMIV_ENABLE_FP64=1 + -O + "${_imiv_shader_src}" + -o "${_imiv_shader_spv_16f_fp64}" + DEPENDS "${_imiv_shader_src}" + COMMENT "imiv: compiling Vulkan compute shader (rgba16f, fp64)") + add_custom_command ( + OUTPUT "${_imiv_shader_spv_32f_fp64}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_CURRENT_BINARY_DIR}" + COMMAND ${OIIO_IMIV_GLSLC_EXECUTABLE} + -fshader-stage=comp + -DIMIV_OUTPUT_16F=0 + -DIMIV_ENABLE_FP64=1 + -O + "${_imiv_shader_src}" + -o "${_imiv_shader_spv_32f_fp64}" + DEPENDS "${_imiv_shader_src}" + COMMENT "imiv: compiling Vulkan compute shader (rgba32f, fp64)") + add_custom_command ( + OUTPUT "${_imiv_preview_vert_spv}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_CURRENT_BINARY_DIR}" + COMMAND ${OIIO_IMIV_GLSLC_EXECUTABLE} + -fshader-stage=vert + -O + "${_imiv_preview_vert_src}" + -o "${_imiv_preview_vert_spv}" + DEPENDS "${_imiv_preview_vert_src}" + COMMENT "imiv: compiling Vulkan preview vertex shader") + add_custom_command ( + OUTPUT "${_imiv_preview_frag_spv}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_CURRENT_BINARY_DIR}" + COMMAND ${OIIO_IMIV_GLSLC_EXECUTABLE} + -fshader-stage=frag + -O + "${_imiv_preview_frag_src}" + -o "${_imiv_preview_frag_spv}" + DEPENDS "${_imiv_preview_frag_src}" + COMMENT "imiv: compiling Vulkan preview fragment shader") + _imiv_add_embedded_spirv_header ( + "${_imiv_shader_spv_16f}" + "${_imiv_shader_hdr_16f}" + "g_imiv_upload_to_rgba16f_spv") + _imiv_add_embedded_spirv_header ( + "${_imiv_shader_spv_32f}" + "${_imiv_shader_hdr_32f}" + "g_imiv_upload_to_rgba32f_spv") + _imiv_add_embedded_spirv_header ( + "${_imiv_shader_spv_16f_fp64}" + "${_imiv_shader_hdr_16f_fp64}" + "g_imiv_upload_to_rgba16f_fp64_spv") + _imiv_add_embedded_spirv_header ( + "${_imiv_shader_spv_32f_fp64}" + "${_imiv_shader_hdr_32f_fp64}" + "g_imiv_upload_to_rgba32f_fp64_spv") + _imiv_add_embedded_spirv_header ( + "${_imiv_preview_vert_spv}" + "${_imiv_preview_vert_hdr}" + "g_imiv_preview_vert_spv") + _imiv_add_embedded_spirv_header ( + "${_imiv_preview_frag_spv}" + "${_imiv_preview_frag_hdr}" + "g_imiv_preview_frag_spv") + set (_imiv_shader_outputs + "${_imiv_shader_spv_16f}" + "${_imiv_shader_spv_32f}" + "${_imiv_shader_spv_16f_fp64}" + "${_imiv_shader_spv_32f_fp64}" + "${_imiv_preview_vert_spv}" + "${_imiv_preview_frag_spv}") + set (_imiv_embedded_shader_headers + "${_imiv_shader_hdr_16f}" + "${_imiv_shader_hdr_32f}" + "${_imiv_shader_hdr_16f_fp64}" + "${_imiv_shader_hdr_32f_fp64}" + "${_imiv_preview_vert_hdr}" + "${_imiv_preview_frag_hdr}") + add_custom_target (imiv_shaders DEPENDS + ${_imiv_shader_outputs} + ${_imiv_embedded_shader_headers}) + else () + message (STATUS + "imiv: glslc not found; Vulkan compute upload shader generation disabled") + endif () +endif () + +set (_imiv_imgui_sources + "${OIIO_IMIV_IMGUI_ROOT}/imgui.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_demo.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_draw.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_tables.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/imgui_widgets.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/misc/cpp/imgui_stdlib.cpp" + "${OIIO_IMIV_IMGUI_ROOT}/backends/imgui_impl_glfw.cpp" + ${_imiv_imgui_renderer_sources}) + +set (_imiv_test_engine_sources) +set (_imiv_test_engine_dir "") +if (OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE) + if (OIIO_IMIV_TEST_ENGINE_ROOT) + if (EXISTS "${OIIO_IMIV_TEST_ENGINE_ROOT}/imgui_te_engine.h") + set (_imiv_test_engine_dir "${OIIO_IMIV_TEST_ENGINE_ROOT}") + elseif (EXISTS + "${OIIO_IMIV_TEST_ENGINE_ROOT}/imgui_test_engine/imgui_te_engine.h") + set (_imiv_test_engine_dir "${OIIO_IMIV_TEST_ENGINE_ROOT}/imgui_test_engine") + endif () + elseif (DEFINED ENV{IMGUI_TEST_ENGINE_ROOT} + AND NOT "$ENV{IMGUI_TEST_ENGINE_ROOT}" STREQUAL "") + if (EXISTS "$ENV{IMGUI_TEST_ENGINE_ROOT}/imgui_te_engine.h") + set (_imiv_test_engine_dir "$ENV{IMGUI_TEST_ENGINE_ROOT}") + elseif (EXISTS + "$ENV{IMGUI_TEST_ENGINE_ROOT}/imgui_test_engine/imgui_te_engine.h") + set (_imiv_test_engine_dir "$ENV{IMGUI_TEST_ENGINE_ROOT}/imgui_test_engine") + endif () + endif () + + if (_imiv_test_engine_dir) + set (_imiv_test_engine_sources + "${_imiv_test_engine_dir}/imgui_te_context.cpp" + "${_imiv_test_engine_dir}/imgui_te_coroutine.cpp" + "${_imiv_test_engine_dir}/imgui_te_engine.cpp" + "${_imiv_test_engine_dir}/imgui_te_exporters.cpp" + "${_imiv_test_engine_dir}/imgui_te_perftool.cpp" + "${_imiv_test_engine_dir}/imgui_te_ui.cpp" + "${_imiv_test_engine_dir}/imgui_te_utils.cpp" + "${_imiv_test_engine_dir}/imgui_capture_tool.cpp") + record_build_dependency (ImGuiTestEngine FOUND) + else () + record_build_dependency ( + ImGuiTestEngine NOTFOUND + NOT_FOUND_EXPLANATION + "(sources not found under ${OIIO_IMIV_TEST_ENGINE_ROOT})") + message (STATUS + "imiv: Dear ImGui Test Engine not found; test automation support will be disabled") + endif () +else () + record_build_dependency ( + ImGuiTestEngine NOTFOUND + NOT_FOUND_EXPLANATION + "(disabled by OIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE=OFF)") +endif () diff --git a/src/imiv/cmake/imiv_tests.cmake b/src/imiv/cmake/imiv_tests.cmake new file mode 100644 index 0000000000..79d0558d19 --- /dev/null +++ b/src/imiv/cmake/imiv_tests.cmake @@ -0,0 +1,689 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +if (TARGET imiv + AND OIIO_BUILD_TESTS + AND BUILD_TESTING + AND OIIO_IMIV_ADD_UPLOAD_SMOKE_CTEST + AND UNIX) + find_program (OIIO_IMIV_BASH_EXECUTABLE NAMES bash) + if (OIIO_IMIV_BASH_EXECUTABLE) + add_test ( + NAME imiv_upload_smoke + COMMAND + "${OIIO_IMIV_BASH_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_upload_corpus_ctest.sh" + "${CMAKE_BINARY_DIR}") + set_tests_properties ( + imiv_upload_smoke PROPERTIES + LABELS "imiv;imiv_upload_smoke;gui" + TIMEOUT 1800) + else () + message (STATUS + "imiv: OIIO_IMIV_ADD_UPLOAD_SMOKE_CTEST requested but no bash executable was found") + endif () +endif () + +set (OIIO_IMIV_TEST_OCIO_CONFIG "" + CACHE STRING + "Optional external OCIO config for imiv live-update regressions") +set (_imiv_ocio_live_update_config "ocio://default") +if (NOT "${OIIO_IMIV_TEST_OCIO_CONFIG}" STREQUAL "") + set (_imiv_ocio_live_update_config "${OIIO_IMIV_TEST_OCIO_CONFIG}") +endif () +set (_imiv_ocio_config_source_input "ocio://default") +set (_imiv_ctest_default_backend_args --backend "${_imiv_selected_renderer}") +set (_imiv_ctest_stable_ui_backend_args ${_imiv_ctest_default_backend_args}) +if (APPLE AND _imiv_enabled_metal) + set (_imiv_ctest_stable_ui_backend_args --backend metal) +elseif ((TARGET OpenGL::GL OR TARGET OpenGL::OpenGL) AND _imiv_enabled_opengl) + set (_imiv_ctest_stable_ui_backend_args --backend opengl) +elseif (_imiv_enabled_vulkan) + set (_imiv_ctest_stable_ui_backend_args --backend vulkan) +endif () +if (TARGET imiv + AND OIIO_BUILD_TESTS + AND BUILD_TESTING + AND Python3_EXECUTABLE + AND _imiv_test_engine_sources) + if (_imiv_renderer_is_vulkan AND _imiv_has_runtime_glslang) + add_test ( + NAME imiv_ocio_live_update_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_live_update_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/ocio_live_update_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/ocio_live_update_regression/ocio_live_input.exr" + --ocio-config "${_imiv_ocio_live_update_config}" + --switch-mode view) + set_tests_properties ( + imiv_ocio_live_update_regression PROPERTIES + LABELS "imiv;imiv_ocio;gui" + TIMEOUT 300) + + add_test ( + NAME imiv_ocio_live_display_update_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_live_update_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/ocio_live_display_update_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/ocio_live_display_update_regression/ocio_live_input.exr" + --ocio-config "${_imiv_ocio_live_update_config}" + --switch-mode display) + set_tests_properties ( + imiv_ocio_live_display_update_regression PROPERTIES + LABELS "imiv;imiv_ocio;gui" + TIMEOUT 300) + elseif (_imiv_renderer_is_opengl) + add_test ( + NAME imiv_opengl_ocio_live_update_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_live_update_regression.py" + --bin "$" + --cwd "$" + --backend opengl + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/opengl_ocio_live_update_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/opengl_ocio_live_update_regression/ocio_live_input.exr" + --ocio-config "${_imiv_ocio_live_update_config}" + --switch-mode view) + set_tests_properties ( + imiv_opengl_ocio_live_update_regression PROPERTIES + LABELS "imiv;imiv_ocio;imiv_opengl;gui" + TIMEOUT 300) + + add_test ( + NAME imiv_opengl_ocio_live_display_update_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_live_update_regression.py" + --bin "$" + --cwd "$" + --backend opengl + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/opengl_ocio_live_display_update_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/opengl_ocio_live_display_update_regression/ocio_live_input.exr" + --ocio-config "${_imiv_ocio_live_update_config}" + --switch-mode display) + set_tests_properties ( + imiv_opengl_ocio_live_display_update_regression PROPERTIES + LABELS "imiv;imiv_ocio;imiv_opengl;gui" + TIMEOUT 300) + elseif (_imiv_renderer_is_metal) + add_test ( + NAME imiv_metal_ocio_live_update_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_live_update_regression.py" + --bin "$" + --cwd "$" + --backend metal + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/metal_ocio_live_update_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/metal_ocio_live_update_regression/ocio_live_input.exr" + --ocio-config "${_imiv_ocio_live_update_config}" + --switch-mode view) + set_tests_properties ( + imiv_metal_ocio_live_update_regression PROPERTIES + LABELS "imiv;imiv_ocio;imiv_metal;gui" + TIMEOUT 300) + + add_test ( + NAME imiv_metal_ocio_live_display_update_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_live_update_regression.py" + --bin "$" + --cwd "$" + --backend metal + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/metal_ocio_live_display_update_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/metal_ocio_live_display_update_regression/ocio_live_input.exr" + --ocio-config "${_imiv_ocio_live_update_config}" + --switch-mode display) + set_tests_properties ( + imiv_metal_ocio_live_display_update_regression PROPERTIES + LABELS "imiv;imiv_ocio;imiv_metal;gui" + TIMEOUT 300) + endif () +endif () + +if (TARGET imiv + AND OIIO_BUILD_TESTS + AND BUILD_TESTING + AND Python3_EXECUTABLE + AND _imiv_test_engine_sources) + if (_imiv_renderer_is_opengl) + add_test ( + NAME imiv_opengl_smoke_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_opengl_smoke_regression.py" + --bin "$" + --cwd "$" + --backend opengl + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/opengl_smoke_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_opengl_smoke_regression PROPERTIES + LABELS "imiv;imiv_opengl;gui" + TIMEOUT 180) + + add_test ( + NAME imiv_opengl_multiopen_ocio_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_opengl_multiopen_ocio_regression.py" + --bin "$" + --cwd "$" + --backend opengl + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/opengl_multiopen_ocio_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_opengl_multiopen_ocio_regression PROPERTIES + LABELS "imiv;imiv_opengl;imiv_ocio;gui" + TIMEOUT 180) + elseif (_imiv_renderer_is_metal) + add_test ( + NAME imiv_metal_screenshot_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_metal_screenshot_regression.py" + --bin "$" + --cwd "$" + --backend metal + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/metal_screenshot_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_metal_screenshot_regression PROPERTIES + LABELS "imiv;imiv_metal;gui" + TIMEOUT 180) + + if (TARGET oiiotool) + add_test ( + NAME imiv_metal_orientation_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_metal_orientation_regression.py" + --bin "$" + --cwd "$" + --backend metal + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --oiiotool "$" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/metal_orientation_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_metal_orientation_regression PROPERTIES + LABELS "imiv;imiv_metal;gui" + TIMEOUT 180) + endif () + endif () + + add_test ( + NAME imiv_ocio_config_source_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_config_source_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/ocio_config_source_regression" + --ocio-config "${_imiv_ocio_config_source_input}") + set_tests_properties ( + imiv_ocio_config_source_regression PROPERTIES + LABELS "imiv;imiv_ocio;gui" + TIMEOUT 240 + RUN_SERIAL TRUE) + + add_test ( + NAME imiv_ocio_missing_fallback_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ocio_missing_fallback_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/ocio_missing_fallback_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_ocio_missing_fallback_regression PROPERTIES + LABELS "imiv;imiv_ocio;gui" + TIMEOUT 180) + + add_test ( + NAME imiv_developer_menu_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_developer_menu_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/developer_menu_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_developer_menu_regression PROPERTIES + LABELS "imiv;imiv_devmode;gui" + TIMEOUT 120) + + add_test ( + NAME imiv_area_probe_closeup_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_area_probe_closeup_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/area_probe_closeup_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_area_probe_closeup_regression PROPERTIES + LABELS "imiv;gui" + TIMEOUT 120) + + add_test ( + NAME imiv_auto_subimage_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_auto_subimage_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/auto_subimage_regression" + --image "${CMAKE_BINARY_DIR}/imiv_captures/auto_subimage_regression/auto_subimages.tif") + set_tests_properties ( + imiv_auto_subimage_regression PROPERTIES + LABELS "imiv;gui" + TIMEOUT 180) + + add_test ( + NAME imiv_ux_actions_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_ux_actions_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/ux_actions_regression") + set_tests_properties ( + imiv_ux_actions_regression PROPERTIES + LABELS "imiv;gui" + TIMEOUT 360) + + add_test ( + NAME imiv_multiview_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_multiview_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/multiview_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_multiview_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + + add_test ( + NAME imiv_image_list_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_image_list_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/image_list_regression") + set_tests_properties ( + imiv_image_list_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + + add_test ( + NAME imiv_image_list_interaction_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_image_list_interaction_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_stable_ui_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/image_list_interaction_regression") + set_tests_properties ( + imiv_image_list_interaction_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + + if (TARGET oiiotool) + add_test ( + NAME imiv_image_list_center_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_image_list_center_regression.py" + --bin "$" + --cwd "$" + --backend opengl + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/image_list_center_regression") + set_tests_properties ( + imiv_image_list_center_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + endif () + + add_test ( + NAME imiv_drag_drop_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_drag_drop_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_stable_ui_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/drag_drop_regression") + set_tests_properties ( + imiv_drag_drop_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + + add_test ( + NAME imiv_view_recipe_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_view_recipe_regression.py" + --bin "$" + ${_imiv_ctest_stable_ui_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/view_recipe_regression") + set_tests_properties ( + imiv_view_recipe_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + + add_test ( + NAME imiv_open_folder_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_open_folder_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/open_folder_regression") + set_tests_properties ( + imiv_open_folder_regression PROPERTIES + LABELS "imiv;gui;imiv_multiview" + TIMEOUT 180) + + add_test ( + NAME imiv_save_selection_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_save_selection_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_stable_ui_backend_args} + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/save_selection_regression") + set_tests_properties ( + imiv_save_selection_regression PROPERTIES + LABELS "imiv;gui;imiv_export" + TIMEOUT 180) + + add_test ( + NAME imiv_export_selection_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_export_selection_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_stable_ui_backend_args} + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/export_selection_regression") + set_tests_properties ( + imiv_export_selection_regression PROPERTIES + LABELS "imiv;gui;imiv_export" + TIMEOUT 180) + + add_test ( + NAME imiv_save_window_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_save_window_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_stable_ui_backend_args} + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/save_window_regression") + set_tests_properties ( + imiv_save_window_regression PROPERTIES + LABELS "imiv;gui;imiv_export" + TIMEOUT 180) + + add_test ( + NAME imiv_save_window_ocio_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_save_window_ocio_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_stable_ui_backend_args} + --oiiotool "$" + --idiff "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/save_window_ocio_regression") + set_tests_properties ( + imiv_save_window_ocio_regression PROPERTIES + LABELS "imiv;gui;imiv_export;imiv_ocio" + TIMEOUT 180) + + add_test ( + NAME imiv_rgb_input_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_rgb_input_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/rgb_input_regression" + --source-image "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_rgb_input_regression PROPERTIES + LABELS "imiv;imiv_rgb;gui" + TIMEOUT 120) + + add_test ( + NAME imiv_sampling_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_sampling_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/sampling_regression") + set_tests_properties ( + imiv_sampling_regression PROPERTIES + LABELS "imiv;imiv_sampling;gui" + TIMEOUT 180) + + if (_imiv_enabled_vulkan) + add_test ( + NAME imiv_large_image_switch_regression_vulkan + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_large_image_switch_regression.py" + --bin "$" + --cwd "$" + --backend vulkan + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/large_image_switch_regression_vulkan") + set_tests_properties ( + imiv_large_image_switch_regression_vulkan PROPERTIES + LABELS "imiv;gui;imiv_vulkan;imiv_large" + TIMEOUT 300) + endif () + + if (_imiv_enabled_opengl) + add_test ( + NAME imiv_large_image_switch_regression_opengl + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_large_image_switch_regression.py" + --bin "$" + --cwd "$" + --backend opengl + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/large_image_switch_regression_opengl") + set_tests_properties ( + imiv_large_image_switch_regression_opengl PROPERTIES + LABELS "imiv;gui;imiv_opengl;imiv_large" + TIMEOUT 300) + endif () + + if (_imiv_enabled_metal) + add_test ( + NAME imiv_large_image_switch_regression_metal + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_large_image_switch_regression.py" + --bin "$" + --cwd "$" + --backend metal + --oiiotool "$" + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/large_image_switch_regression_metal") + set_tests_properties ( + imiv_large_image_switch_regression_metal PROPERTIES + LABELS "imiv;gui;imiv_metal;imiv_large" + TIMEOUT 300) + endif () + + if (_imiv_enabled_backend_count GREATER 1) + add_test ( + NAME imiv_backend_preferences_regression + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_backend_preferences_regression.py" + --bin "$" + --cwd "$" + ${_imiv_ctest_default_backend_args} + --env-script "${CMAKE_BINARY_DIR}/imiv_env.sh" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/backend_preferences_regression" + --open "${PROJECT_SOURCE_DIR}/ASWF/logos/openimageio-stacked-gradient.png") + set_tests_properties ( + imiv_backend_preferences_regression PROPERTIES + LABELS "imiv;gui;imiv_backend" + TIMEOUT 180 + SKIP_RETURN_CODE 77) + endif () + + if (OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST) + if (_imiv_enabled_vulkan) + add_test ( + NAME imiv_backend_verify_vulkan + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_backend_verify.py" + --backend vulkan + --build-dir "${CMAKE_BINARY_DIR}" + --config "$" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/backend_verify_vulkan" + --skip-configure + --skip-build) + set_tests_properties ( + imiv_backend_verify_vulkan PROPERTIES + LABELS "imiv;imiv_backend_verify;imiv_vulkan;gui" + TIMEOUT 1800 + RUN_SERIAL TRUE) + endif () + if (_imiv_enabled_opengl) + add_test ( + NAME imiv_backend_verify_opengl + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_backend_verify.py" + --backend opengl + --build-dir "${CMAKE_BINARY_DIR}" + --config "$" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/backend_verify_opengl" + --skip-configure + --skip-build) + set_tests_properties ( + imiv_backend_verify_opengl PROPERTIES + LABELS "imiv;imiv_backend_verify;imiv_opengl;gui" + TIMEOUT 1800 + RUN_SERIAL TRUE) + endif () + if (_imiv_enabled_metal) + add_test ( + NAME imiv_backend_verify_metal + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/imiv_backend_verify.py" + --backend metal + --build-dir "${CMAKE_BINARY_DIR}" + --config "$" + --out-dir "${CMAKE_BINARY_DIR}/imiv_captures/backend_verify_metal" + --skip-configure + --skip-build) + set_tests_properties ( + imiv_backend_verify_metal PROPERTIES + LABELS "imiv;imiv_backend_verify;imiv_metal;gui" + TIMEOUT 1800 + RUN_SERIAL TRUE) + endif () + endif () +elseif (TARGET imiv + AND OIIO_BUILD_TESTS + AND BUILD_TESTING) + message (STATUS + "imiv: developer menu regression test not added (requires Python3 and ImGui Test Engine)") +endif () diff --git a/src/imiv/embed_binary_header.cmake b/src/imiv/embed_binary_header.cmake new file mode 100644 index 0000000000..18cda243ac --- /dev/null +++ b/src/imiv/embed_binary_header.cmake @@ -0,0 +1,53 @@ +if (NOT DEFINED INPUT OR NOT DEFINED OUTPUT OR NOT DEFINED SYMBOL_NAME) + message (FATAL_ERROR + "embed_binary_header.cmake requires INPUT, OUTPUT, and SYMBOL_NAME") +endif () + +get_filename_component (_embed_output_dir "${OUTPUT}" DIRECTORY) +file (MAKE_DIRECTORY "${_embed_output_dir}") +file (READ "${INPUT}" _embed_hex HEX) +string (LENGTH "${_embed_hex}" _embed_hex_length) +if (_embed_hex_length EQUAL 0) + message (FATAL_ERROR "binary input '${INPUT}' is empty") +endif () + +math (EXPR _embed_remainder "${_embed_hex_length} % 2") +if (NOT _embed_remainder EQUAL 0) + message (FATAL_ERROR + "binary input '${INPUT}' has invalid size ${_embed_hex_length}") +endif () + +math (EXPR _embed_byte_count "${_embed_hex_length} / 2") +math (EXPR _embed_last_index "${_embed_byte_count} - 1") + +file (WRITE "${OUTPUT}" "// Generated by embed_binary_header.cmake. Do not edit.\n") +file (APPEND "${OUTPUT}" "#pragma once\n\n") +file (APPEND "${OUTPUT}" "#include \n") +file (APPEND "${OUTPUT}" "#include \n\n") +file (APPEND "${OUTPUT}" "namespace Imiv {\n\n") +file (APPEND "${OUTPUT}" "static const unsigned char ${SYMBOL_NAME}[] = {\n") + +set (_embed_line " ") +set (_embed_items_on_line 0) +foreach (_embed_byte_index RANGE 0 ${_embed_last_index}) + math (EXPR _embed_offset "${_embed_byte_index} * 2") + string (SUBSTRING "${_embed_hex}" ${_embed_offset} 2 _embed_byte) + + string (APPEND _embed_line "0x${_embed_byte}") + if (NOT _embed_byte_index EQUAL _embed_last_index) + string (APPEND _embed_line ", ") + endif () + + math (EXPR _embed_items_on_line "${_embed_items_on_line} + 1") + if (_embed_items_on_line EQUAL 12 OR _embed_byte_index EQUAL _embed_last_index) + string (APPEND _embed_line "\n") + file (APPEND "${OUTPUT}" "${_embed_line}") + set (_embed_line " ") + set (_embed_items_on_line 0) + endif () +endforeach () + +file (APPEND "${OUTPUT}" "};\n") +file (APPEND "${OUTPUT}" + "static constexpr std::size_t ${SYMBOL_NAME}_size = sizeof(${SYMBOL_NAME});\n\n") +file (APPEND "${OUTPUT}" "} // namespace Imiv\n") diff --git a/src/imiv/embed_spirv_header.cmake b/src/imiv/embed_spirv_header.cmake new file mode 100644 index 0000000000..9740038818 --- /dev/null +++ b/src/imiv/embed_spirv_header.cmake @@ -0,0 +1,60 @@ +if (NOT DEFINED INPUT OR NOT DEFINED OUTPUT OR NOT DEFINED SYMBOL_NAME) + message (FATAL_ERROR + "embed_spirv_header.cmake requires INPUT, OUTPUT, and SYMBOL_NAME") +endif () + +get_filename_component (_embed_output_dir "${OUTPUT}" DIRECTORY) +file (MAKE_DIRECTORY "${_embed_output_dir}") +file (READ "${INPUT}" _embed_hex HEX) +string (LENGTH "${_embed_hex}" _embed_hex_length) +if (_embed_hex_length EQUAL 0) + message (FATAL_ERROR "SPIR-V input '${INPUT}' is empty") +endif () + +math (EXPR _embed_remainder "${_embed_hex_length} % 8") +if (NOT _embed_remainder EQUAL 0) + message (FATAL_ERROR + "SPIR-V input '${INPUT}' has invalid size ${_embed_hex_length}") +endif () + +math (EXPR _embed_word_count "${_embed_hex_length} / 8") +math (EXPR _embed_last_index "${_embed_word_count} - 1") + +file (WRITE "${OUTPUT}" "// Generated by embed_spirv_header.cmake. Do not edit.\n") +file (APPEND "${OUTPUT}" "#pragma once\n\n") +file (APPEND "${OUTPUT}" "#include \n") +file (APPEND "${OUTPUT}" "#include \n\n") +file (APPEND "${OUTPUT}" "namespace Imiv {\n\n") +file (APPEND "${OUTPUT}" "static const uint32_t ${SYMBOL_NAME}[] = {\n") + +set (_embed_line " ") +set (_embed_items_on_line 0) +foreach (_embed_word_index RANGE 0 ${_embed_last_index}) + math (EXPR _embed_offset "${_embed_word_index} * 8") + math (EXPR _embed_offset_1 "${_embed_offset} + 2") + math (EXPR _embed_offset_2 "${_embed_offset} + 4") + math (EXPR _embed_offset_3 "${_embed_offset} + 6") + string (SUBSTRING "${_embed_hex}" ${_embed_offset} 2 _embed_b0) + string (SUBSTRING "${_embed_hex}" ${_embed_offset_1} 2 _embed_b1) + string (SUBSTRING "${_embed_hex}" ${_embed_offset_2} 2 _embed_b2) + string (SUBSTRING "${_embed_hex}" ${_embed_offset_3} 2 _embed_b3) + + string (APPEND _embed_line + "0x${_embed_b3}${_embed_b2}${_embed_b1}${_embed_b0}u") + if (NOT _embed_word_index EQUAL _embed_last_index) + string (APPEND _embed_line ", ") + endif () + + math (EXPR _embed_items_on_line "${_embed_items_on_line} + 1") + if (_embed_items_on_line EQUAL 4 OR _embed_word_index EQUAL _embed_last_index) + string (APPEND _embed_line "\n") + file (APPEND "${OUTPUT}" "${_embed_line}") + set (_embed_line " ") + set (_embed_items_on_line 0) + endif () +endforeach () + +file (APPEND "${OUTPUT}" "};\n") +file (APPEND "${OUTPUT}" + "static constexpr std::size_t ${SYMBOL_NAME}_word_count = sizeof(${SYMBOL_NAME}) / sizeof(${SYMBOL_NAME}[0]);\n\n") +file (APPEND "${OUTPUT}" "} // namespace Imiv\n") diff --git a/src/imiv/external/dnd_glfw/LICENSE b/src/imiv/external/dnd_glfw/LICENSE new file mode 100644 index 0000000000..1489601fd2 --- /dev/null +++ b/src/imiv/external/dnd_glfw/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025-2026 Erium Vladlen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/imiv/external/dnd_glfw/README.md b/src/imiv/external/dnd_glfw/README.md new file mode 100644 index 0000000000..445d8e8789 --- /dev/null +++ b/src/imiv/external/dnd_glfw/README.md @@ -0,0 +1,208 @@ +# dnd_glfw – GLFW Drag-and-Drop Helper + +`dnd_glfw` is a very small helper around GLFW that exposes a simple, C‑style drag‑and‑drop callback interface. It is designed for applications that already use GLFW for their main window (e.g. Dear ImGui with the GLFW/OpenGL backend) and want better control over file drops and drag‑hover overlays. + +The implementation is intentionally minimal and C‑style C++: no heavy abstractions, only basic C++17 containers and RAII where needed. + +## Supported platforms + +- **Windows** + - Uses the GLFW window handle (`HWND`) and registers an OLE `IDropTarget`. + - Provides real OS‑level events for file drags: + - `dragEnter`, `dragOver`, `dragLeave`, `dragCancel`. + - File paths are still obtained via GLFW’s existing drop callback semantics and forwarded to your `drop` handler. + +- **macOS** + - Uses Cocoa drag‑and‑drop via an `NSView` overlay on the GLFW window’s content view. + - Provides OS‑level `dragEntered`, `draggingUpdated`, `draggingExited`, and `performDragOperation` mapped to: + - `dragEnter`, `dragOver`, `dragLeave`, `dragCancel`, `drop`. + - File paths are read from the pasteboard and passed to your `drop` handler as UTF‑8 strings. + +- **Linux / X11 / Wayland** + - Relies on GLFW’s internal Xdnd / DnD handling. + - Wraps `glfwSetDropCallback` and exposes file paths and cursor position via the `drop` callback. + - OS‑level drag‑enter / drag‑hover / drag‑leave for external file drags are **not** available via GLFW’s public API, so `dragEnter` / `dragOver` / `dragLeave` / `dragCancel` are not fired for system file drags on Linux. + +In practice this means: + +- You get full overlay‑style drag feedback on Windows and macOS. +- On Linux you still get correct file drops with a consistent API, but no OS‑level “drag in progress” notifications. + +## API overview + +Header: `dnd_glfw/dnd_glfw.h` + +```cpp +namespace dnd_glfw { + +enum class PayloadKind { + Unknown = 0, + Files = 1 +}; + +struct DragEvent { + double x; + double y; + PayloadKind kind; +}; + +struct DropEvent { + double x; + double y; + PayloadKind kind; + std::vector paths; // UTF‑8 file paths +}; + +struct Callbacks { + void (*dragEnter)(GLFWwindow*, const DragEvent&, void* userData); + void (*dragOver)(GLFWwindow*, const DragEvent&, void* userData); + void (*dragLeave)(GLFWwindow*, void* userData); + void (*drop)(GLFWwindow*, const DropEvent&, void* userData); + void (*dragCancel)(GLFWwindow*, void* userData); +}; + +bool init(GLFWwindow* window, const Callbacks& callbacks, void* userData); +void shutdown(GLFWwindow* window); + +} // namespace dnd_glfw +``` + +All callbacks are optional; set a pointer to `nullptr` to ignore that event type. + +## Typical usage + +1. **Create your GLFW window as usual** and make it current. +2. **Register drag‑and‑drop callbacks** once, after the window exists: + +```cpp +static void onDragEnter(GLFWwindow* window, const dnd_glfw::DragEvent& e, void* user) +{ + (void)window; + AppState* app = static_cast(user); + if (!app) return; + + if (e.kind == dnd_glfw::PayloadKind::Files) { + app->dragOverlayActive = true; + } +} + +static void onDrop(GLFWwindow* window, const dnd_glfw::DropEvent& e, void* user) +{ + (void)window; + AppState* app = static_cast(user); + if (!app) return; + + std::lock_guard lock(app->dropMutex); + app->pendingDrops = e.paths; + app->dragOverlayActive = false; +} + +static void onDragLeave(GLFWwindow* window, void* user) +{ + (void)window; + AppState* app = static_cast(user); + if (!app) return; + app->dragOverlayActive = false; +} + +// ... + +dnd_glfw::Callbacks cbs{}; +cbs.dragEnter = &onDragEnter; +cbs.dragOver = nullptr; // not needed for simple overlay +cbs.dragLeave = &onDragLeave; +cbs.drop = &onDrop; +cbs.dragCancel = &onDragLeave; // treat cancel as leave + +dnd_glfw::init(window, cbs, &app); +``` + +3. **Use your own flag to drive an overlay** in ImGui (or another GUI): + +```cpp +if (app.dragOverlayActive) { + // Draw a full‑window dim background and center text +} +``` + +4. **On shutdown**, before destroying the GLFW window: + +```cpp +dnd_glfw::shutdown(window); +glfwDestroyWindow(window); +``` + +## Integration details + +- **GLFW drop callback chaining** + - On Windows and Linux, `dnd_glfw::init` installs an internal `glfwSetDropCallback` handler and remembers any previously registered callback. + - When a drop occurs, the internal handler: + - Builds a `DropEvent` (cursor position, `PayloadKind::Files`, file paths). + - Calls your `Callbacks::drop` (if provided). + - Forwards the event to the previous GLFW drop callback (if any). + - On macOS, GLFW’s own DnD is handled internally; `dnd_glfw` uses Cocoa APIs instead and does **not** override `glfwSetDropCallback`. + +- **Windows / OLE** + - Uses `glfwGetWin32Window(window)` to get the `HWND` and registers an `IDropTarget` implementation. + - Only CF_HDROP (file paths) is accepted; other payloads are ignored. + - `DragEnter` / `DragOver` use client‑area coordinates (converted from screen) for the `DragEvent::x`/`y` fields. + - The actual file paths are still delivered via GLFW’s drop events to keep behavior consistent with GLFW/X11 and Cocoa. + +- **macOS / Cocoa** + - Obtains the native `NSWindow*` for the GLFW window with `glfwGetCocoaWindow`. + - Adds a transparent `NSView` subclass (`DndGlfwDragView`) above the content view and registers it for file URL types. + - Uses the system pasteboard to extract file URLs and forwards them as UTF‑8 paths in `DropEvent::paths`. + +- **Linux** + - Depends on GLFW’s own X11/Wayland drag‑and‑drop implementation. + - Only the `drop` callback is meaningful for OS‑level file drags; hover/enter/leave for external drags are not exposed by GLFW and therefore not emitted by `dnd_glfw`. + +## CMake integration + +The top‑level `CMakeLists.txt` for this project adds the helper as a static library: + +```cmake +if(APPLE) + add_library(dnd_glfw STATIC + dnd_glfw_macos.mm + ) +else() + add_library(dnd_glfw STATIC + dnd_glfw_dummy.cpp + ) +endif() + +target_include_directories(dnd_glfw + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) + +if(APPLE) + target_link_libraries(dnd_glfw PUBLIC "-framework Cocoa") +endif() +``` + +To use it from another target (like the GUI): + +```cmake +add_subdirectory(dnd_glfw) + +target_link_libraries(meshrepair_gui + PRIVATE + dnd_glfw + # other libs: glfw, OpenGL, ImGui, etc. +) + +if(WIN32) + target_link_libraries(meshrepair_gui PRIVATE ole32) +endif() +``` + +The macOS Cocoa framework and Windows `ole32` import library are wired automatically via these targets. + +## Notes and limitations + +- Only a small subset of drag‑and‑drop is implemented: **file paths** dragged from the OS (Explorer/Finder/desktop/file manager) into your GLFW window. +- No attempt is made to support arbitrary MIME types or cross‑process data beyond file URLs / paths. +- On Linux, GLFW owns the Xdnd handling; `dnd_glfw` intentionally does **not** duplicate Xdnd parsing to avoid conflicting with GLFW’s internal event loop. +- The internal state is stored in a small global vector keyed by `GLFWwindow*`. It assumes a small number of windows (typically one) and is not optimized for hundreds of windows. This matches the intended usage for a single ImGui main window. diff --git a/src/imiv/external/dnd_glfw/dnd_glfw.h b/src/imiv/external/dnd_glfw/dnd_glfw.h new file mode 100644 index 0000000000..4861ef1187 --- /dev/null +++ b/src/imiv/external/dnd_glfw/dnd_glfw.h @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025-2026 Erium Vladlen +// +// Lightweight GLFW-based drag-and-drop helper +// Header-only; C-style callbacks with minimal C++17 usage. + +#pragma once + +#include + +#include +#include + +#if defined(_WIN32) +# include +# include +# include +# if !defined(GLFW_EXPOSE_NATIVE_WIN32) +# define GLFW_EXPOSE_NATIVE_WIN32 +# endif +# include +#endif + +#if defined(__APPLE__) && !defined(_WIN32) +# if !defined(GLFW_EXPOSE_NATIVE_COCOA) +# define GLFW_EXPOSE_NATIVE_COCOA +# endif +# include +#endif + +namespace dnd_glfw { + +enum class PayloadKind { Unknown = 0, Files = 1 }; + +struct DragEvent { + double x = 0.0; + double y = 0.0; + PayloadKind kind = PayloadKind::Unknown; +}; + +struct DropEvent { + double x = 0.0; + double y = 0.0; + PayloadKind kind = PayloadKind::Unknown; + std::vector paths; +}; + +struct Callbacks { + void (*dragEnter)(GLFWwindow* window, const DragEvent& event, + void* userData) + = nullptr; + void (*dragOver)(GLFWwindow* window, const DragEvent& event, void* userData) + = nullptr; + void (*dragLeave)(GLFWwindow* window, void* userData) = nullptr; + void (*drop)(GLFWwindow* window, const DropEvent& event, void* userData) + = nullptr; + void (*dragCancel)(GLFWwindow* window, void* userData) = nullptr; +}; + +namespace detail { + + struct WindowState { + GLFWwindow* window = nullptr; + Callbacks callbacks = {}; + void* userData = nullptr; + GLFWdropfun prevDropCb = nullptr; + bool dropCallbackInstalled = false; + +#if defined(_WIN32) + HWND hwnd = nullptr; + IDropTarget* dropTarget = nullptr; +#endif + +#if defined(__APPLE__) && !defined(_WIN32) + void* cocoaView = nullptr; +#endif + }; + + inline std::vector& windowStates() + { + static std::vector states; + return states; + } + + inline WindowState* findState(GLFWwindow* window) + { + auto& states = windowStates(); + for (auto& s : states) { + if (s.window == window) { + return &s; + } + } + return nullptr; + } + + inline WindowState* addState(GLFWwindow* window) + { + auto& states = windowStates(); + states.push_back(WindowState {}); + WindowState& s = states.back(); + s.window = window; + return &s; + } + + inline void removeState(GLFWwindow* window) + { + auto& states = windowStates(); + for (std::size_t i = 0; i < states.size(); ++i) { + if (states[i].window == window) { + states[i] = states.back(); + states.pop_back(); + break; + } + } + } + + inline void dispatchDragEnter(WindowState* state, double x, double y, + PayloadKind kind) + { + if (!state || !state->callbacks.dragEnter) { + return; + } + DragEvent ev {}; + ev.x = x; + ev.y = y; + ev.kind = kind; + state->callbacks.dragEnter(state->window, ev, state->userData); + } + + inline void dispatchDragOver(WindowState* state, double x, double y, + PayloadKind kind) + { + if (!state || !state->callbacks.dragOver) { + return; + } + DragEvent ev {}; + ev.x = x; + ev.y = y; + ev.kind = kind; + state->callbacks.dragOver(state->window, ev, state->userData); + } + + inline void dispatchDragLeave(WindowState* state) + { + if (!state || !state->callbacks.dragLeave) { + return; + } + state->callbacks.dragLeave(state->window, state->userData); + } + + inline void dispatchDragCancel(WindowState* state) + { + if (!state || !state->callbacks.dragCancel) { + return; + } + state->callbacks.dragCancel(state->window, state->userData); + } + + inline void dispatchDrop(WindowState* state, const DropEvent& ev) + { + if (!state || !state->callbacks.drop) { + return; + } + state->callbacks.drop(state->window, ev, state->userData); + } + + inline void glfwDropCallback(GLFWwindow* window, int count, + const char** paths); + +#if defined(__APPLE__) && !defined(_WIN32) + void platformInitMac(WindowState* state, GLFWwindow* window); + void platformShutdownMac(WindowState* state, GLFWwindow* window); +#endif + +#if defined(_WIN32) + + struct DropTarget : public IDropTarget { + LONG refCount; + WindowState* state; + + explicit DropTarget(WindowState* s) + : refCount(1) + , state(s) + { + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, + void** ppvObject) override + { + if (!ppvObject) { + return E_INVALIDARG; + } + if (riid == IID_IUnknown || riid == IID_IDropTarget) { + *ppvObject = static_cast(this); + AddRef(); + return S_OK; + } + *ppvObject = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override + { + return static_cast(InterlockedIncrement(&refCount)); + } + + ULONG STDMETHODCALLTYPE Release() override + { + LONG count = InterlockedDecrement(&refCount); + if (count == 0) { + delete this; + return 0; + } + return static_cast(count); + } + + HRESULT STDMETHODCALLTYPE DragEnter(IDataObject* dataObj, + DWORD grfKeyState, POINTL pt, + DWORD* pdwEffect) override + { + (void)grfKeyState; + + if (!pdwEffect) { + return E_INVALIDARG; + } + + if (!state) { + *pdwEffect = DROPEFFECT_NONE; + return S_OK; + } + + FORMATETC fmt {}; + fmt.cfFormat = CF_HDROP; + fmt.ptd = nullptr; + fmt.dwAspect = DVASPECT_CONTENT; + fmt.lindex = -1; + fmt.tymed = TYMED_HGLOBAL; + + if (!dataObj || FAILED(dataObj->QueryGetData(&fmt))) { + *pdwEffect = DROPEFFECT_NONE; + return S_OK; + } + + POINT screenPoint; + screenPoint.x = static_cast(pt.x); + screenPoint.y = static_cast(pt.y); + + POINT clientPoint = screenPoint; + if (state->hwnd) { + ScreenToClient(state->hwnd, &clientPoint); + } + + double x = static_cast(clientPoint.x); + double y = static_cast(clientPoint.y); + dispatchDragEnter(state, x, y, PayloadKind::Files); + + *pdwEffect = DROPEFFECT_COPY; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE DragOver(DWORD grfKeyState, POINTL pt, + DWORD* pdwEffect) override + { + (void)grfKeyState; + + if (!pdwEffect) { + return E_INVALIDARG; + } + + if (!state) { + *pdwEffect = DROPEFFECT_NONE; + return S_OK; + } + + POINT screenPoint; + screenPoint.x = static_cast(pt.x); + screenPoint.y = static_cast(pt.y); + + POINT clientPoint = screenPoint; + if (state->hwnd) { + ScreenToClient(state->hwnd, &clientPoint); + } + + double x = static_cast(clientPoint.x); + double y = static_cast(clientPoint.y); + dispatchDragOver(state, x, y, PayloadKind::Files); + + *pdwEffect = DROPEFFECT_COPY; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE DragLeave() override + { + if (state) { + dispatchDragLeave(state); + } + return S_OK; + } + + HRESULT STDMETHODCALLTYPE Drop(IDataObject* dataObj, DWORD grfKeyState, + POINTL pt, DWORD* pdwEffect) override + { + (void)grfKeyState; + (void)pt; + + if (pdwEffect) { + *pdwEffect = DROPEFFECT_NONE; + } + + if (!state || !dataObj) { + return S_OK; + } + + FORMATETC fmt {}; + fmt.cfFormat = CF_HDROP; + fmt.ptd = nullptr; + fmt.dwAspect = DVASPECT_CONTENT; + fmt.lindex = -1; + fmt.tymed = TYMED_HGLOBAL; + + STGMEDIUM medium {}; + if (FAILED(dataObj->GetData(&fmt, &medium))) { + dispatchDragCancel(state); + return S_OK; + } + + HDROP hdrop = static_cast(GlobalLock(medium.hGlobal)); + if (!hdrop) { + ReleaseStgMedium(&medium); + dispatchDragCancel(state); + return S_OK; + } + + UINT fileCount = DragQueryFileW(hdrop, 0xFFFFFFFFu, nullptr, 0); + dnd_glfw::DropEvent ev; + ev.kind = PayloadKind::Files; + + POINT clientPt {}; + clientPt.x = static_cast(pt.x); + clientPt.y = static_cast(pt.y); + if (state->hwnd) { + ScreenToClient(state->hwnd, &clientPt); + } + ev.x = static_cast(clientPt.x); + ev.y = static_cast(clientPt.y); + + ev.paths.reserve(fileCount); + for (UINT i = 0; i < fileCount; ++i) { + UINT len = DragQueryFileW(hdrop, i, nullptr, 0); + if (len == 0) { + continue; + } + std::wstring wpath; + wpath.resize(len); + if (DragQueryFileW(hdrop, i, &wpath[0], len + 1) == 0) { + continue; + } + int utf8Len = WideCharToMultiByte(CP_UTF8, 0, wpath.c_str(), + static_cast(wpath.size()), + nullptr, 0, nullptr, nullptr); + if (utf8Len <= 0) { + continue; + } + std::string path; + path.resize(utf8Len); + WideCharToMultiByte(CP_UTF8, 0, wpath.c_str(), + static_cast(wpath.size()), &path[0], + utf8Len, nullptr, nullptr); + ev.paths.emplace_back(std::move(path)); + } + + GlobalUnlock(medium.hGlobal); + ReleaseStgMedium(&medium); + + if (!ev.paths.empty()) { + dispatchDrop(state, ev); + if (pdwEffect) { + *pdwEffect = DROPEFFECT_COPY; + } + } else { + dispatchDragCancel(state); + } + + dispatchDragLeave(state); + return S_OK; + } + }; + +#endif // _WIN32 + + inline void glfwDropCallback(GLFWwindow* window, int count, + const char** paths) + { + WindowState* state = findState(window); + + DropEvent ev {}; + ev.kind = PayloadKind::Files; + + double x = 0.0; + double y = 0.0; + if (window) { + glfwGetCursorPos(window, &x, &y); + } + ev.x = x; + ev.y = y; + + if (count > 0 && paths) { + ev.paths.reserve(static_cast(count)); + for (int i = 0; i < count; ++i) { + if (paths[i]) { + ev.paths.emplace_back(paths[i]); + } + } + } + + if (state) { + dispatchDrop(state, ev); + if (state->prevDropCb) { + state->prevDropCb(window, count, paths); + } + } else { + // No state; forward to GLFW's previously installed callback if any (unlikely). + // There is no stored previous callback here, so just ignore. + (void)window; + (void)count; + (void)paths; + } + } + +} // namespace detail + +inline bool +init(GLFWwindow* window, const Callbacks& callbacks, void* userData) +{ + if (!window) { + return false; + } + + detail::WindowState* state = detail::findState(window); + if (!state) { + state = detail::addState(window); + } + + state->callbacks = callbacks; + state->userData = userData; + +#if defined(__APPLE__) && !defined(_WIN32) + state->prevDropCb = nullptr; + state->dropCallbackInstalled = false; +#elif defined(_WIN32) + // On Windows we rely on the OLE IDropTarget path for drops. + state->prevDropCb = nullptr; + state->dropCallbackInstalled = false; +#else + state->prevDropCb = glfwSetDropCallback(window, &detail::glfwDropCallback); + state->dropCallbackInstalled = true; +#endif + +#if defined(_WIN32) + if (!state->dropTarget) { + state->hwnd = glfwGetWin32Window(window); + if (state->hwnd) { + HRESULT hrInit = OleInitialize(nullptr); + (void)hrInit; + + auto* target = new detail::DropTarget(state); + HRESULT hr = RegisterDragDrop(state->hwnd, target); + if (SUCCEEDED(hr)) { + state->dropTarget = target; + } else { + target->Release(); + } + } + } +#endif + +#if defined(__APPLE__) && !defined(_WIN32) + detail::platformInitMac(state, window); +#endif + + return true; +} + +inline void +shutdown(GLFWwindow* window) +{ + if (!window) { + return; + } + + detail::WindowState* state = detail::findState(window); + if (!state) { + return; + } + +#if defined(_WIN32) + if (state->hwnd && state->dropTarget) { + RevokeDragDrop(state->hwnd); + state->dropTarget->Release(); + state->dropTarget = nullptr; + state->hwnd = nullptr; + } +#endif + +#if defined(__APPLE__) && !defined(_WIN32) + detail::platformShutdownMac(state, window); +#endif + + if (state->dropCallbackInstalled) { + glfwSetDropCallback(window, state->prevDropCb); + } + detail::removeState(window); +} + +} // namespace dnd_glfw diff --git a/src/imiv/external/dnd_glfw/dnd_glfw_macos.mm b/src/imiv/external/dnd_glfw/dnd_glfw_macos.mm new file mode 100644 index 0000000000..c61d6b5956 --- /dev/null +++ b/src/imiv/external/dnd_glfw/dnd_glfw_macos.mm @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025-2026 Erium Vladlen + +#import + +#define GLFW_INCLUDE_NONE +#include +#define GLFW_EXPOSE_NATIVE_COCOA +#include + +#include "dnd_glfw.h" + +using dnd_glfw::PayloadKind; +using dnd_glfw::detail::WindowState; +using dnd_glfw::detail::dispatchDragEnter; +using dnd_glfw::detail::dispatchDragOver; +using dnd_glfw::detail::dispatchDragLeave; +using dnd_glfw::detail::dispatchDragCancel; +using dnd_glfw::detail::dispatchDrop; + +@interface DngGlfwDragView : NSView +{ +@public + WindowState* dngState; +} +@end + +@implementation DngGlfwDragView + +- (NSDragOperation)draggingEntered:(id)sender +{ + if (!dngState) { + return NSDragOperationNone; + } + + NSPasteboard* pasteboard = [sender draggingPasteboard]; + if (!pasteboard) { + return NSDragOperationNone; + } + + NSDictionary* options = @{ NSPasteboardURLReadingFileURLsOnlyKey : @YES }; + NSArray* urls = [pasteboard readObjectsForClasses:@[ [NSURL class] ] options:options]; + if (!urls || [urls count] == 0) { + return NSDragOperationNone; + } + + dispatchDragEnter(dngState, 0.0, 0.0, PayloadKind::Files); + return NSDragOperationCopy; +} + +- (NSDragOperation)draggingUpdated:(id)sender +{ + (void)sender; + + if (!dngState) { + return NSDragOperationNone; + } + + dispatchDragOver(dngState, 0.0, 0.0, PayloadKind::Files); + return NSDragOperationCopy; +} + +- (void)draggingExited:(id)sender +{ + (void)sender; + + if (!dngState) { + return; + } + + dispatchDragLeave(dngState); +} + +- (BOOL)performDragOperation:(id)sender +{ + if (!dngState) { + return NO; + } + + NSPasteboard* pasteboard = [sender draggingPasteboard]; + if (!pasteboard) { + dispatchDragCancel(dngState); + return NO; + } + + NSDictionary* options = @{ NSPasteboardURLReadingFileURLsOnlyKey : @YES }; + NSArray* urls = [pasteboard readObjectsForClasses:@[ [NSURL class] ] options:options]; + if (!urls || [urls count] == 0) { + dispatchDragCancel(dngState); + return NO; + } + + dnd_glfw::DropEvent ev; + ev.kind = PayloadKind::Files; + + NSWindow* nsWindow = [self window]; + if (nsWindow) { + NSView* contentView = [nsWindow contentView]; + if (contentView) { + const NSRect contentRect = [contentView frame]; + const NSPoint pos = [sender draggingLocation]; + ev.x = pos.x; + ev.y = contentRect.size.height - pos.y; + } + } + + for (NSURL* url in urls) { + if (![url isFileURL]) { + continue; + } + const char* cpath = [url fileSystemRepresentation]; + if (cpath) { + ev.paths.emplace_back(cpath); + } + } + + if (ev.paths.empty()) { + dispatchDragCancel(dngState); + return NO; + } + + dispatchDrop(dngState, ev); + dispatchDragLeave(dngState); + return YES; +} + +@end + +namespace dnd_glfw { +namespace detail { + +void +platformInitMac(WindowState* state, GLFWwindow* window) +{ + if (!state || !window) { + return; + } + + id cocoaWindowObj = glfwGetCocoaWindow(window); + NSWindow* nsWindow = (NSWindow*)cocoaWindowObj; + if (!nsWindow) { + return; + } + + NSView* contentView = [nsWindow contentView]; + if (!contentView) { + return; + } + + DngGlfwDragView* dragView = [[DngGlfwDragView alloc] initWithFrame:[contentView bounds]]; + dragView->dngState = state; + [dragView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + + [contentView addSubview:dragView positioned:NSWindowAbove relativeTo:nil]; + [dragView registerForDraggedTypes:@[ NSPasteboardTypeFileURL ]]; + + state->cocoaView = (void*)dragView; +} + +void +platformShutdownMac(WindowState* state, GLFWwindow* window) +{ + (void)window; + + if (!state || !state->cocoaView) { + return; + } + + NSView* view = (NSView*)state->cocoaView; + [view removeFromSuperview]; + [view release]; + state->cocoaView = nullptr; +} + +} // namespace detail +} // namespace dnd_glfw diff --git a/src/imiv/external/imgui_impl_metal_imiv.mm b/src/imiv/external/imgui_impl_metal_imiv.mm new file mode 100644 index 0000000000..9a0b4212bd --- /dev/null +++ b/src/imiv/external/imgui_impl_metal_imiv.mm @@ -0,0 +1,1022 @@ +// dear imgui: Renderer Backend for Metal +// This needs to be used along with a Platform Backend (e.g. OSX) + +// Implemented features: +// [X] Renderer: User texture binding. Use 'MTLTexture' as texture identifier. Read the FAQ about ImTextureID/ImTextureRef! +// [X] Renderer: Large meshes support (64k+ vertices) even with 16-bit indices (ImGuiBackendFlags_RendererHasVtxOffset). +// [X] Renderer: Texture updates support for dynamic font atlas (ImGuiBackendFlags_RendererHasTextures). +// [X] Renderer: Multi-viewport support (multiple windows). Enable with 'io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable'. + +// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this. +// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need. +// Learn about Dear ImGui: +// - FAQ https://dearimgui.com/faq +// - Getting Started https://dearimgui.com/getting-started +// - Documentation https://dearimgui.com/docs (same as your local docs/ folder). +// - Introduction, links and more at the top of imgui.cpp + +// CHANGELOG +// (minor and older changes stripped away, please see git history for details) +// 2026-XX-XX: Metal: Added support for multiple windows via the ImGuiPlatformIO interface. +// 2025-09-18: Call platform_io.ClearRendererHandlers() on shutdown. +// 2025-06-11: Added support for ImGuiBackendFlags_RendererHasTextures, for dynamic font atlas. Removed ImGui_ImplMetal_CreateFontsTexture() and ImGui_ImplMetal_DestroyFontsTexture(). +// 2025-02-03: Metal: Crash fix. (#8367) +// 2025-01-08: Metal: Fixed memory leaks when using metal-cpp (#8276, #8166) or when using multiple contexts (#7419). +// 2022-08-23: Metal: Update deprecated property 'sampleCount'->'rasterSampleCount'. +// 2022-07-05: Metal: Add dispatch synchronization. +// 2022-06-30: Metal: Use __bridge for ARC based systems. +// 2022-06-01: Metal: Fixed null dereference on exit inside command buffer completion handler. +// 2022-04-27: Misc: Store backend data in a per-context struct, allowing to use this backend with multiple contexts. +// 2022-01-03: Metal: Ignore ImDrawCmd where ElemCount == 0 (very rare but can technically be manufactured by user code). +// 2021-12-30: Metal: Added Metal C++ support. Enable with '#define IMGUI_IMPL_METAL_CPP' in your imconfig.h file. +// 2021-08-24: Metal: Fixed a crash when clipping rect larger than framebuffer is submitted. (#4464) +// 2021-05-19: Metal: Replaced direct access to ImDrawCmd::TextureId with a call to ImDrawCmd::GetTexID(). (will become a requirement) +// 2021-02-18: Metal: Change blending equation to preserve alpha in output buffer. +// 2021-01-25: Metal: Fixed texture storage mode when building on Mac Catalyst. +// 2019-05-29: Metal: Added support for large mesh (64K+ vertices), enable ImGuiBackendFlags_RendererHasVtxOffset flag. +// 2019-04-30: Metal: Added support for special ImDrawCallback_ResetRenderState callback to reset render state. +// 2019-02-11: Metal: Projecting clipping rectangles correctly using draw_data->FramebufferScale to allow multi-viewports for retina display. +// 2018-11-30: Misc: Setting up io.BackendRendererName so it can be displayed in the About Window. +// 2018-07-05: Metal: Added new Metal backend implementation. + +#include "imgui.h" +#ifndef IMGUI_DISABLE +# include "imgui_impl_metal.h" +# import +# import + +// Forward Declarations +static void +ImGui_ImplMetal_InitMultiViewportSupport(); +static void +ImGui_ImplMetal_ShutdownMultiViewportSupport(); +static void +ImGui_ImplMetal_CreateDeviceObjectsForPlatformWindows(); +static void +ImGui_ImplMetal_InvalidateDeviceObjectsForPlatformWindows(); + +# pragma mark - Support classes + +// A wrapper around a MTLBuffer object that knows the last time it was reused +@interface MetalBuffer : NSObject +@property (nonatomic, strong) id buffer; +@property (nonatomic, assign) double lastReuseTime; +- (instancetype)initWithBuffer:(id)buffer; +@end + +// An object that encapsulates the data necessary to uniquely identify a +// render pipeline state. These are used as cache keys. +@interface FramebufferDescriptor : NSObject +@property (nonatomic, assign) unsigned long sampleCount; +@property (nonatomic, assign) MTLPixelFormat colorPixelFormat; +@property (nonatomic, assign) MTLPixelFormat depthPixelFormat; +@property (nonatomic, assign) MTLPixelFormat stencilPixelFormat; +- (instancetype)initWithRenderPassDescriptor: + (MTLRenderPassDescriptor*)renderPassDescriptor; +@end + +@interface MetalTexture : NSObject +@property (nonatomic, strong) id metalTexture; +@property (nonatomic, strong) id samplerState; +- (instancetype)initWithTexture:(id)metalTexture; +- (instancetype)initWithTexture:(id)metalTexture + samplerState:(id)samplerState; +@end + +// A singleton that stores long-lived objects that are needed by the Metal +// renderer backend. Stores the render pipeline state cache and the default +// font texture, and manages the reusable buffer cache. +@interface MetalContext : NSObject +@property (nonatomic, strong) id device; +@property (nonatomic, strong) id depthStencilState; +@property (nonatomic, strong) id defaultSamplerState; +@property (nonatomic, strong) FramebufferDescriptor* + framebufferDescriptor; // framebuffer descriptor for current frame; transient +@property (nonatomic, strong) NSMutableDictionary* + renderPipelineStateCache; // pipeline cache; keyed on framebuffer descriptors +@property (nonatomic, strong) NSMutableArray* bufferCache; +@property (nonatomic, assign) double lastBufferCachePurge; +- (MetalBuffer*)dequeueReusableBufferOfLength:(NSUInteger)length + device:(id)device; +- (id) + renderPipelineStateForFramebufferDescriptor: + (FramebufferDescriptor*)descriptor + device:(id)device; +@end + +struct ImGui_ImplMetal_Data { + MetalContext* SharedMetalContext; + + ImGui_ImplMetal_Data() { memset((void*)this, 0, sizeof(*this)); } +}; + +static ImGui_ImplMetal_Data* +ImGui_ImplMetal_GetBackendData() +{ + return ImGui::GetCurrentContext() + ? (ImGui_ImplMetal_Data*)ImGui::GetIO().BackendRendererUserData + : nullptr; +} +static void +ImGui_ImplMetal_DestroyBackendData() +{ + IM_DELETE(ImGui_ImplMetal_GetBackendData()); +} + +static inline CFTimeInterval +GetMachAbsoluteTimeInSeconds() +{ + return (CFTimeInterval)(double)(clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + / 1e9); +} + +# ifdef IMGUI_IMPL_METAL_CPP + +# pragma mark - Dear ImGui Metal C++ Backend API + +bool +ImGui_ImplMetal_Init(MTL::Device* device) +{ + return ImGui_ImplMetal_Init((__bridge id)(device)); +} + +void +ImGui_ImplMetal_NewFrame(MTL::RenderPassDescriptor* renderPassDescriptor) +{ + ImGui_ImplMetal_NewFrame( + (__bridge MTLRenderPassDescriptor*)(renderPassDescriptor)); +} + +void +ImGui_ImplMetal_RenderDrawData(ImDrawData* draw_data, + MTL::CommandBuffer* commandBuffer, + MTL::RenderCommandEncoder* commandEncoder) +{ + ImGui_ImplMetal_RenderDrawData( + draw_data, (__bridge id)(commandBuffer), + (__bridge id)(commandEncoder)); +} + +bool +ImGui_ImplMetal_CreateDeviceObjects(MTL::Device* device) +{ + return ImGui_ImplMetal_CreateDeviceObjects( + (__bridge id)(device)); +} + +# endif // #ifdef IMGUI_IMPL_METAL_CPP + +# pragma mark - Dear ImGui Metal Backend API + +bool +ImGui_ImplMetal_Init(id device) +{ + ImGuiIO& io = ImGui::GetIO(); + IMGUI_CHECKVERSION(); + IM_ASSERT(io.BackendRendererUserData == nullptr + && "Already initialized a renderer backend!"); + + ImGui_ImplMetal_Data* bd = IM_NEW(ImGui_ImplMetal_Data)(); + io.BackendRendererUserData = (void*)bd; + io.BackendRendererName = "imgui_impl_metal"; + io.BackendFlags + |= ImGuiBackendFlags_RendererHasVtxOffset; // We can honor the ImDrawCmd::VtxOffset field, allowing for large meshes. + io.BackendFlags + |= ImGuiBackendFlags_RendererHasTextures; // We can honor ImGuiPlatformIO::Textures[] requests during render. + io.BackendFlags + |= ImGuiBackendFlags_RendererHasViewports; // We can create multi-viewports on the Renderer side (optional) + + bd->SharedMetalContext = [[MetalContext alloc] init]; + bd->SharedMetalContext.device = device; + + ImGui_ImplMetal_InitMultiViewportSupport(); + + return true; +} + +void +ImGui_ImplMetal_Shutdown() +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + IM_UNUSED(bd); + IM_ASSERT(bd != nullptr + && "No renderer backend to shutdown, or already shutdown?"); + ImGuiIO& io = ImGui::GetIO(); + ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO(); + + ImGui_ImplMetal_ShutdownMultiViewportSupport(); + ImGui_ImplMetal_DestroyDeviceObjects(); + ImGui_ImplMetal_DestroyBackendData(); + + io.BackendRendererName = nullptr; + io.BackendRendererUserData = nullptr; + io.BackendFlags &= ~(ImGuiBackendFlags_RendererHasVtxOffset + | ImGuiBackendFlags_RendererHasTextures + | ImGuiBackendFlags_RendererHasViewports); + platform_io.ClearRendererHandlers(); +} + +void +ImGui_ImplMetal_NewFrame(MTLRenderPassDescriptor* renderPassDescriptor) +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + IM_ASSERT( + bd != nil + && "Context or backend not initialized! Did you call ImGui_ImplMetal_Init()?"); +# ifdef IMGUI_IMPL_METAL_CPP + bd->SharedMetalContext.framebufferDescriptor = + [[[FramebufferDescriptor alloc] + initWithRenderPassDescriptor:renderPassDescriptor] autorelease]; +# else + bd->SharedMetalContext.framebufferDescriptor = [[FramebufferDescriptor alloc] + initWithRenderPassDescriptor:renderPassDescriptor]; +# endif + if (bd->SharedMetalContext.depthStencilState == nil) + ImGui_ImplMetal_CreateDeviceObjects(bd->SharedMetalContext.device); +} + +ImTextureID +ImGui_ImplMetal_CreateUserTextureID(id texture, + id sampler_state) +{ + if (texture == nil) + return ImTextureID_Invalid; + MetalTexture* backend_tex = [[MetalTexture alloc] + initWithTexture:texture + samplerState:sampler_state]; + return (ImTextureID)(__bridge_retained void*)(backend_tex); +} + +void +ImGui_ImplMetal_DestroyUserTextureID(ImTextureID tex_id) +{ + if (tex_id == ImTextureID_Invalid) + return; + MetalTexture* backend_tex + = (__bridge_transfer MetalTexture*)(void*)(intptr_t)(tex_id); + backend_tex.metalTexture = nil; + backend_tex.samplerState = nil; +} + +static void +ImGui_ImplMetal_SetupRenderState(ImDrawData* draw_data, + id commandBuffer, + id commandEncoder, + id renderPipelineState, + MetalBuffer* vertexBuffer, + size_t vertexBufferOffset) +{ + IM_UNUSED(commandBuffer); + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + [commandEncoder setCullMode:MTLCullModeNone]; + [commandEncoder + setDepthStencilState:bd->SharedMetalContext.depthStencilState]; + + // Setup viewport, orthographic projection matrix + // Our visible imgui space lies from draw_data->DisplayPos (top left) to + // draw_data->DisplayPos+data_data->DisplaySize (bottom right). DisplayMin is typically (0,0) for single viewport apps. + MTLViewport viewport = { .originX = 0.0, + .originY = 0.0, + .width = (double)(draw_data->DisplaySize.x + * draw_data->FramebufferScale.x), + .height = (double)(draw_data->DisplaySize.y + * draw_data->FramebufferScale.y), + .znear = 0.0, + .zfar = 1.0 }; + [commandEncoder setViewport:viewport]; + + float L = draw_data->DisplayPos.x; + float R = draw_data->DisplayPos.x + draw_data->DisplaySize.x; + float T = draw_data->DisplayPos.y; + float B = draw_data->DisplayPos.y + draw_data->DisplaySize.y; + float N = (float)viewport.znear; + float F = (float)viewport.zfar; + const float ortho_projection[4][4] = { + { 2.0f / (R - L), 0.0f, 0.0f, 0.0f }, + { 0.0f, 2.0f / (T - B), 0.0f, 0.0f }, + { 0.0f, 0.0f, 1 / (F - N), 0.0f }, + { (R + L) / (L - R), (T + B) / (B - T), N / (F - N), 1.0f }, + }; + [commandEncoder setVertexBytes:&ortho_projection + length:sizeof(ortho_projection) + atIndex:1]; + + [commandEncoder setRenderPipelineState:renderPipelineState]; + + [commandEncoder setVertexBuffer:vertexBuffer.buffer offset:0 atIndex:0]; + [commandEncoder setVertexBufferOffset:vertexBufferOffset atIndex:0]; +} + +// Metal Render function. +void +ImGui_ImplMetal_RenderDrawData(ImDrawData* draw_data, + id commandBuffer, + id commandEncoder) +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + MetalContext* ctx = bd->SharedMetalContext; + + // Avoid rendering when minimized, scale coordinates for retina displays (screen coordinates != framebuffer coordinates) + int fb_width = (int)(draw_data->DisplaySize.x + * draw_data->FramebufferScale.x); + int fb_height = (int)(draw_data->DisplaySize.y + * draw_data->FramebufferScale.y); + if (fb_width <= 0 || fb_height <= 0 || draw_data->CmdLists.Size == 0) + return; + + // Catch up with texture updates. Most of the times, the list will have 1 element with an OK status, aka nothing to do. + // (This almost always points to ImGui::GetPlatformIO().Textures[] but is part of ImDrawData to allow overriding or disabling texture updates). + if (draw_data->Textures != nullptr) + for (ImTextureData* tex : *draw_data->Textures) + if (tex->Status != ImTextureStatus_OK) + ImGui_ImplMetal_UpdateTexture(tex); + + // Try to retrieve a render pipeline state that is compatible with the framebuffer config for this frame + // The hit rate for this cache should be very near 100%. + id renderPipelineState + = ctx.renderPipelineStateCache[ctx.framebufferDescriptor]; + if (renderPipelineState == nil) { + // No luck; make a new render pipeline state + renderPipelineState = [ctx + renderPipelineStateForFramebufferDescriptor:ctx.framebufferDescriptor + device:commandBuffer.device]; + + // Cache render pipeline state for later reuse + ctx.renderPipelineStateCache[ctx.framebufferDescriptor] + = renderPipelineState; + } + + size_t vertexBufferLength = (size_t)draw_data->TotalVtxCount + * sizeof(ImDrawVert); + size_t indexBufferLength = (size_t)draw_data->TotalIdxCount + * sizeof(ImDrawIdx); + MetalBuffer* vertexBuffer = [ctx + dequeueReusableBufferOfLength:vertexBufferLength + device:commandBuffer.device]; + MetalBuffer* indexBuffer = [ctx + dequeueReusableBufferOfLength:indexBufferLength + device:commandBuffer.device]; + + ImGui_ImplMetal_SetupRenderState(draw_data, commandBuffer, commandEncoder, + renderPipelineState, vertexBuffer, 0); + + // Will project scissor/clipping rectangles into framebuffer space + ImVec2 clip_off + = draw_data->DisplayPos; // (0,0) unless using multi-viewports + ImVec2 clip_scale + = draw_data + ->FramebufferScale; // (1,1) unless using retina display which are often (2,2) + + // Render command lists + size_t vertexBufferOffset = 0; + size_t indexBufferOffset = 0; + for (const ImDrawList* draw_list : draw_data->CmdLists) { + memcpy((char*)vertexBuffer.buffer.contents + vertexBufferOffset, + draw_list->VtxBuffer.Data, + (size_t)draw_list->VtxBuffer.Size * sizeof(ImDrawVert)); + memcpy((char*)indexBuffer.buffer.contents + indexBufferOffset, + draw_list->IdxBuffer.Data, + (size_t)draw_list->IdxBuffer.Size * sizeof(ImDrawIdx)); + + for (int cmd_i = 0; cmd_i < draw_list->CmdBuffer.Size; cmd_i++) { + const ImDrawCmd* pcmd = &draw_list->CmdBuffer[cmd_i]; + if (pcmd->UserCallback) { + // User callback, registered via ImDrawList::AddCallback() + // (ImDrawCallback_ResetRenderState is a special callback value used by the user to request the renderer to reset render state.) + if (pcmd->UserCallback == ImDrawCallback_ResetRenderState) + ImGui_ImplMetal_SetupRenderState(draw_data, commandBuffer, + commandEncoder, + renderPipelineState, + vertexBuffer, + vertexBufferOffset); + else + pcmd->UserCallback(draw_list, pcmd); + } else { + // Project scissor/clipping rectangles into framebuffer space + ImVec2 clip_min((pcmd->ClipRect.x - clip_off.x) * clip_scale.x, + (pcmd->ClipRect.y - clip_off.y) * clip_scale.y); + ImVec2 clip_max((pcmd->ClipRect.z - clip_off.x) * clip_scale.x, + (pcmd->ClipRect.w - clip_off.y) * clip_scale.y); + + // Clamp to viewport as setScissorRect() won't accept values that are off bounds + if (clip_min.x < 0.0f) { + clip_min.x = 0.0f; + } + if (clip_min.y < 0.0f) { + clip_min.y = 0.0f; + } + if (clip_max.x > fb_width) { + clip_max.x = (float)fb_width; + } + if (clip_max.y > fb_height) { + clip_max.y = (float)fb_height; + } + if (clip_max.x <= clip_min.x || clip_max.y <= clip_min.y) + continue; + if (pcmd->ElemCount + == 0) // drawIndexedPrimitives() validation doesn't accept this + continue; + + // Apply scissor/clipping rectangle + MTLScissorRect scissorRect + = { .x = NSUInteger(clip_min.x), + .y = NSUInteger(clip_min.y), + .width = NSUInteger(clip_max.x - clip_min.x), + .height = NSUInteger(clip_max.y - clip_min.y) }; + [commandEncoder setScissorRect:scissorRect]; + + // Bind texture, Draw + id sampler_state + = bd->SharedMetalContext.defaultSamplerState; + if (ImTextureID tex_id = pcmd->GetTexID()) { + id metal_object = (__bridge id)(void*)(intptr_t)(tex_id); + if ([metal_object isKindOfClass:[MetalTexture class]]) { + MetalTexture* backend_tex = (MetalTexture*)metal_object; + [commandEncoder + setFragmentTexture:backend_tex.metalTexture + atIndex:0]; + if (backend_tex.samplerState != nil) + sampler_state = backend_tex.samplerState; + } else { + [commandEncoder + setFragmentTexture:(id)metal_object + atIndex:0]; + } + } + [commandEncoder setFragmentSamplerState:sampler_state + atIndex:0]; + + [commandEncoder + setVertexBufferOffset:(vertexBufferOffset + + pcmd->VtxOffset + * sizeof(ImDrawVert)) + atIndex:0]; + [commandEncoder + drawIndexedPrimitives:MTLPrimitiveTypeTriangle + indexCount:pcmd->ElemCount + indexType:sizeof(ImDrawIdx) == 2 + ? MTLIndexTypeUInt16 + : MTLIndexTypeUInt32 + indexBuffer:indexBuffer.buffer + indexBufferOffset:indexBufferOffset + + pcmd->IdxOffset + * sizeof(ImDrawIdx)]; + } + } + + vertexBufferOffset += (size_t)draw_list->VtxBuffer.Size + * sizeof(ImDrawVert); + indexBufferOffset += (size_t)draw_list->IdxBuffer.Size + * sizeof(ImDrawIdx); + } + + MetalContext* sharedMetalContext = bd->SharedMetalContext; + [commandBuffer addCompletedHandler:^(id) { + dispatch_async(dispatch_get_main_queue(), ^{ + @synchronized(sharedMetalContext.bufferCache) { + [sharedMetalContext.bufferCache addObject:vertexBuffer]; + [sharedMetalContext.bufferCache addObject:indexBuffer]; + } + }); + }]; +} + +static void +ImGui_ImplMetal_DestroyTexture(ImTextureData* tex) +{ + if (MetalTexture* backend_tex + = (__bridge_transfer MetalTexture*)(tex->BackendUserData)) { + IM_ASSERT(backend_tex.metalTexture + == (__bridge id)(void*)(intptr_t)tex->TexID); + backend_tex.metalTexture = nil; + + // Clear identifiers and mark as destroyed (in order to allow e.g. calling InvalidateDeviceObjects while running) + tex->SetTexID(ImTextureID_Invalid); + tex->BackendUserData = nullptr; + } + tex->SetStatus(ImTextureStatus_Destroyed); +} + +void +ImGui_ImplMetal_UpdateTexture(ImTextureData* tex) +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + if (tex->Status == ImTextureStatus_WantCreate) { + // Create and upload new texture to graphics system + //IMGUI_DEBUG_LOG("UpdateTexture #%03d: WantCreate %dx%d\n", tex->UniqueID, tex->Width, tex->Height); + IM_ASSERT(tex->TexID == ImTextureID_Invalid + && tex->BackendUserData == nullptr); + IM_ASSERT(tex->Format == ImTextureFormat_RGBA32); + + // We are retrieving and uploading the font atlas as a 4-channels RGBA texture here. + // In theory we could call GetTexDataAsAlpha8() and upload a 1-channel texture to save on memory access bandwidth. + // However, using a shader designed for 1-channel texture would make it less obvious to use the ImTextureID facility to render users own textures. + // You can make that change in your implementation. + MTLTextureDescriptor* textureDescriptor = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm + width:(NSUInteger)tex->Width + height:(NSUInteger)tex->Height + mipmapped:NO]; + textureDescriptor.usage = MTLTextureUsageShaderRead; +# if TARGET_OS_OSX || TARGET_OS_MACCATALYST + textureDescriptor.storageMode = MTLStorageModeManaged; +# else + textureDescriptor.storageMode = MTLStorageModeShared; +# endif + id texture = [bd->SharedMetalContext.device + newTextureWithDescriptor:textureDescriptor]; + [texture replaceRegion:MTLRegionMake2D(0, 0, (NSUInteger)tex->Width, + (NSUInteger)tex->Height) + mipmapLevel:0 + withBytes:tex->Pixels + bytesPerRow:(NSUInteger)tex->Width * 4]; + MetalTexture* backend_tex = [[MetalTexture alloc] + initWithTexture:texture]; + + // Store identifiers + tex->SetTexID((ImTextureID)(intptr_t)texture); + tex->SetStatus(ImTextureStatus_OK); + tex->BackendUserData = (__bridge_retained void*)(backend_tex); + } else if (tex->Status == ImTextureStatus_WantUpdates) { + // Update selected blocks. We only ever write to textures regions which have never been used before! + // This backend choose to use tex->Updates[] but you can use tex->UpdateRect to upload a single region. + MetalTexture* backend_tex + = (__bridge MetalTexture*)(tex->BackendUserData); + for (ImTextureRect& r : tex->Updates) { + [backend_tex.metalTexture + replaceRegion:MTLRegionMake2D((NSUInteger)r.x, (NSUInteger)r.y, + (NSUInteger)r.w, (NSUInteger)r.h) + mipmapLevel:0 + withBytes:tex->GetPixelsAt(r.x, r.y) + bytesPerRow:(NSUInteger)tex->Width * 4]; + } + tex->SetStatus(ImTextureStatus_OK); + } else if (tex->Status == ImTextureStatus_WantDestroy + && tex->UnusedFrames > 0) { + ImGui_ImplMetal_DestroyTexture(tex); + } +} + +bool +ImGui_ImplMetal_CreateDeviceObjects(id device) +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + MTLDepthStencilDescriptor* depthStencilDescriptor = + [[MTLDepthStencilDescriptor alloc] init]; + depthStencilDescriptor.depthWriteEnabled = NO; + depthStencilDescriptor.depthCompareFunction = MTLCompareFunctionAlways; + bd->SharedMetalContext.depthStencilState = [device + newDepthStencilStateWithDescriptor:depthStencilDescriptor]; + MTLSamplerDescriptor* samplerDescriptor = [[MTLSamplerDescriptor alloc] + init]; + samplerDescriptor.minFilter = MTLSamplerMinMagFilterLinear; + samplerDescriptor.magFilter = MTLSamplerMinMagFilterLinear; + samplerDescriptor.mipFilter = MTLSamplerMipFilterLinear; + samplerDescriptor.sAddressMode = MTLSamplerAddressModeClampToEdge; + samplerDescriptor.tAddressMode = MTLSamplerAddressModeClampToEdge; + bd->SharedMetalContext.defaultSamplerState = [device + newSamplerStateWithDescriptor:samplerDescriptor]; + ImGui_ImplMetal_CreateDeviceObjectsForPlatformWindows(); +# ifdef IMGUI_IMPL_METAL_CPP + [samplerDescriptor release]; + [depthStencilDescriptor release]; +# endif + + return true; +} + +void +ImGui_ImplMetal_DestroyDeviceObjects() +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + + // Destroy all textures + for (ImTextureData* tex : ImGui::GetPlatformIO().Textures) + if (tex->RefCount == 1) + ImGui_ImplMetal_DestroyTexture(tex); + + ImGui_ImplMetal_InvalidateDeviceObjectsForPlatformWindows(); + [bd->SharedMetalContext.renderPipelineStateCache removeAllObjects]; + bd->SharedMetalContext.defaultSamplerState = nil; + bd->SharedMetalContext.depthStencilState = nil; +} + +# pragma mark - Multi-viewport support + +# import + +# if TARGET_OS_OSX +# import +# endif + +//-------------------------------------------------------------------------------------------------------- +// MULTI-VIEWPORT / PLATFORM INTERFACE SUPPORT +// This is an _advanced_ and _optional_ feature, allowing the back-end to create and handle multiple viewports simultaneously. +// If you are new to dear imgui or creating a new binding for dear imgui, it is recommended that you completely ignore this section first.. +//-------------------------------------------------------------------------------------------------------- + +struct ImGuiViewportDataMetal { + CAMetalLayer* MetalLayer; + id CommandQueue; + MTLRenderPassDescriptor* RenderPassDescriptor; + void* Handle = nullptr; + bool FirstFrame = true; +}; + +static void +ImGui_ImplMetal_CreateWindow(ImGuiViewport* viewport) +{ + ImGui_ImplMetal_Data* bd = ImGui_ImplMetal_GetBackendData(); + ImGuiViewportDataMetal* data = IM_NEW(ImGuiViewportDataMetal)(); + viewport->RendererUserData = data; + + // PlatformHandleRaw should always be a NSWindow*, whereas PlatformHandle might be a higher-level handle (e.g. GLFWWindow*, SDL_Window*). + // Some back-ends will leave PlatformHandleRaw == 0, in which case we assume PlatformHandle will contain the NSWindow*. + void* handle = viewport->PlatformHandleRaw ? viewport->PlatformHandleRaw + : viewport->PlatformHandle; + IM_ASSERT(handle != nullptr); + + id device = bd->SharedMetalContext.device; + CAMetalLayer* layer = [CAMetalLayer layer]; + layer.device = device; + layer.framebufferOnly = YES; + layer.pixelFormat + = bd->SharedMetalContext.framebufferDescriptor.colorPixelFormat; +# if TARGET_OS_OSX + NSWindow* window = (__bridge NSWindow*)handle; + NSView* view = window.contentView; + view.layer = layer; + view.wantsLayer = YES; +# endif + data->MetalLayer = layer; + data->CommandQueue = [device newCommandQueue]; + data->RenderPassDescriptor = [[MTLRenderPassDescriptor alloc] init]; + data->Handle = handle; +} + +static void +ImGui_ImplMetal_DestroyWindow(ImGuiViewport* viewport) +{ + // The main viewport (owned by the application) will always have RendererUserData == 0 since we didn't create the data for it. + if (ImGuiViewportDataMetal* data + = (ImGuiViewportDataMetal*)viewport->RendererUserData) + IM_DELETE(data); + viewport->RendererUserData = nullptr; +} + +inline static CGSize +MakeScaledSize(CGSize size, CGFloat scale) +{ + return CGSizeMake(size.width * scale, size.height * scale); +} + +static void +ImGui_ImplMetal_SetWindowSize(ImGuiViewport* viewport, ImVec2 size) +{ + ImGuiViewportDataMetal* data + = (ImGuiViewportDataMetal*)viewport->RendererUserData; + data->MetalLayer.drawableSize = MakeScaledSize(CGSizeMake(size.x, size.y), + viewport->DpiScale); +} + +static void +ImGui_ImplMetal_RenderWindow(ImGuiViewport* viewport, void*) +{ + ImGuiViewportDataMetal* data + = (ImGuiViewportDataMetal*)viewport->RendererUserData; + +# if TARGET_OS_OSX + void* handle = viewport->PlatformHandleRaw ? viewport->PlatformHandleRaw + : viewport->PlatformHandle; + NSWindow* window = (__bridge NSWindow*)handle; + + // Always render the first frame, regardless of occlusionState, to avoid an initial flicker + if ((window.occlusionState & NSWindowOcclusionStateVisible) == 0 + && !data->FirstFrame) { + // Do not render windows which are completely occluded. Calling -[CAMetalLayer nextDrawable] will hang for + // approximately 1 second if the Metal layer is completely occluded. + return; + } + data->FirstFrame = false; + + float fb_scale = (float)window.backingScaleFactor; + if (data->MetalLayer.contentsScale != fb_scale) { + data->MetalLayer.contentsScale = fb_scale; + data->MetalLayer.drawableSize = MakeScaledSize(window.frame.size, + fb_scale); + } +# endif + + id drawable = [data->MetalLayer nextDrawable]; + if (drawable == nil) + return; + + MTLRenderPassDescriptor* renderPassDescriptor = data->RenderPassDescriptor; + renderPassDescriptor.colorAttachments[0].texture = drawable.texture; + renderPassDescriptor.colorAttachments[0].clearColor + = MTLClearColorMake(0, 0, 0, 0); + if ((viewport->Flags & ImGuiViewportFlags_NoRendererClear) == 0) + renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear; + + id commandBuffer = [data->CommandQueue commandBuffer]; + id renderEncoder = [commandBuffer + renderCommandEncoderWithDescriptor:renderPassDescriptor]; + ImGui_ImplMetal_RenderDrawData(viewport->DrawData, commandBuffer, + renderEncoder); + [renderEncoder endEncoding]; + + [commandBuffer presentDrawable:drawable]; + [commandBuffer commit]; +} + +static void +ImGui_ImplMetal_InitMultiViewportSupport() +{ + ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO(); + platform_io.Renderer_CreateWindow = ImGui_ImplMetal_CreateWindow; + platform_io.Renderer_DestroyWindow = ImGui_ImplMetal_DestroyWindow; + platform_io.Renderer_SetWindowSize = ImGui_ImplMetal_SetWindowSize; + platform_io.Renderer_RenderWindow = ImGui_ImplMetal_RenderWindow; +} + +static void +ImGui_ImplMetal_ShutdownMultiViewportSupport() +{ + ImGui::DestroyPlatformWindows(); +} + +static void +ImGui_ImplMetal_CreateDeviceObjectsForPlatformWindows() +{ + ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO(); + for (int i = 1; i < platform_io.Viewports.Size; i++) + if (!platform_io.Viewports[i]->RendererUserData) + ImGui_ImplMetal_CreateWindow(platform_io.Viewports[i]); +} + +static void +ImGui_ImplMetal_InvalidateDeviceObjectsForPlatformWindows() +{ + ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO(); + for (int i = 1; i < platform_io.Viewports.Size; i++) + if (platform_io.Viewports[i]->RendererUserData) + ImGui_ImplMetal_DestroyWindow(platform_io.Viewports[i]); +} + +# pragma mark - MetalBuffer implementation + +@implementation MetalBuffer +- (instancetype)initWithBuffer:(id)buffer +{ + if ((self = [super init])) { + _buffer = buffer; + _lastReuseTime = GetMachAbsoluteTimeInSeconds(); + } + return self; +} +@end + +# pragma mark - FramebufferDescriptor implementation + +@implementation FramebufferDescriptor +- (instancetype)initWithRenderPassDescriptor: + (MTLRenderPassDescriptor*)renderPassDescriptor +{ + if ((self = [super init])) { + _sampleCount + = renderPassDescriptor.colorAttachments[0].texture.sampleCount; + _colorPixelFormat + = renderPassDescriptor.colorAttachments[0].texture.pixelFormat; + _depthPixelFormat + = renderPassDescriptor.depthAttachment.texture.pixelFormat; + _stencilPixelFormat + = renderPassDescriptor.stencilAttachment.texture.pixelFormat; + } + return self; +} + +- (nonnull id)copyWithZone:(nullable NSZone*)zone +{ + FramebufferDescriptor* copy = [[FramebufferDescriptor allocWithZone:zone] + init]; + copy.sampleCount = self.sampleCount; + copy.colorPixelFormat = self.colorPixelFormat; + copy.depthPixelFormat = self.depthPixelFormat; + copy.stencilPixelFormat = self.stencilPixelFormat; + return copy; +} + +- (NSUInteger)hash +{ + NSUInteger sc = _sampleCount & 0x3; + NSUInteger cf = _colorPixelFormat & 0x3FF; + NSUInteger df = _depthPixelFormat & 0x3FF; + NSUInteger sf = _stencilPixelFormat & 0x3FF; + NSUInteger hash = (sf << 22) | (df << 12) | (cf << 2) | sc; + return hash; +} + +- (BOOL)isEqual:(id)object +{ + FramebufferDescriptor* other = object; + if (![other isKindOfClass:[FramebufferDescriptor class]]) + return NO; + return other.sampleCount == self.sampleCount + && other.colorPixelFormat == self.colorPixelFormat + && other.depthPixelFormat == self.depthPixelFormat + && other.stencilPixelFormat == self.stencilPixelFormat; +} + +@end + +# pragma mark - MetalTexture implementation + +@implementation MetalTexture +- (instancetype)initWithTexture:(id)metalTexture +{ + return [self initWithTexture:metalTexture samplerState:nil]; +} + +- (instancetype)initWithTexture:(id)metalTexture + samplerState:(id)samplerState +{ + if ((self = [super init])) { + self.metalTexture = metalTexture; + self.samplerState = samplerState; + } + return self; +} + +@end + +# pragma mark - MetalContext implementation + +@implementation MetalContext +- (instancetype)init +{ + if ((self = [super init])) { + self.renderPipelineStateCache = [NSMutableDictionary dictionary]; + self.bufferCache = [NSMutableArray array]; + _lastBufferCachePurge = GetMachAbsoluteTimeInSeconds(); + } + return self; +} + +- (MetalBuffer*)dequeueReusableBufferOfLength:(NSUInteger)length + device:(id)device +{ + uint64_t now = GetMachAbsoluteTimeInSeconds(); + + @synchronized(self.bufferCache) { + // Purge old buffers that haven't been useful for a while + if (now - self.lastBufferCachePurge > 1.0) { + NSMutableArray* survivors = [NSMutableArray array]; + for (MetalBuffer* candidate in self.bufferCache) + if (candidate.lastReuseTime > self.lastBufferCachePurge) + [survivors addObject:candidate]; + self.bufferCache = [survivors mutableCopy]; + self.lastBufferCachePurge = now; + } + + // See if we have a buffer we can reuse + MetalBuffer* bestCandidate = nil; + for (MetalBuffer* candidate in self.bufferCache) + if (candidate.buffer.length >= length + && (bestCandidate == nil + || bestCandidate.lastReuseTime > candidate.lastReuseTime)) + bestCandidate = candidate; + + if (bestCandidate != nil) { + [self.bufferCache removeObject:bestCandidate]; + bestCandidate.lastReuseTime = now; + return bestCandidate; + } + } + + // No luck; make a new buffer + id backing = [device + newBufferWithLength:length + options:MTLResourceStorageModeShared]; + return [[MetalBuffer alloc] initWithBuffer:backing]; +} + +// Bilinear sampling is required by default. Set 'io.Fonts->Flags |= ImFontAtlasFlags_NoBakedLines' or 'style.AntiAliasedLinesUseTex = false' to allow point/nearest sampling. +- (id) + renderPipelineStateForFramebufferDescriptor: + (FramebufferDescriptor*)descriptor + device:(id)device +{ + NSError* error = nil; + + NSString* shaderSource + = @"" + "#include \n" + "using namespace metal;\n" + "\n" + "struct Uniforms {\n" + " float4x4 projectionMatrix;\n" + "};\n" + "\n" + "struct VertexIn {\n" + " float2 position [[attribute(0)]];\n" + " float2 texCoords [[attribute(1)]];\n" + " uchar4 color [[attribute(2)]];\n" + "};\n" + "\n" + "struct VertexOut {\n" + " float4 position [[position]];\n" + " float2 texCoords;\n" + " float4 color;\n" + "};\n" + "\n" + "vertex VertexOut vertex_main(VertexIn in [[stage_in]],\n" + " constant Uniforms &uniforms [[buffer(1)]]) {\n" + " VertexOut out;\n" + " out.position = uniforms.projectionMatrix * float4(in.position, 0, 1);\n" + " out.texCoords = in.texCoords;\n" + " out.color = float4(in.color) / float4(255.0);\n" + " return out;\n" + "}\n" + "\n" + "fragment half4 fragment_main(VertexOut in [[stage_in]],\n" + " texture2d texture [[texture(0)]],\n" + " sampler textureSampler [[sampler(0)]]) {\n" + " half4 texColor = texture.sample(textureSampler, in.texCoords);\n" + " return half4(in.color) * texColor;\n" + "}\n"; + + id library = [device newLibraryWithSource:shaderSource + options:nil + error:&error]; + if (library == nil) { + NSLog(@"Error: failed to create Metal library: %@", error); + return nil; + } + + id vertexFunction = [library + newFunctionWithName:@"vertex_main"]; + id fragmentFunction = [library + newFunctionWithName:@"fragment_main"]; + + if (vertexFunction == nil || fragmentFunction == nil) { + NSLog(@"Error: failed to find Metal shader functions in library: %@", + error); + return nil; + } + + MTLVertexDescriptor* vertexDescriptor = + [MTLVertexDescriptor vertexDescriptor]; + vertexDescriptor.attributes[0].offset = offsetof(ImDrawVert, pos); + vertexDescriptor.attributes[0].format = MTLVertexFormatFloat2; // position + vertexDescriptor.attributes[0].bufferIndex = 0; + vertexDescriptor.attributes[1].offset = offsetof(ImDrawVert, uv); + vertexDescriptor.attributes[1].format = MTLVertexFormatFloat2; // texCoords + vertexDescriptor.attributes[1].bufferIndex = 0; + vertexDescriptor.attributes[2].offset = offsetof(ImDrawVert, col); + vertexDescriptor.attributes[2].format = MTLVertexFormatUChar4; // color + vertexDescriptor.attributes[2].bufferIndex = 0; + vertexDescriptor.layouts[0].stepRate = 1; + vertexDescriptor.layouts[0].stepFunction = MTLVertexStepFunctionPerVertex; + vertexDescriptor.layouts[0].stride = sizeof(ImDrawVert); + + MTLRenderPipelineDescriptor* pipelineDescriptor = + [[MTLRenderPipelineDescriptor alloc] init]; + pipelineDescriptor.vertexFunction = vertexFunction; + pipelineDescriptor.fragmentFunction = fragmentFunction; + pipelineDescriptor.vertexDescriptor = vertexDescriptor; + pipelineDescriptor.rasterSampleCount + = self.framebufferDescriptor.sampleCount; + pipelineDescriptor.colorAttachments[0].pixelFormat + = self.framebufferDescriptor.colorPixelFormat; + pipelineDescriptor.colorAttachments[0].blendingEnabled = YES; + pipelineDescriptor.colorAttachments[0].rgbBlendOperation + = MTLBlendOperationAdd; + pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor + = MTLBlendFactorSourceAlpha; + pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor + = MTLBlendFactorOneMinusSourceAlpha; + pipelineDescriptor.colorAttachments[0].alphaBlendOperation + = MTLBlendOperationAdd; + pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor + = MTLBlendFactorOne; + pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor + = MTLBlendFactorOneMinusSourceAlpha; + pipelineDescriptor.depthAttachmentPixelFormat + = self.framebufferDescriptor.depthPixelFormat; + pipelineDescriptor.stencilAttachmentPixelFormat + = self.framebufferDescriptor.stencilPixelFormat; + + id renderPipelineState = [device + newRenderPipelineStateWithDescriptor:pipelineDescriptor + error:&error]; + if (error != nil) + NSLog(@"Error: failed to create Metal pipeline state: %@", error); + + return renderPipelineState; +} + +@end + +//----------------------------------------------------------------------------- + +#endif // #ifndef IMGUI_DISABLE diff --git a/src/imiv/imiv_action_dispatch.cpp b/src/imiv/imiv_action_dispatch.cpp new file mode 100644 index 0000000000..ed9e892aad --- /dev/null +++ b/src/imiv/imiv_action_dispatch.cpp @@ -0,0 +1,305 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_frame_actions.h" + +#include "imiv_actions.h" +#include "imiv_file_actions.h" +#include "imiv_image_library.h" +#include "imiv_viewer.h" + +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + void clear_recent_images_action(ViewerState& viewer, + ImageLibraryState& library, + MultiViewWorkspace* workspace) + { + library.recent_images.clear(); + if (workspace != nullptr) { + sync_workspace_library_state(*workspace, library); + } else { + viewer.recent_images.clear(); + } + viewer.status_message = "Cleared recent files list"; + viewer.last_error.clear(); + } + + void toggle_full_screen_action(GLFWwindow* window, ViewerState& viewer, + PlaceholderUiState& ui_state) + { + ui_state.full_screen_mode = !ui_state.full_screen_mode; + std::string fullscreen_error; + set_full_screen_mode(window, viewer, ui_state.full_screen_mode, + fullscreen_error); + if (!fullscreen_error.empty()) { + viewer.last_error = fullscreen_error; + ui_state.full_screen_mode = viewer.fullscreen_applied; + return; + } + + viewer.status_message = ui_state.full_screen_mode + ? "Entered full screen" + : "Exited full screen"; + viewer.last_error.clear(); + } + + void delete_current_image_from_disk_action(RendererState& renderer_state, + ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) + { + if (viewer.image.path.empty()) + return; + + const std::string to_delete = viewer.image.path; + close_current_image_action(renderer_state, viewer, library, ui_state); + std::error_code ec; + if (std::filesystem::remove(to_delete, ec)) { + remove_loaded_image_path(library, &viewer, to_delete); + viewer.status_message = Strutil::fmt::format("Deleted {}", + to_delete); + viewer.last_error.clear(); + return; + } + + viewer.last_error = ec ? Strutil::fmt::format("Delete failed: {}", + ec.message()) + : "Delete failed"; + } + + void create_new_view_action(RendererState& renderer_state, + ViewerState& viewer, ImageLibraryState& library, + PlaceholderUiState& ui_state, + MultiViewWorkspace* workspace) + { + if (workspace == nullptr || viewer.image.path.empty()) + return; + + ImageViewWindow& new_view = append_image_view(*workspace); + sync_viewer_library_state(new_view.viewer, library); + new_view.viewer.recipe = viewer.recipe; + new_view.request_focus = true; + if (load_viewer_image(renderer_state, new_view.viewer, library, + &ui_state, viewer.image.path, + viewer.image.subimage, viewer.image.miplevel)) { + workspace->active_view_id = new_view.id; + sync_workspace_library_state(*workspace, library); + } + } + + int apply_orientation_step(int orientation, const int next_orientation[9]) + { + return next_orientation[clamp_orientation(orientation)]; + } + + void apply_orientation_actions(ViewerState& viewer, + ViewerFrameActions& actions) + { + if (!actions.rotate_left_requested && !actions.rotate_right_requested + && !actions.flip_horizontal_requested + && !actions.flip_vertical_requested) { + return; + } + + if (viewer.image.path.empty()) { + viewer.status_message = "No image loaded"; + viewer.last_error.clear(); + } else { + int orientation = clamp_orientation(viewer.image.orientation); + if (actions.rotate_left_requested) { + static const int next_orientation[] = { 0, 8, 5, 6, 7, + 4, 1, 2, 3 }; + orientation = apply_orientation_step(orientation, + next_orientation); + } + if (actions.rotate_right_requested) { + static const int next_orientation[] = { 0, 6, 7, 8, 5, + 2, 3, 4, 1 }; + orientation = apply_orientation_step(orientation, + next_orientation); + } + if (actions.flip_horizontal_requested) { + static const int next_orientation[] = { 0, 2, 1, 4, 3, + 6, 5, 8, 7 }; + orientation = apply_orientation_step(orientation, + next_orientation); + } + if (actions.flip_vertical_requested) { + static const int next_orientation[] = { 0, 4, 3, 2, 1, + 8, 7, 6, 5 }; + orientation = apply_orientation_step(orientation, + next_orientation); + } + viewer.image.orientation = clamp_orientation(orientation); + viewer.fit_request = true; + viewer.status_message + = Strutil::fmt::format("Orientation set to {}", + viewer.image.orientation); + viewer.last_error.clear(); + } + + actions.rotate_left_requested = false; + actions.rotate_right_requested = false; + actions.flip_horizontal_requested = false; + actions.flip_vertical_requested = false; + } + + void advance_slide_show_if_due(RendererState& renderer_state, + ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) + { + if (!ui_state.slide_show_running || viewer.image.path.empty() + || viewer.loaded_image_paths.empty()) { + viewer.slide_last_advance_time = 0.0; + return; + } + + const double now = ImGui::GetTime(); + if (viewer.slide_last_advance_time <= 0.0) + viewer.slide_last_advance_time = now; + const double delay = std::max(1, ui_state.slide_duration_seconds); + if (now - viewer.slide_last_advance_time >= delay) { + (void)advance_slide_show_action(renderer_state, viewer, library, + ui_state); + viewer.slide_last_advance_time = now; + } + } + +} // namespace + +void +execute_viewer_frame_actions(ViewerState& viewer, PlaceholderUiState& ui_state, + ImageLibraryState& library, + MultiViewWorkspace* workspace, + ViewerFrameActions& actions +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + , + GLFWwindow* window, RendererState& renderer_state +#endif +) +{ + if (actions.open_requested) { + open_image_dialog_action(renderer_state, viewer, library, ui_state, + ui_state.subimage_index, + ui_state.miplevel_index); + actions.open_requested = false; + } + if (actions.open_folder_requested) { + open_folder_dialog_action(renderer_state, viewer, library, ui_state, + workspace); + actions.open_folder_requested = false; + } + if (!actions.recent_open_path.empty()) { + load_viewer_image(renderer_state, viewer, library, &ui_state, + actions.recent_open_path, ui_state.subimage_index, + ui_state.miplevel_index); + actions.recent_open_path.clear(); + } + if (actions.clear_recent_requested) { + clear_recent_images_action(viewer, library, workspace); + actions.clear_recent_requested = false; + } + if (actions.reload_requested) { + reload_current_image_action(renderer_state, viewer, library, ui_state); + actions.reload_requested = false; + } + if (actions.close_requested) { + close_current_image_action(renderer_state, viewer, library, ui_state); + actions.close_requested = false; + } + if (actions.prev_requested) { + next_sibling_image_action(renderer_state, viewer, library, ui_state, + -1); + actions.prev_requested = false; + } + if (actions.next_requested) { + next_sibling_image_action(renderer_state, viewer, library, ui_state, 1); + actions.next_requested = false; + } + if (actions.toggle_requested) { + toggle_image_action(renderer_state, viewer, library, ui_state); + actions.toggle_requested = false; + } + if (actions.prev_subimage_requested) { + change_subimage_action(renderer_state, viewer, library, ui_state, -1); + actions.prev_subimage_requested = false; + } + if (actions.next_subimage_requested) { + change_subimage_action(renderer_state, viewer, library, ui_state, 1); + actions.next_subimage_requested = false; + } + if (actions.prev_mip_requested) { + change_miplevel_action(renderer_state, viewer, library, ui_state, -1); + actions.prev_mip_requested = false; + } + if (actions.next_mip_requested) { + change_miplevel_action(renderer_state, viewer, library, ui_state, 1); + actions.next_mip_requested = false; + } + if (actions.save_as_requested) { + save_as_dialog_action(viewer); + actions.save_as_requested = false; + } + if (actions.save_window_as_requested) { + save_window_as_dialog_action(viewer, ui_state); + actions.save_window_as_requested = false; + } + if (actions.save_selection_as_requested) { + save_selection_as_dialog_action(viewer); + actions.save_selection_as_requested = false; + } + if (actions.export_selection_as_requested) { + export_selection_as_dialog_action(viewer, ui_state); + actions.export_selection_as_requested = false; + } + if (actions.select_all_requested) { + select_all_image_action(viewer, ui_state); + actions.select_all_requested = false; + } + if (actions.deselect_selection_requested) { + deselect_selection_action(viewer, ui_state); + actions.deselect_selection_requested = false; + } + if (actions.fit_window_to_image_requested) { + fit_window_to_image_action(window, viewer, ui_state); + actions.fit_window_to_image_requested = false; + } + if (actions.full_screen_toggle_requested) { + toggle_full_screen_action(window, viewer, ui_state); + actions.full_screen_toggle_requested = false; + } + if (actions.delete_from_disk_requested) { + delete_current_image_from_disk_action(renderer_state, viewer, library, + ui_state); + actions.delete_from_disk_requested = false; + } + if (actions.new_view_requested) { + create_new_view_action(renderer_state, viewer, library, ui_state, + workspace); + } + actions.new_view_requested = false; + + apply_orientation_actions(viewer, actions); + advance_slide_show_if_due(renderer_state, viewer, library, ui_state); + + if (workspace != nullptr) + sync_workspace_library_state(*workspace, library); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_actions.cpp b/src/imiv/imiv_actions.cpp new file mode 100644 index 0000000000..84c128f188 --- /dev/null +++ b/src/imiv/imiv_actions.cpp @@ -0,0 +1,580 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_actions.h" + +#include "imiv_file_dialog.h" +#include "imiv_image_library.h" +#include "imiv_loaded_image.h" +#include "imiv_ocio.h" +#include "imiv_parse.h" +#include "imiv_probe_overlay.h" +#include "imiv_ui.h" +#include "imiv_viewer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +# define GLFW_INCLUDE_NONE +# include +#endif + +#include + +#include +#include +#include +#include +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + void clear_loaded_image_state(ViewerState& viewer) + { + viewer.image = LoadedImage(); + viewer.zoom = 1.0f; + viewer.fit_request = true; + reset_view_navigation_state(viewer); + viewer.probe_valid = false; + viewer.probe_channels.clear(); + reset_area_probe_overlay(viewer); + } + + void calc_subimage_from_zoom(const LoadedImage& image, int& subimage, + float& zoom) + { + const int rel_subimage = static_cast( + std::trunc(std::log2(std::max(1.0e-6f, 1.0f / zoom)))); + subimage = std::clamp(image.subimage + rel_subimage, 0, + image.nsubimages - 1); + if (!(image.subimage == 0 && zoom > 1.0f) + && !(image.subimage == image.nsubimages - 1 && zoom < 1.0f)) { + const float pow_zoom = std::pow(2.0f, + static_cast(rel_subimage)); + zoom *= pow_zoom; + } + } + + void restore_view_after_subimage_load(ViewerState& viewer, float zoom, + const ImVec2& norm_scroll) + { + int display_width = viewer.image.width; + int display_height = viewer.image.height; + oriented_image_dimensions(viewer.image, display_width, display_height); + viewer.zoom = zoom; + viewer.fit_request = false; + const ImVec2 image_size(static_cast(display_width) * viewer.zoom, + static_cast(display_height) + * viewer.zoom); + sync_view_scroll_from_display_scroll( + viewer, + ImVec2(std::clamp(norm_scroll.x, 0.0f, 1.0f) * image_size.x, + std::clamp(norm_scroll.y, 0.0f, 1.0f) * image_size.y), + image_size); + viewer.scroll_sync_frames_left + = std::max(viewer.scroll_sync_frames_left, 2); + } + +} // namespace + +bool +viewer_texture_has_gpu_lifetime(const RendererTexture& texture) +{ + return texture.backend != nullptr; +} + +void +quiesce_viewer_texture_lifetime(RendererState& renderer_state, + const RendererTexture& texture) +{ + if (!viewer_texture_has_gpu_lifetime(texture)) + return; + std::string error_message; + renderer_wait_idle(renderer_state, error_message); +} + +bool +load_viewer_image(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState* ui_state, + const std::string& path, int requested_subimage, + int requested_miplevel) +{ + viewer.last_error.clear(); + const std::string previous_path = viewer.image.path; + const int previous_index = viewer.current_path_index; + LoadedImage loaded; + std::string error; + if (!load_image_for_compute(path, requested_subimage, requested_miplevel, + viewer.rawcolor, loaded, error)) { + viewer.last_error = Strutil::fmt::format("open failed: {}", error); + print(stderr, "imiv: {}\n", viewer.last_error); + return false; + } + RendererTexture texture; + if (!renderer_create_texture(vk_state, loaded, texture, error)) { + viewer.last_error = Strutil::fmt::format("upload failed: {}", error); + print(stderr, "imiv: {}\n", viewer.last_error); + return false; + } + quiesce_viewer_texture_lifetime(vk_state, viewer.texture); + renderer_destroy_texture(vk_state, viewer.texture); + if (should_reset_preview_on_load(viewer, path)) + reset_per_image_preview_state(viewer.recipe); + viewer.image = std::move(loaded); + viewer.texture = std::move(texture); + viewer.zoom = 1.0f; + viewer.fit_request = true; + reset_view_navigation_state(viewer); + viewer.probe_valid = false; + viewer.probe_channels.clear(); + reset_area_probe_overlay(viewer); + if (viewer.image.width > 0 && viewer.image.height > 0) { + const int center_x = viewer.image.width / 2; + const int center_y = viewer.image.height / 2; + std::vector sample; + if (sample_loaded_pixel(viewer.image, center_x, center_y, sample)) { + viewer.probe_valid = true; + viewer.probe_x = center_x; + viewer.probe_y = center_y; + viewer.probe_channels = std::move(sample); + } + } + int loaded_index = -1; + add_loaded_image_path(library, viewer.image.path, &loaded_index); + viewer.loaded_image_paths = library.loaded_image_paths; + viewer.recent_images = library.recent_images; + viewer.sort_mode = library.sort_mode; + viewer.sort_reverse = library.sort_reverse; + if (!previous_path.empty() && previous_index >= 0 + && previous_path != viewer.image.path + && previous_index != loaded_index) { + viewer.last_path_index = previous_index; + } + viewer.current_path_index = loaded_index; + add_recent_image_path(library, viewer.image.path); + viewer.recent_images = library.recent_images; + viewer.status_message = Strutil::fmt::format( + "Loaded {} ({}x{}, {} channels, {}, subimage {}/{}, mip {}/{})", + viewer.image.path, viewer.image.width, viewer.image.height, + viewer.image.nchannels, upload_data_type_name(viewer.image.type), + viewer.image.subimage + 1, viewer.image.nsubimages, + viewer.image.miplevel + 1, viewer.image.nmiplevels); + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_JUNIT_XML")) { + print("imiv: {}\n", viewer.status_message); + } + return true; +} + +void +set_placeholder_status(ViewerState& viewer, const char* action) +{ + viewer.status_message = Strutil::fmt::format("{} (not implemented yet)", + action); + viewer.last_error.clear(); +} + +void +set_full_screen_mode(GLFWwindow* window, ViewerState& viewer, bool enable, + std::string& error_message) +{ + error_message.clear(); + if (window == nullptr) + return; + if (enable == viewer.fullscreen_applied) + return; + + if (enable) { + GLFWmonitor* monitor = glfwGetPrimaryMonitor(); + if (monitor == nullptr) { + error_message = "fullscreen failed: no primary monitor"; + return; + } + const GLFWvidmode* mode = glfwGetVideoMode(monitor); + if (mode == nullptr) { + error_message = "fullscreen failed: monitor mode unavailable"; + return; + } + + glfwGetWindowPos(window, &viewer.windowed_x, &viewer.windowed_y); + glfwGetWindowSize(window, &viewer.windowed_width, + &viewer.windowed_height); + glfwSetWindowMonitor(window, monitor, 0, 0, mode->width, mode->height, + mode->refreshRate); + viewer.fullscreen_applied = true; + return; + } + + const int restore_w = std::max(320, viewer.windowed_width); + const int restore_h = std::max(240, viewer.windowed_height); + glfwSetWindowMonitor(window, nullptr, viewer.windowed_x, viewer.windowed_y, + restore_w, restore_h, 0); + viewer.fullscreen_applied = false; +} + +void +fit_window_to_image_action(GLFWwindow* window, ViewerState& viewer, + PlaceholderUiState& ui_state) +{ + if (window == nullptr || viewer.image.path.empty()) + return; + if (viewer.fullscreen_applied || ui_state.full_screen_mode) + return; + + int window_w = 0; + int window_h = 0; + int fb_w = 0; + int fb_h = 0; + glfwGetWindowSize(window, &window_w, &window_h); + glfwGetFramebufferSize(window, &fb_w, &fb_h); + + const float scale_x = (fb_w > 0) ? (static_cast(window_w) / fb_w) + : 1.0f; + const float scale_y = (fb_h > 0) ? (static_cast(window_h) / fb_h) + : 1.0f; + + constexpr int k_view_padding_px = 24; + constexpr int k_ui_overhead_px = 120; + int display_width = viewer.image.width; + int display_height = viewer.image.height; + oriented_image_dimensions(viewer.image, display_width, display_height); + const int target_fb_w = std::max(320, display_width + k_view_padding_px); + const int target_fb_h = std::max(240, display_height + k_ui_overhead_px); + const int target_w = static_cast( + std::round(static_cast(target_fb_w) * scale_x)); + const int target_h = static_cast( + std::round(static_cast(target_fb_h) * scale_y)); + + glfwSetWindowSize(window, target_w, target_h); + ui_state.fit_image_to_window = false; + viewer.zoom = 1.0f; + viewer.fit_request = false; + viewer.status_message = Strutil::fmt::format("Fit window to image: {}x{}", + target_w, target_h); + viewer.last_error.clear(); +} + +void +select_all_image_action(ViewerState& viewer, const PlaceholderUiState& ui_state) +{ + if (viewer.image.path.empty()) { + viewer.status_message = "No image loaded"; + viewer.last_error.clear(); + return; + } + set_image_selection(viewer, 0, 0, viewer.image.width, viewer.image.height); + sync_area_probe_to_selection(viewer, ui_state); + viewer.status_message = Strutil::fmt::format("Selected full image ({}x{})", + viewer.image.width, + viewer.image.height); + viewer.last_error.clear(); +} + +void +deselect_selection_action(ViewerState& viewer, + const PlaceholderUiState& ui_state) +{ + if (!has_image_selection(viewer)) { + sync_area_probe_to_selection(viewer, ui_state); + viewer.status_message = "No selection"; + viewer.last_error.clear(); + return; + } + clear_image_selection(viewer); + sync_area_probe_to_selection(viewer, ui_state); + viewer.status_message = "Selection cleared"; + viewer.last_error.clear(); +} + +void +set_area_sample_enabled(ViewerState& viewer, PlaceholderUiState& ui_state, + bool enabled) +{ + ui_state.show_area_probe_window = enabled; + if (enabled) { + ui_state.mouse_mode = 3; + sync_area_probe_to_selection(viewer, ui_state); + return; + } + + if (ui_state.mouse_mode == 3) + ui_state.mouse_mode = 0; + clear_image_selection(viewer); + reset_area_probe_overlay(viewer); +} + +void +set_mouse_mode_action(ViewerState& viewer, PlaceholderUiState& ui_state, + int mouse_mode) +{ + ui_state.mouse_mode = std::clamp(mouse_mode, 0, 4); + if (ui_state.mouse_mode == 3) + ui_state.mouse_mode = 0; + if (ui_state.show_area_probe_window) { + set_area_sample_enabled(viewer, ui_state, false); + } +} + +void +set_sort_mode_action(ImageLibraryState& library, + const std::vector& viewers, + ImageSortMode mode) +{ + library.sort_mode = mode; + sort_loaded_image_paths(library, viewers); + for (ViewerState* viewer : viewers) { + if (viewer == nullptr) + continue; + sync_viewer_library_state(*viewer, library); + viewer->status_message = "Image list sort mode changed"; + viewer->last_error.clear(); + } +} + +void +toggle_sort_reverse_action(ImageLibraryState& library, + const std::vector& viewers) +{ + library.sort_reverse = !library.sort_reverse; + sort_loaded_image_paths(library, viewers); + for (ViewerState* viewer : viewers) { + if (viewer == nullptr) + continue; + sync_viewer_library_state(*viewer, library); + viewer->status_message = library.sort_reverse + ? "Image list order reversed" + : "Image list order restored"; + viewer->last_error.clear(); + } +} + +bool +advance_slide_show_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) +{ + if (!ui_state.slide_show_running || library.loaded_image_paths.empty() + || viewer.image.path.empty()) { + return false; + } + + const int count = static_cast(library.loaded_image_paths.size()); + if (count <= 0 || viewer.current_path_index < 0) + return false; + + if (!ui_state.slide_loop && viewer.current_path_index >= count - 1) { + ui_state.slide_show_running = false; + viewer.status_message = "Slide show reached final image"; + viewer.last_error.clear(); + return false; + } + + std::string next_path; + if (!pick_loaded_image_path(library, viewer, 1, next_path) + || next_path.empty()) + return false; + return load_viewer_image(vk_state, viewer, library, &ui_state, next_path, + viewer.image.subimage, viewer.image.miplevel); +} + +void +toggle_slide_show_action(PlaceholderUiState& ui_state, ViewerState& viewer) +{ + ui_state.slide_show_running = !ui_state.slide_show_running; + if (ui_state.slide_show_running) + ui_state.full_screen_mode = true; + viewer.slide_last_advance_time = ImGui::GetTime(); + viewer.status_message = ui_state.slide_show_running ? "Slide show started" + : "Slide show stopped"; + viewer.last_error.clear(); +} + +void +reload_current_image_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) +{ + if (viewer.image.path.empty()) { + viewer.status_message = "No image loaded"; + viewer.last_error.clear(); + return; + } + load_viewer_image(vk_state, viewer, library, &ui_state, viewer.image.path, + viewer.image.subimage, viewer.image.miplevel); +} + +void +close_current_image_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) +{ + quiesce_viewer_texture_lifetime(vk_state, viewer.texture); + renderer_destroy_texture(vk_state, viewer.texture); + clear_loaded_image_state(viewer); + viewer.loaded_image_paths = library.loaded_image_paths; + viewer.recent_images = library.recent_images; + viewer.sort_mode = library.sort_mode; + viewer.sort_reverse = library.sort_reverse; + viewer.current_path_index = -1; + viewer.last_path_index = -1; + viewer.last_error.clear(); + viewer.status_message = "Closed current image"; +} + +void +next_sibling_image_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, int delta) +{ + std::string path; + if (!pick_loaded_image_path(library, viewer, delta, path)) { + viewer.status_message = (delta < 0) ? "Previous image unavailable" + : "Next image unavailable"; + viewer.last_error.clear(); + return; + } + load_viewer_image(vk_state, viewer, library, &ui_state, path, + viewer.image.subimage, viewer.image.miplevel); +} + +void +toggle_image_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState& ui_state) +{ + if (viewer.last_path_index < 0 + || viewer.last_path_index + >= static_cast(library.loaded_image_paths.size())) { + viewer.status_message = "No toggled image available"; + viewer.last_error.clear(); + return; + } + const std::string toggle_path + = library + .loaded_image_paths[static_cast(viewer.last_path_index)]; + load_viewer_image(vk_state, viewer, library, &ui_state, toggle_path, + viewer.image.subimage, viewer.image.miplevel); +} + +void +change_subimage_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState& ui_state, + int delta) +{ + if (viewer.image.path.empty()) { + viewer.status_message = "No image loaded"; + viewer.last_error.clear(); + return; + } + viewer.pending_auto_subimage = -1; + bool ok = false; + if (delta < 0) { + if (viewer.image.miplevel > 0) { + viewer.auto_subimage = false; + ok = load_viewer_image(vk_state, viewer, library, &ui_state, + viewer.image.path, viewer.image.subimage, + viewer.image.miplevel - 1); + } else if (viewer.image.subimage > 0) { + viewer.auto_subimage = false; + ok = load_viewer_image(vk_state, viewer, library, &ui_state, + viewer.image.path, viewer.image.subimage - 1, + 0); + } else if (viewer.image.nsubimages > 1) { + viewer.auto_subimage = true; + viewer.status_message = "Auto subimage enabled"; + viewer.last_error.clear(); + } + } else if (delta > 0) { + if (viewer.auto_subimage) { + viewer.auto_subimage = false; + ok = load_viewer_image(vk_state, viewer, library, &ui_state, + viewer.image.path, 0, 0); + } else if (viewer.image.miplevel < viewer.image.nmiplevels - 1) { + ok = load_viewer_image(vk_state, viewer, library, &ui_state, + viewer.image.path, viewer.image.subimage, + viewer.image.miplevel + 1); + } else if (viewer.image.subimage < viewer.image.nsubimages - 1) { + ok = load_viewer_image(vk_state, viewer, library, &ui_state, + viewer.image.path, viewer.image.subimage + 1, + 0); + } + } + if (ok) + viewer.last_error.clear(); +} + +void +change_miplevel_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState& ui_state, + int delta) +{ + if (viewer.image.path.empty()) { + viewer.status_message = "No image loaded"; + viewer.last_error.clear(); + return; + } + const int target_mip = viewer.image.miplevel + delta; + if (target_mip < 0 || target_mip >= viewer.image.nmiplevels) + return; + viewer.auto_subimage = false; + viewer.pending_auto_subimage = -1; + load_viewer_image(vk_state, viewer, library, &ui_state, viewer.image.path, + viewer.image.subimage, target_mip); +} + +void +queue_auto_subimage_from_zoom(ViewerState& viewer) +{ + viewer.pending_auto_subimage = -1; + if (!viewer.auto_subimage || viewer.image.path.empty() + || viewer.image.nsubimages <= 1) { + return; + } + int target_subimage = viewer.image.subimage; + float adjusted_zoom = viewer.zoom; + calc_subimage_from_zoom(viewer.image, target_subimage, adjusted_zoom); + if (target_subimage == viewer.image.subimage) + return; + viewer.pending_auto_subimage = target_subimage; + viewer.pending_auto_subimage_zoom = adjusted_zoom; + viewer.pending_auto_subimage_norm_scroll = viewer.norm_scroll; +} + +bool +apply_pending_auto_subimage_action(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) +{ + if (viewer.pending_auto_subimage < 0 || viewer.image.path.empty()) + return false; + const int target_subimage = viewer.pending_auto_subimage; + const float adjusted_zoom = viewer.pending_auto_subimage_zoom; + const ImVec2 preserved_scroll = viewer.pending_auto_subimage_norm_scroll; + const bool auto_mode = viewer.auto_subimage; + viewer.pending_auto_subimage = -1; + if (target_subimage < 0 || target_subimage >= viewer.image.nsubimages) + return false; + if (!load_viewer_image(vk_state, viewer, library, &ui_state, + viewer.image.path, target_subimage, 0)) { + return false; + } + viewer.auto_subimage = auto_mode; + restore_view_after_subimage_load(viewer, adjusted_zoom, preserved_scroll); + return true; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_actions.h b/src/imiv/imiv_actions.h new file mode 100644 index 0000000000..ad7905a118 --- /dev/null +++ b/src/imiv/imiv_actions.h @@ -0,0 +1,89 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_renderer.h" +#include "imiv_viewer.h" + +#include + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +struct GLFWwindow; +#endif + +namespace Imiv { + +bool +load_viewer_image(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState* ui_state, + const std::string& path, int requested_subimage, + int requested_miplevel); +void +set_placeholder_status(ViewerState& viewer, const char* action); +void +select_all_image_action(ViewerState& viewer, + const PlaceholderUiState& ui_state); +void +deselect_selection_action(ViewerState& viewer, + const PlaceholderUiState& ui_state); +void +set_area_sample_enabled(ViewerState& viewer, PlaceholderUiState& ui_state, + bool enabled); +void +set_mouse_mode_action(ViewerState& viewer, PlaceholderUiState& ui_state, + int mouse_mode); +void +set_sort_mode_action(ImageLibraryState& library, + const std::vector& viewers, + ImageSortMode mode); +void +toggle_sort_reverse_action(ImageLibraryState& library, + const std::vector& viewers); +bool +advance_slide_show_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state); +void +toggle_slide_show_action(PlaceholderUiState& ui_state, ViewerState& viewer); +void +reload_current_image_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state); +void +close_current_image_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state); +void +next_sibling_image_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, int delta); +void +toggle_image_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState& ui_state); +void +change_subimage_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState& ui_state, + int delta); +void +change_miplevel_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, PlaceholderUiState& ui_state, + int delta); +void +queue_auto_subimage_from_zoom(ViewerState& viewer); +bool +apply_pending_auto_subimage_action(RendererState& renderer_state, + ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state); + +void +set_full_screen_mode(GLFWwindow* window, ViewerState& viewer, bool enable, + std::string& error_message); +void +fit_window_to_image_action(GLFWwindow* window, ViewerState& viewer, + PlaceholderUiState& ui_state); + +} // namespace Imiv diff --git a/src/imiv/imiv_app.cpp b/src/imiv/imiv_app.cpp new file mode 100644 index 0000000000..eb50171533 --- /dev/null +++ b/src/imiv/imiv_app.cpp @@ -0,0 +1,845 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_app.h" +#include "imiv_actions.h" +#include "imiv_build_config.h" +#include "imiv_developer_tools.h" +#include "imiv_drag_drop.h" +#include "imiv_file_dialog.h" +#include "imiv_frame.h" +#include "imiv_image_library.h" +#include "imiv_menu.h" +#include "imiv_navigation.h" +#include "imiv_ocio.h" +#include "imiv_parse.h" +#include "imiv_persistence.h" +#include "imiv_platform_glfw.h" +#include "imiv_renderer.h" +#include "imiv_style.h" +#include "imiv_test_engine.h" +#include "imiv_types.h" +#include "imiv_ui.h" +#include "imiv_viewer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace OIIO; + +#if defined(IMIV_EMBED_FONTS) && IMIV_EMBED_FONTS +# include "imiv_font_droidsans_ttf.h" +# include "imiv_font_droidsansmono_ttf.h" +#endif + +namespace Imiv { + +namespace { + + struct NativeDialogWindowScope { + GLFWwindow* window = nullptr; + PlaceholderUiState* ui_state = nullptr; + int suspend_depth = 0; + bool restore_floating_on_exit = false; + }; + + std::filesystem::path executable_directory_path() + { + const std::string program_path = Sysutil::this_program_path(); + if (program_path.empty()) + return std::filesystem::path(); + return std::filesystem::path(program_path).parent_path(); + } + + ImFont* load_font_if_present(const std::filesystem::path& path, + float size_pixels) + { + std::error_code ec; + if (path.empty() || !std::filesystem::exists(path, ec) || ec) + return nullptr; + ImGuiIO& io = ImGui::GetIO(); + return io.Fonts->AddFontFromFileTTF(path.string().c_str(), size_pixels); + } + + ImFont* load_embedded_font_if_present(const unsigned char* data, + size_t size_bytes, float size_pixels) + { + if (data == nullptr || size_bytes == 0) + return nullptr; + ImGuiIO& io = ImGui::GetIO(); + ImFontConfig config; + config.FontDataOwnedByAtlas = false; + return io.Fonts->AddFontFromMemoryTTF(const_cast(data), + static_cast(size_bytes), + size_pixels, &config); + } + + AppFonts setup_app_fonts(bool verbose_logging) + { + AppFonts fonts; + ImGuiIO& io = ImGui::GetIO(); + const char* ui_font_source = "missing"; + const char* mono_font_source = "missing"; + + const std::filesystem::path font_root = executable_directory_path() + / "fonts"; + const std::filesystem::path ui_font_path = font_root / "Droid_Sans" + / "DroidSans.ttf"; + const std::filesystem::path mono_font_path + = font_root / "Droid_Sans_Mono" / "DroidSansMono.ttf"; + +#if defined(IMIV_EMBED_FONTS) && IMIV_EMBED_FONTS + fonts.ui = load_embedded_font_if_present(g_imiv_font_droidsans_ttf, + g_imiv_font_droidsans_ttf_size, + 16.0f); + if (fonts.ui) + ui_font_source = "embedded"; + fonts.mono + = load_embedded_font_if_present(g_imiv_font_droidsansmono_ttf, + g_imiv_font_droidsansmono_ttf_size, + 16.0f); + if (fonts.mono) + mono_font_source = "embedded"; +#endif + + if (!fonts.ui) { + fonts.ui = load_font_if_present(ui_font_path, 16.0f); + if (fonts.ui) + ui_font_source = "file"; + } + if (!fonts.mono) { + fonts.mono = load_font_if_present(mono_font_path, 16.0f); + if (fonts.mono) + mono_font_source = "file"; + } + if (!fonts.ui) { + fonts.ui = io.Fonts->AddFontDefault(); + ui_font_source = "default"; + } + if (!fonts.mono) { + fonts.mono = fonts.ui; + mono_font_source = (fonts.ui == fonts.mono && ui_font_source) + ? ui_font_source + : "default"; + } + io.FontDefault = fonts.ui; + + print("imiv: fonts ui={} mono={}\n", ui_font_source, mono_font_source); + return fonts; + } + bool default_developer_mode_enabled() + { +#if defined(NDEBUG) + return false; +#else + return true; +#endif + } + + bool resolve_developer_mode_enabled(const AppConfig& config, + bool verbose_logging) + { + bool enabled = default_developer_mode_enabled(); + + std::string env_value; + if (read_env_value("OIIO_DEVMODE", env_value)) { + bool parsed_value = false; + if (parse_bool_string(env_value, parsed_value)) { + enabled = parsed_value; + } else if (verbose_logging) { + print(stderr, + "imiv: ignoring invalid OIIO_DEVMODE value '{}'; " + "expected 0/1/true/false/on/off/yes/no\n", + env_value); + } + } + + if (config.developer_mode_explicit) + enabled = config.developer_mode; + return enabled; + } + + BackendKind requested_backend_for_launch(const AppConfig& config, + const PlaceholderUiState& ui_state) + { + if (config.requested_backend != BackendKind::Auto) + return config.requested_backend; + return sanitize_backend_kind(ui_state.renderer_backend); + } + + void apply_glfw_topmost_state_to_platform_windows(GLFWwindow* main_window, + bool always_on_top) + { + if (main_window != nullptr) + platform_glfw_set_window_floating(main_window, always_on_top); + + ImGuiContext* ctx = ImGui::GetCurrentContext(); + if (ctx == nullptr) + return; + if ((ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) + == 0) { + return; + } + + ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO(); + for (int i = 0; i < platform_io.Viewports.Size; ++i) { + ImGuiViewport* viewport = platform_io.Viewports[i]; + if (viewport == nullptr || viewport->PlatformHandle == nullptr) + continue; + GLFWwindow* viewport_window = static_cast( + viewport->PlatformHandle); + platform_glfw_set_window_floating(viewport_window, always_on_top); + } + } + + void native_dialog_scope_callback(bool begin_scope, void* user_data) + { + auto* scope = static_cast(user_data); + if (scope == nullptr || scope->window == nullptr + || scope->ui_state == nullptr) + return; + + if (begin_scope) { + ++scope->suspend_depth; + if (scope->suspend_depth == 1 + && scope->ui_state->window_always_on_top + && platform_glfw_is_window_floating(scope->window)) { + apply_glfw_topmost_state_to_platform_windows(scope->window, + false); + scope->restore_floating_on_exit = true; + } + return; + } + + if (scope->suspend_depth > 0) + --scope->suspend_depth; + if (scope->suspend_depth == 0 && scope->restore_floating_on_exit) { + apply_glfw_topmost_state_to_platform_windows( + scope->window, scope->ui_state->window_always_on_top); + scope->restore_floating_on_exit = false; + } + } + + std::vector expand_startup_input_paths(const AppConfig& config, + bool verbose_logging, + ImageSortMode sort_mode, + bool sort_reverse) + { + std::vector expanded; + for (const std::string& input_path : config.input_paths) { + std::error_code ec; + const std::filesystem::path path(input_path); + if (std::filesystem::is_directory(path, ec) && !ec) { + std::vector directory_paths; + std::string error_message; + if (!collect_directory_image_paths(input_path, sort_mode, + sort_reverse, + directory_paths, + error_message)) { + print(stderr, "imiv: {}\n", error_message); + continue; + } + if (directory_paths.empty()) { + print(stderr, + "imiv: no supported image files found in '{}'\n", + input_path); + continue; + } + expanded.insert(expanded.end(), directory_paths.begin(), + directory_paths.end()); + continue; + } + if (ec && verbose_logging) { + print(stderr, + "imiv: ignoring directory probe error for '{}': {}\n", + input_path, ec.message()); + } + expanded.push_back(input_path); + } + return expanded; + } +} // namespace + + + +int +run(const AppConfig& config) +{ + AppConfig run_config = config; + run_config.input_paths.erase( + std::remove_if(run_config.input_paths.begin(), + run_config.input_paths.end(), + [](const std::string& path) { + return Strutil::strip(path).empty(); + }), + run_config.input_paths.end()); + + std::string startup_open_path; + if (run_config.input_paths.empty() + && read_env_value("IMIV_IMGUI_TEST_ENGINE_OPEN_PATH", startup_open_path) + && !startup_open_path.empty()) { + run_config.input_paths.push_back(startup_open_path); + } + MultiViewWorkspace workspace; + ImageLibraryState library; + PlaceholderUiState ui_state; + ViewerState& viewer = ensure_primary_image_view(workspace).viewer; + DeveloperUiState developer_ui; + viewer.rawcolor = run_config.rawcolor; + viewer.no_autopremult = run_config.no_autopremult; + std::string prefs_error; + if (!load_persistent_state(ui_state, viewer, library, prefs_error)) { + print(stderr, "imiv: failed to load preferences: {}\n", prefs_error); + viewer.last_error + = Strutil::fmt::format("failed to load preferences: {}", + prefs_error); + } + run_config.input_paths + = expand_startup_input_paths(run_config, run_config.verbose, + library.sort_mode, library.sort_reverse); + const BackendKind requested_backend + = requested_backend_for_launch(run_config, ui_state); + + const bool verbose_logging = run_config.verbose; + const bool verbose_validation_output + = verbose_logging + || env_flag_is_truthy("IMIV_VULKAN_VERBOSE_VALIDATION"); + const bool log_imgui_texture_updates = env_flag_is_truthy( + "IMIV_DEBUG_IMGUI_TEXTURES"); + developer_ui.enabled = resolve_developer_mode_enabled(run_config, + verbose_logging); + +#if defined(IMGUI_ENABLE_TEST_ENGINE) + TestEngineConfig test_engine_cfg = gather_test_engine_config(); + TestEngineRuntime test_engine_runtime; +#else + bool want_test_engine + = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_JUNIT_XML"); +#endif + + std::string startup_error; + if (!platform_glfw_init(verbose_logging, startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + return EXIT_FAILURE; + } + + refresh_runtime_backend_info(verbose_logging, startup_error); + + const BackendKind active_backend = resolve_backend_request( + requested_backend); + if (active_backend == BackendKind::Auto) { + if (compiled_backend_count() == 0) { + print(stderr, + "imiv: no renderer backend is compiled into this build\n"); + } else { + print(stderr, + "imiv: no compiled renderer backend is currently available\n"); + for (const BackendRuntimeInfo& info : runtime_backend_info()) { + if (!info.build_info.compiled || info.available) + continue; + print(stderr, "imiv: {} unavailable: {}\n", + info.build_info.display_name, + info.unavailable_reason.empty() + ? "backend is unavailable" + : info.unavailable_reason); + } + } + platform_glfw_terminate(); + return EXIT_FAILURE; + } + if (requested_backend != BackendKind::Auto + && requested_backend != active_backend) { + if (!backend_kind_is_compiled(requested_backend)) { + print("imiv: requested backend '{}' is not compiled into this " + "build; using '{}'\n", + backend_cli_name(requested_backend), + backend_runtime_name(active_backend)); + } else { + const std::string_view unavailable_reason + = backend_unavailable_reason(requested_backend); + print("imiv: requested backend '{}' is unavailable at runtime{}; " + "using '{}'\n", + backend_cli_name(requested_backend), + unavailable_reason.empty() + ? "" + : Strutil::fmt::format(" ({})", unavailable_reason), + backend_runtime_name(active_backend)); + } + } + + const std::string window_title + = Strutil::fmt::format("ImIv v.{} [{}]", OIIO_VERSION_STRING, + backend_cli_name(active_backend)); + GLFWwindow* window + = platform_glfw_create_main_window(active_backend, 1600, 900, + window_title.c_str(), startup_error); + if (window == nullptr) { + print(stderr, "imiv: {}\n", startup_error); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + if (active_backend == BackendKind::Vulkan + && !platform_glfw_supports_vulkan(startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + IMGUI_CHECKVERSION(); + if (!ImGui::CreateContext()) { + print(stderr, "imiv: failed to create Dear ImGui context\n"); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + RendererState renderer_state; + renderer_select_backend(renderer_state, active_backend); + renderer_state.verbose_logging = verbose_logging; + renderer_state.verbose_validation_output = verbose_validation_output; + renderer_state.log_imgui_texture_updates = log_imgui_texture_updates; + ImVector instance_extensions; + if (active_backend == BackendKind::Vulkan) + platform_glfw_collect_vulkan_instance_extensions(instance_extensions); + + if (!renderer_setup_instance(renderer_state, instance_extensions, + startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + renderer_cleanup(renderer_state); + ImGui::DestroyContext(); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + if (!renderer_create_surface(renderer_state, window, startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + renderer_cleanup(renderer_state); + ImGui::DestroyContext(); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + if (!renderer_setup_device(renderer_state, startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + renderer_destroy_surface(renderer_state); + renderer_cleanup(renderer_state); + ImGui::DestroyContext(); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + int framebuffer_width = 0; + int framebuffer_height = 0; + platform_glfw_get_framebuffer_size(window, framebuffer_width, + framebuffer_height); + if (!renderer_setup_window(renderer_state, framebuffer_width, + framebuffer_height, startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + renderer_cleanup_window(renderer_state); + renderer_cleanup(renderer_state); + ImGui::DestroyContext(); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; + io.IniFilename = nullptr; + const AppFonts fonts = setup_app_fonts(verbose_logging); + apply_imgui_app_style(AppStylePreset::ImGuiDark); + const std::filesystem::path settings_load_path = imgui_ini_load_path(); + if (!settings_load_path.empty()) + ImGui::LoadIniSettingsFromDisk(settings_load_path.string().c_str()); + if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + ImGuiStyle& style = ImGui::GetStyle(); + style.WindowRounding = 0.0f; + style.Colors[ImGuiCol_WindowBg].w = 1.0f; + } + platform_glfw_imgui_init(window, active_backend); + if (!renderer_imgui_init(renderer_state, startup_error)) { + print(stderr, "imiv: {}\n", startup_error); + platform_glfw_imgui_shutdown(); + renderer_cleanup_window(renderer_state); + renderer_cleanup(renderer_state); + ImGui::DestroyContext(); + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_FAILURE; + } + + const bool platform_has_viewports + = (io.BackendFlags & ImGuiBackendFlags_PlatformHasViewports) != 0; + const bool renderer_has_viewports + = (io.BackendFlags & ImGuiBackendFlags_RendererHasViewports) != 0; + const int selected_glfw_platform = platform_glfw_selected_platform(); + if (verbose_logging) { + print("imiv: GLFW selected platform={} imgui_viewports platform={} " + "renderer={}\n", + platform_glfw_name(selected_glfw_platform), + platform_has_viewports ? "yes" : "no", + renderer_has_viewports ? "yes" : "no"); + } + if ((io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) + && (!platform_has_viewports || !renderer_has_viewports)) { + io.ConfigFlags &= ~ImGuiConfigFlags_ViewportsEnable; + print("imiv: detached auxiliary windows disabled because the active " + "GLFW platform backend does not support Dear ImGui " + "multi-viewports\n"); + } + + if (run_config.verbose) { + print("imiv: bootstrap initialized (backend: {})\n", + backend_runtime_name(active_backend)); + print("imiv: developer mode: {}\n", + developer_ui.enabled ? "enabled" : "disabled"); + for (const BackendRuntimeInfo& info : runtime_backend_info()) { + print("imiv: backend {} ({}) compiled={} available={} " + "build_default={} platform_default={}{}\n", + info.build_info.display_name, info.build_info.cli_name, + info.build_info.compiled ? "yes" : "no", + info.available ? "yes" : "no", + info.build_info.active_build ? "yes" : "no", + info.build_info.platform_default ? "yes" : "no", + (!info.available && !info.unavailable_reason.empty()) + ? Strutil::fmt::format(" reason='{}'", + info.unavailable_reason) + : std::string()); + } + print("imiv: startup queue has {} image path(s)\n", + run_config.input_paths.size()); + print("imiv: native file dialogs: {}\n", + FileDialog::available() ? "enabled" : "disabled"); + } + +#if !defined(IMGUI_ENABLE_TEST_ENGINE) + if (want_test_engine) { + print(stderr, + "imiv: IMIV_IMGUI_TEST_ENGINE requested but support is not " + "compiled in. Configure with " + "-DOIIO_IMIV_ENABLE_IMGUI_TEST_ENGINE=ON.\n"); + } +#endif + + if (run_config.open_dialog) { + FileDialog::DialogReply reply = FileDialog::open_image_files(""); + if (reply.result == FileDialog::Result::Okay) + print("imiv: open dialog selected {} path(s)\n", + reply.paths.empty() ? 0 : reply.paths.size()); + else if (reply.result == FileDialog::Result::Cancel) + print("imiv: open dialog cancelled\n"); + else + print(stderr, "imiv: open dialog failed: {}\n", reply.message); + } + if (run_config.save_dialog) { + FileDialog::DialogReply reply + = FileDialog::save_image_file("", "image.exr"); + if (reply.result == FileDialog::Result::Okay) + print("imiv: save dialog selected '{}'\n", reply.path); + else if (reply.result == FileDialog::Result::Cancel) + print("imiv: save dialog cancelled\n"); + else + print(stderr, "imiv: save dialog failed: {}\n", reply.message); + } + OIIO::attribute("imagebuf:use_imagecache", 1); + if (std::shared_ptr imagecache = ImageCache::create(true)) + imagecache->attribute("unassociatedalpha", + run_config.no_autopremult ? 1 : 0); + reset_view_recipe(viewer.recipe); + if (!run_config.ocio_display.empty()) + viewer.recipe.ocio_display = run_config.ocio_display; + if (!run_config.ocio_view.empty()) + viewer.recipe.ocio_view = run_config.ocio_view; + if (!run_config.ocio_image_color_space.empty()) + viewer.recipe.ocio_image_color_space = run_config.ocio_image_color_space; + if (!run_config.ocio_display.empty() || !run_config.ocio_view.empty() + || !run_config.ocio_image_color_space.empty()) { + viewer.recipe.use_ocio = true; + } + clamp_view_recipe(viewer.recipe); + apply_view_recipe_to_ui_state(viewer.recipe, ui_state); + clamp_placeholder_ui_state(ui_state); + if (viewer.recipe.use_ocio) { + std::string ocio_preflight_error; + bool ocio_preflight_ok = false; + switch (active_backend) { + case BackendKind::Vulkan: + ocio_preflight_ok + = preflight_ocio_runtime_shader(ui_state, nullptr, + ocio_preflight_error); + break; + case BackendKind::Metal: + ocio_preflight_ok + = preflight_ocio_runtime_shader_metal(ui_state, nullptr, + ocio_preflight_error); + break; + case BackendKind::OpenGL: + ocio_preflight_ok + = preflight_ocio_runtime_shader_glsl(ui_state, nullptr, + ocio_preflight_error); + break; + case BackendKind::Auto: + ocio_preflight_error + = "OCIO preview is not implemented on this renderer"; + ocio_preflight_ok = false; + break; + } + if (!ocio_preflight_ok) { + viewer.recipe.use_ocio = false; + apply_view_recipe_to_ui_state(viewer.recipe, ui_state); + if (verbose_logging) { + print("imiv: OCIO preflight unavailable, using standard " + "preview fallback: {}\n", + ocio_preflight_error); + } + } + } + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_AUX_WINDOWS")) { + ui_state.show_info_window = true; + ui_state.show_preferences_window = true; + ui_state.show_preview_window = true; + ui_state.show_pixelview_window = true; + ui_state.show_area_probe_window = true; + } + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_INFO")) + ui_state.show_info_window = true; + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_PREFS")) + ui_state.show_preferences_window = true; + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_PREVIEW")) + ui_state.show_preview_window = true; + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_PIXEL")) + ui_state.show_pixelview_window = true; + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_AREA")) { + ui_state.show_area_probe_window = true; + ui_state.mouse_mode = 3; + } + set_area_sample_enabled(viewer, ui_state, ui_state.show_area_probe_window); + apply_imgui_app_style(sanitize_app_style_preset(ui_state.style_preset)); + apply_glfw_topmost_state_to_platform_windows(window, + ui_state.window_always_on_top); + + NativeDialogWindowScope native_dialog_scope = { window, &ui_state }; + FileDialog::set_native_dialog_scope_hook(native_dialog_scope_callback, + &native_dialog_scope); + + if (!run_config.input_paths.empty()) { + append_loaded_image_paths(library, run_config.input_paths); + sync_viewer_library_state(viewer, library); + if (!load_viewer_image(renderer_state, viewer, library, &ui_state, + run_config.input_paths[0], + ui_state.subimage_index, + ui_state.miplevel_index)) { + print(stderr, "imiv: startup load failed for '{}'\n", + run_config.input_paths[0]); + } + } else { + viewer.status_message = "Open an image to start preview"; + } + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + install_drag_drop(window, viewer); +#endif + +#if defined(IMGUI_ENABLE_TEST_ENGINE) + ViewerStateJsonWriteContext test_engine_state_ctx + = { &viewer, &workspace, &ui_state, active_backend }; + TestEngineHooks test_engine_hooks; + test_engine_hooks.image_window_title = k_image_window_title; + test_engine_hooks.screen_capture = renderer_screen_capture; + test_engine_hooks.screen_capture_user_data = &renderer_state; + test_engine_hooks.write_viewer_state_json + = write_test_engine_viewer_state_json; + test_engine_hooks.write_viewer_state_user_data = &test_engine_state_ctx; + test_engine_start(test_engine_runtime, test_engine_cfg, test_engine_hooks); +#endif + + platform_glfw_show_window(window); + platform_glfw_poll_events(); + force_center_glfw_window(window); + + auto save_combined_settings = [&](std::string& save_error_message) { + size_t imgui_ini_size = 0; + const char* imgui_ini_text = ImGui::SaveIniSettingsToMemory( + &imgui_ini_size); + io.WantSaveIniSettings = false; + return save_persistent_state(ui_state, viewer, library, imgui_ini_text, + imgui_ini_size, save_error_message); + }; + + bool request_exit = false; + int applied_style_preset = ui_state.style_preset; + int startup_center_frames = 90; + while (!platform_glfw_should_close(window)) { + platform_glfw_poll_events(); + if (startup_center_frames > 0) { + center_glfw_window(window); + --startup_center_frames; + } + if (native_dialog_scope.suspend_depth == 0) { + apply_glfw_topmost_state_to_platform_windows( + window, ui_state.window_always_on_top); + } + + int fb_width = 0; + int fb_height = 0; + platform_glfw_get_framebuffer_size(window, fb_width, fb_height); + if (fb_width > 0 && fb_height > 0 + && renderer_needs_main_window_resize(renderer_state, fb_width, + fb_height)) { + renderer_resize_main_window(renderer_state, fb_width, fb_height); + } + if (platform_glfw_is_iconified(window)) { + platform_glfw_sleep(10); + continue; + } + + renderer_imgui_new_frame(renderer_state); + platform_glfw_imgui_new_frame(); + ImGui::NewFrame(); + draw_viewer_ui(workspace, library, ui_state, developer_ui, fonts, + request_exit +#if defined(IMGUI_ENABLE_TEST_ENGINE) + , + test_engine_show_windows_ptr(test_engine_runtime) +#endif + , + window, renderer_state); + if (ui_state.style_preset != applied_style_preset) { + ui_state.style_preset = static_cast( + sanitize_app_style_preset(ui_state.style_preset)); + apply_imgui_app_style( + sanitize_app_style_preset(ui_state.style_preset)); + applied_style_preset = ui_state.style_preset; + } +#if defined(IMGUI_ENABLE_TEST_ENGINE) + test_engine_maybe_show_windows(test_engine_runtime, test_engine_cfg); +#endif + + ImGui::Render(); + ImDrawData* draw_data = ImGui::GetDrawData(); + const bool main_is_minimized = (draw_data->DisplaySize.x <= 0.0f + || draw_data->DisplaySize.y <= 0.0f); + renderer_set_main_clear_color(renderer_state, 0.08f, 0.08f, 0.08f, + 1.0f); + if (!main_is_minimized) + renderer_frame_render(renderer_state, draw_data); + if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + renderer_prepare_platform_windows(renderer_state); + ImGui::UpdatePlatformWindows(); + if (native_dialog_scope.suspend_depth == 0) { + apply_glfw_topmost_state_to_platform_windows( + window, ui_state.window_always_on_top); + } + ImGui::RenderPlatformWindowsDefault(); + renderer_finish_platform_windows(renderer_state); + } + ImageViewWindow* active_view_window = active_image_view(workspace); + process_developer_post_render_actions(developer_ui, + active_view_window != nullptr + ? active_view_window->viewer + : viewer, + renderer_state); +#if defined(IMGUI_ENABLE_TEST_ENGINE) + test_engine_post_swap(test_engine_runtime); +#endif + if (!main_is_minimized) + renderer_frame_present(renderer_state); + +#if defined(IMGUI_ENABLE_TEST_ENGINE) + if (test_engine_should_close(test_engine_runtime, test_engine_cfg)) + platform_glfw_request_close(window); +#endif + if (io.WantSaveIniSettings) { + std::string save_error_message; + if (!save_combined_settings(save_error_message)) { + print(stderr, "imiv: failed to save preferences: {}\n", + save_error_message); + } + } + if (request_exit) + platform_glfw_request_close(window); + } + + std::string prefs_save_error; + if (!save_combined_settings(prefs_save_error)) + print(stderr, "imiv: failed to save preferences: {}\n", + prefs_save_error); + + if (!renderer_wait_idle(renderer_state, prefs_save_error) + && !prefs_save_error.empty()) + print(stderr, "imiv: failed to wait for renderer idle: {}\n", + prefs_save_error); + + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view == nullptr) + continue; + renderer_destroy_texture(renderer_state, view->viewer.texture); + } + if (!renderer_wait_idle(renderer_state, prefs_save_error) + && !prefs_save_error.empty()) + print(stderr, "imiv: failed to finalize renderer idle: {}\n", + prefs_save_error); +#if defined(IMGUI_ENABLE_TEST_ENGINE) + test_engine_stop(test_engine_runtime); +#endif + FileDialog::set_native_dialog_scope_hook(nullptr, nullptr); + uninstall_drag_drop(window); + if (active_backend == BackendKind::OpenGL) { + renderer_cleanup_window(renderer_state); + renderer_cleanup(renderer_state); + } + renderer_imgui_shutdown(renderer_state); + platform_glfw_imgui_shutdown(); + ImGui::DestroyContext(); +#if defined(IMGUI_ENABLE_TEST_ENGINE) + test_engine_destroy(test_engine_runtime); +#endif + + if (active_backend != BackendKind::OpenGL) { + renderer_cleanup_window(renderer_state); + renderer_cleanup(renderer_state); + } + platform_glfw_destroy_window(window); + platform_glfw_terminate(); + return EXIT_SUCCESS; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_app.h b/src/imiv/imiv_app.h new file mode 100644 index 0000000000..0997e93e54 --- /dev/null +++ b/src/imiv/imiv_app.h @@ -0,0 +1,37 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_backend.h" + +#include +#include + +namespace Imiv { + +struct AppConfig { + bool verbose = false; + bool foreground_mode = false; + bool rawcolor = false; + bool no_autopremult = false; + bool open_dialog = false; + bool save_dialog = false; + bool list_backends = false; + bool developer_mode = false; + bool developer_mode_explicit = false; + + BackendKind requested_backend = BackendKind::Auto; + + std::string ocio_display; + std::string ocio_image_color_space; + std::string ocio_view; + + std::vector input_paths; +}; + +int +run(const AppConfig& config); + +} // namespace Imiv diff --git a/src/imiv/imiv_aux_windows.cpp b/src/imiv/imiv_aux_windows.cpp new file mode 100644 index 0000000000..fdd1f6321c --- /dev/null +++ b/src/imiv/imiv_aux_windows.cpp @@ -0,0 +1,779 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_ui.h" + +#include "imiv_backend.h" +#include "imiv_file_dialog.h" +#include "imiv_ocio.h" +#include "imiv_test_engine.h" +#include "imiv_ui_metrics.h" + +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + bool draw_preview_row_button_cell(const char* label, bool active) + { + ImGui::TableNextColumn(); + push_active_button_style(active); + const bool pressed + = ImGui::Button(label, + ImVec2(ImGui::GetContentRegionAvail().x, 0.0f)); + pop_active_button_style(active); + return pressed; + } + + void preview_set_rgb_mode(PlaceholderUiState& ui) + { + ui.color_mode = 1; + ui.current_channel = 0; + } + + void preview_set_luma_mode(PlaceholderUiState& ui) + { + ui.color_mode = 3; + ui.current_channel = 0; + } + + void preview_set_single_channel_mode(PlaceholderUiState& ui, int channel) + { + ui.color_mode = 2; + ui.current_channel = channel; + } + + void preview_set_heat_mode(PlaceholderUiState& ui) + { + ui.color_mode = 4; + if (ui.current_channel <= 0) + ui.current_channel = 1; + } + + void preview_reset_adjustments(PlaceholderUiState& ui) + { + ui.exposure = 0.0f; + ui.gamma = 1.0f; + ui.offset = 0.0f; + } + +} // namespace + +void +draw_info_window(const ViewerState& viewer, bool& show_window, + bool reset_layout) +{ + if (!show_window) + return; + set_aux_window_defaults(UiMetrics::AuxiliaryWindows::kInfoOffset, + UiMetrics::AuxiliaryWindows::kInfoSize, + reset_layout); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, + UiMetrics::kAuxWindowPadding); + if (ImGui::Begin(k_info_window_title, &show_window)) { + const float close_height = ImGui::GetFrameHeightWithSpacing(); + const float body_height + = std::max(UiMetrics::AuxiliaryWindows::kInfoBodyMinHeight, + ImGui::GetContentRegionAvail().y - close_height + - UiMetrics::AuxiliaryWindows::kBodyBottomGap); + ImGui::BeginChild("##iv_info_scroll", ImVec2(0.0f, body_height), true, + ImGuiWindowFlags_HorizontalScrollbar); + if (viewer.image.path.empty()) { + draw_padded_message( + "No image loaded.", + UiMetrics::AuxiliaryWindows::kEmptyMessagePadding.x, + UiMetrics::AuxiliaryWindows::kEmptyMessagePadding.y); + register_layout_dump_synthetic_item("text", "No image loaded."); + } else { + if (begin_two_column_table( + "##iv_info_table", + UiMetrics::AuxiliaryWindows::kInfoTableLabelWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg, + "Field", "Value")) { + draw_wrapped_value_row("Path", viewer.image.path.c_str()); + for (const std::pair& row : + viewer.image.longinfo_rows) { + draw_wrapped_value_row(row.first.c_str(), + row.second.c_str()); + } + draw_wrapped_value_row( + "Orientation", + Strutil::fmt::format("{}", viewer.image.orientation) + .c_str()); + draw_wrapped_value_row( + "Subimage", + Strutil::fmt::format("{}/{}", viewer.image.subimage + 1, + viewer.image.nsubimages) + .c_str()); + draw_wrapped_value_row( + "MIP level", + Strutil::fmt::format("{}/{}", viewer.image.miplevel + 1, + viewer.image.nmiplevels) + .c_str()); + draw_wrapped_value_row( + "Row pitch (bytes)", + Strutil::fmt::format("{}", viewer.image.row_pitch_bytes) + .c_str()); + ImGui::EndTable(); + } + register_layout_dump_synthetic_item("text", "iv Info content"); + } + ImGui::EndChild(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + + UiMetrics::AuxiliaryWindows::kInfoCloseGap); + if (ImGui::Button("Close")) + show_window = false; + } + ImGui::End(); + ImGui::PopStyleVar(); +} + +void +draw_preview_window(PlaceholderUiState& ui, bool& show_window, + bool reset_layout) +{ + if (!show_window) + return; + set_aux_window_defaults(UiMetrics::AuxiliaryWindows::kPreviewOffset, + UiMetrics::AuxiliaryWindows::kPreviewSize, + reset_layout); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, + UiMetrics::kAuxWindowPadding); + if (ImGui::Begin(k_preview_window_title, &show_window)) { + const float close_height = ImGui::GetFrameHeightWithSpacing(); + const float body_height + = std::max(UiMetrics::AuxiliaryWindows::kPreviewBodyMinHeight, + ImGui::GetContentRegionAvail().y - close_height + - UiMetrics::AuxiliaryWindows::kBodyBottomGap); + ImGui::BeginChild("##iv_preview_body", ImVec2(0.0f, body_height), false, + ImGuiWindowFlags_NoScrollbar); + + if (begin_two_column_table("##iv_preview_form", + UiMetrics::Preview::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + table_labeled_row("Interpolation"); + ImGui::Checkbox("Linear##preview_interp", &ui.linear_interpolation); + + table_labeled_row("Exposure"); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::SliderFloat("##preview_exposure", &ui.exposure, -10.0f, + 10.0f, "%.2f"); + + table_labeled_row(""); + if (ImGui::BeginTable("##preview_exposure_steps", 4, + ImGuiTableFlags_SizingStretchSame + | ImGuiTableFlags_NoSavedSettings)) { + if (draw_preview_row_button_cell("-1/2", false)) + ui.exposure -= 0.5f; + if (draw_preview_row_button_cell("-1/10", false)) + ui.exposure -= 0.1f; + if (draw_preview_row_button_cell("+1/10", false)) + ui.exposure += 0.1f; + if (draw_preview_row_button_cell("+1/2", false)) + ui.exposure += 0.5f; + ImGui::EndTable(); + } + + table_labeled_row("Gamma"); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::SliderFloat("##preview_gamma", &ui.gamma, 0.1f, 4.0f, + "%.2f"); + + table_labeled_row(""); + if (ImGui::BeginTable("##preview_gamma_steps", 2, + ImGuiTableFlags_SizingStretchSame + | ImGuiTableFlags_NoSavedSettings)) { + if (draw_preview_row_button_cell("-0.1", false)) + ui.gamma = std::max(0.1f, ui.gamma - 0.1f); + if (draw_preview_row_button_cell("+0.1", false)) + ui.gamma += 0.1f; + ImGui::EndTable(); + } + + table_labeled_row("Offset"); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::SliderFloat("##preview_offset", &ui.offset, -1.0f, 1.0f, + "%+.3f"); + + table_labeled_row(""); + if (ImGui::Button("Reset", + ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) { + preview_reset_adjustments(ui); + } + + table_labeled_row(""); + if (ImGui::BeginTable("##preview_modes", 3, + ImGuiTableFlags_SizingStretchSame + | ImGuiTableFlags_NoSavedSettings)) { + const bool rgb_active = ui.current_channel == 0 + && (ui.color_mode == 0 + || ui.color_mode == 1); + if (draw_preview_row_button_cell("RGB", rgb_active)) + preview_set_rgb_mode(ui); + if (draw_preview_row_button_cell("Luma", + ui.color_mode == 3 + && ui.current_channel + == 0)) { + preview_set_luma_mode(ui); + } + if (draw_preview_row_button_cell("Heat", ui.color_mode == 4)) + preview_set_heat_mode(ui); + ImGui::EndTable(); + } + + if (ImGui::BeginTable("##rgb_modes", 4, + ImGuiTableFlags_SizingStretchSame + | ImGuiTableFlags_NoSavedSettings)) { + const bool red_active = ui.current_channel == 1 + && ui.color_mode != 3 + && ui.color_mode != 4; + const bool green_active = ui.current_channel == 2 + && ui.color_mode != 3 + && ui.color_mode != 4; + const bool blue_active = ui.current_channel == 3 + && ui.color_mode != 3 + && ui.color_mode != 4; + const bool alpha_active = ui.current_channel == 4 + && ui.color_mode != 3 + && ui.color_mode != 4; + if (draw_preview_row_button_cell("R", red_active)) + preview_set_single_channel_mode(ui, 1); + if (draw_preview_row_button_cell("G", green_active)) + preview_set_single_channel_mode(ui, 2); + if (draw_preview_row_button_cell("B", blue_active)) + preview_set_single_channel_mode(ui, 3); + if (draw_preview_row_button_cell("A", alpha_active)) + preview_set_single_channel_mode(ui, 4); + ImGui::EndTable(); + } + + ImGui::EndTable(); + } + + ImGui::EndChild(); + clamp_placeholder_ui_state(ui); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 3.0f); + if (ImGui::Button("Close")) + show_window = false; + register_layout_dump_synthetic_item("text", "iv Preview content"); + } + ImGui::End(); + ImGui::PopStyleVar(); +} + +namespace { + + std::string ocio_config_dialog_default_path(const PlaceholderUiState& ui) + { + if (!ui.ocio_user_config_path.empty()) { + const std::filesystem::path user_path(ui.ocio_user_config_path); + if (user_path.has_parent_path()) + return user_path.parent_path().string(); + } + + OcioConfigSelection selection; + resolve_ocio_config_selection(ui, selection); + if ((selection.resolved_source == OcioConfigSource::User + || selection.resolved_source == OcioConfigSource::Global) + && !selection.resolved_path.empty()) { + if (Strutil::istarts_with(selection.resolved_path, "ocio://")) + return std::string(); + const std::filesystem::path resolved_path(selection.resolved_path); + if (resolved_path.has_parent_path()) + return resolved_path.parent_path().string(); + } + return std::string(); + } + + bool draw_preferences_segment_button(const char* id, const char* label, + bool selected, bool enabled, + float width) + { + ImGui::PushID(id); + if (!enabled) + ImGui::BeginDisabled(); + push_active_button_style(selected); + const bool pressed = ImGui::Button(label, ImVec2(width, 0.0f)); + pop_active_button_style(selected); + if (!enabled) + ImGui::EndDisabled(); + ImGui::PopID(); + return pressed && enabled; + } + + void draw_theme_preferences_section(PlaceholderUiState& ui) + { + draw_section_heading( + "Theme", UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + const AppStylePreset current_style_preset = sanitize_app_style_preset( + ui.style_preset); + if (ImGui::BeginCombo("##pref_ui_style", + app_style_preset_name(current_style_preset))) { + for (int preset_value = static_cast(AppStylePreset::IvLight); + preset_value <= static_cast(AppStylePreset::ImGuiClassic); + ++preset_value) { + const AppStylePreset preset = static_cast( + preset_value); + const bool selected = preset == current_style_preset; + if (ImGui::Selectable(app_style_preset_name(preset), selected)) + ui.style_preset = preset_value; + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + + void draw_pixel_view_preferences_section(PlaceholderUiState& ui) + { + draw_section_heading( + "Pixel View", + UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + if (begin_two_column_table("##pref_pixel_view", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + table_labeled_row("Follows mouse"); + draw_right_aligned_checkbox("##pref_pixelview_follows_mouse", + ui.pixelview_follows_mouse); + register_layout_dump_synthetic_item("text", + "Pixel view follows mouse"); + + table_labeled_row("Closeup pixels"); + draw_right_aligned_int_stepper( + "pref_closeup_pixels", ui.closeup_pixels, 2, nullptr, + UiMetrics::Preferences::kStepperButtonWidth, + UiMetrics::Preferences::kStepperValueWidth); + + table_labeled_row("Closeup average"); + draw_right_aligned_int_stepper( + "pref_closeup_avg_pixels", ui.closeup_avg_pixels, 2, nullptr, + UiMetrics::Preferences::kStepperButtonWidth, + UiMetrics::Preferences::kStepperValueWidth); + ImGui::EndTable(); + } + } + + void draw_image_rendering_preferences_section(PlaceholderUiState& ui) + { + draw_section_heading( + "Image Rendering", + UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + if (begin_two_column_table("##pref_image_rendering", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + table_labeled_row("Linear interpolation"); + draw_right_aligned_checkbox("##pref_linear_interpolation", + ui.linear_interpolation); + ImGui::EndTable(); + } + } + + void draw_ocio_config_source_selector(PlaceholderUiState& ui, float spacing) + { + const float row_width = ImGui::GetContentRegionAvail().x; + const float button_width = std::max(1.0f, (row_width - spacing * 2.0f) + / 3.0f); + const int ocio_source = ui.ocio_config_source; + if (draw_preferences_segment_button( + "ocio_cfg_global", "Global", + ocio_source == static_cast(OcioConfigSource::Global), true, + button_width)) { + ui.ocio_config_source = static_cast(OcioConfigSource::Global); + } + ImGui::SameLine(0.0f, spacing); + if (draw_preferences_segment_button( + "ocio_cfg_builtin", "Built-in", + ocio_source == static_cast(OcioConfigSource::BuiltIn), + true, button_width)) { + ui.ocio_config_source = static_cast(OcioConfigSource::BuiltIn); + } + ImGui::SameLine(0.0f, spacing); + if (draw_preferences_segment_button( + "ocio_cfg_user", "User", + ocio_source == static_cast(OcioConfigSource::User), true, + button_width)) { + ui.ocio_config_source = static_cast(OcioConfigSource::User); + } + } + + void draw_ocio_user_config_selector(PlaceholderUiState& ui, float spacing) + { + if (static_cast(ui.ocio_config_source) + != OcioConfigSource::User) { + return; + } + + if (!begin_two_column_table("##pref_ocio_user", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + return; + } + + table_labeled_row("Path"); + const float browse_width + = UiMetrics::Preferences::kOcioBrowseButtonWidth; + const float field_width = std::max(60.0f, + ImGui::GetContentRegionAvail().x + - browse_width - spacing); + ImGui::SetNextItemWidth(field_width); + input_text_string("##pref_ocio_user_config_path", + ui.ocio_user_config_path); + ImGui::SameLine(0.0f, spacing); + if (ImGui::Button("Browse##pref_ocio_user_config", + ImVec2(browse_width, 0.0f))) { + const FileDialog::DialogReply reply + = FileDialog::open_ocio_config_file( + ocio_config_dialog_default_path(ui)); + if (reply.result == FileDialog::Result::Okay + && !reply.path.empty()) { + ui.ocio_user_config_path = reply.path; + ui.ocio_config_source = static_cast( + OcioConfigSource::User); + } + } + ImGui::EndTable(); + } + + void draw_ocio_config_info(const OcioConfigSelection& ocio_selection) + { + if (!begin_two_column_table("##pref_ocio_info", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + return; + } + + table_labeled_row("Resolved source"); + draw_right_aligned_text( + ocio_config_source_name(ocio_selection.resolved_source)); + + if (!ocio_selection.resolved_path.empty()) { + table_labeled_row("Resolved path"); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(ocio_selection.resolved_path.c_str()); + ImGui::PopTextWrapPos(); + } + if (ocio_selection.resolved_source == OcioConfigSource::Global + && !ocio_selection.env_value.empty()) { + table_labeled_row("OCIO env"); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(ocio_selection.env_value.c_str()); + ImGui::PopTextWrapPos(); + } + ImGui::EndTable(); + } + + void draw_ocio_config_status(const OcioConfigSelection& ocio_selection) + { + if (ocio_selection.requested_source == OcioConfigSource::Global + && ocio_selection.resolved_source == OcioConfigSource::BuiltIn) { + draw_disabled_wrapped_text( + "$OCIO is missing or invalid. Built-in config will be used."); + return; + } + if (ocio_selection.requested_source == OcioConfigSource::User + && ocio_selection.fallback_applied) { + draw_disabled_wrapped_text( + "User config is missing or invalid. A fallback config will be used."); + } + } + + void draw_ocio_preferences_section(PlaceholderUiState& ui, float spacing) + { + draw_section_heading( + "OCIO Config", + UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + draw_ocio_config_source_selector(ui, spacing); + draw_ocio_user_config_selector(ui, spacing); + + OcioConfigSelection ocio_selection; + resolve_ocio_config_selection(ui, ocio_selection); + draw_ocio_config_info(ocio_selection); + draw_ocio_config_status(ocio_selection); + } + + void draw_backend_selector_row(PlaceholderUiState& ui, float spacing, + ImVec2& backend_row_min, + ImVec2& backend_row_max, + bool& have_backend_row_rect) + { + BackendKind requested_backend = sanitize_backend_kind( + ui.renderer_backend); + const float row_width = std::max(1.0f, + ImGui::GetContentRegionAvail().x); + const float button_width = std::max(1.0f, (row_width - spacing * 3.0f) + / 4.0f); + + const bool auto_selected = requested_backend == BackendKind::Auto; + if (draw_preferences_segment_button("pref_backend_auto", "Auto", + auto_selected, true, + button_width)) { + ui.renderer_backend = static_cast(BackendKind::Auto); + } + register_test_engine_item_label("pref-backend:auto"); + register_layout_dump_synthetic_item("button", "Renderer backend Auto"); + + backend_row_min = ImGui::GetItemRectMin(); + backend_row_max = ImGui::GetItemRectMax(); + have_backend_row_rect = true; + + for (const BackendRuntimeInfo& info : runtime_backend_info()) { + ImGui::SameLine(0.0f, spacing); + requested_backend = sanitize_backend_kind(ui.renderer_backend); + const bool selected = requested_backend == info.build_info.kind; + const bool enabled = info.build_info.compiled && info.available; + if (draw_preferences_segment_button( + backend_cli_name(info.build_info.kind), + backend_display_name(info.build_info.kind), selected, + enabled, button_width)) { + ui.renderer_backend = static_cast(info.build_info.kind); + } + const std::string test_label = std::string("pref-backend:") + + backend_cli_name( + info.build_info.kind); + register_test_engine_item_label(test_label.c_str()); + register_layout_dump_synthetic_item( + "button", + Strutil::fmt::format("Renderer backend {}", + backend_display_name(info.build_info.kind)) + .c_str()); + const ImVec2 item_min = ImGui::GetItemRectMin(); + const ImVec2 item_max = ImGui::GetItemRectMax(); + backend_row_min.x = std::min(backend_row_min.x, item_min.x); + backend_row_min.y = std::min(backend_row_min.y, item_min.y); + backend_row_max.x = std::max(backend_row_max.x, item_max.x); + backend_row_max.y = std::max(backend_row_max.y, item_max.y); + } + } + + void draw_backend_preference_info(PlaceholderUiState& ui, + BackendKind active_backend) + { + const BackendKind requested_backend = sanitize_backend_kind( + ui.renderer_backend); + const BackendKind next_launch_backend = resolve_backend_request( + requested_backend); + if (!begin_two_column_table("##pref_system_info", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + return; + } + + const char* stored_preference = (requested_backend == BackendKind::Auto) + ? "Auto" + : backend_display_name( + requested_backend); + table_labeled_row("Stored preference"); + draw_right_aligned_text(stored_preference); + + table_labeled_row("Current backend"); + draw_right_aligned_text(backend_display_name(active_backend)); + + table_labeled_row("Next launch backend"); + draw_right_aligned_text(backend_display_name(next_launch_backend)); + + table_labeled_row("Generate mipmaps"); + draw_right_aligned_checkbox("##pref_auto_mipmap", ui.auto_mipmap); + ImGui::EndTable(); + } + + void draw_backend_status_messages(const PlaceholderUiState& ui, + BackendKind active_backend) + { + const BackendKind requested_backend = sanitize_backend_kind( + ui.renderer_backend); + const BackendKind next_launch_backend = resolve_backend_request( + requested_backend); + const bool requested_backend_compiled + = requested_backend == BackendKind::Auto + || backend_kind_is_compiled(requested_backend); + const bool requested_backend_available + = requested_backend == BackendKind::Auto + || backend_kind_is_available(requested_backend); + const bool invalid_requested_backend = requested_backend + != BackendKind::Auto + && !requested_backend_compiled; + const bool unavailable_requested_backend + = requested_backend != BackendKind::Auto + && requested_backend_compiled && !requested_backend_available; + + if (invalid_requested_backend) { + draw_disabled_wrapped_text( + "Requested backend is not built in this binary and will be ignored when Preferences closes."); + } else if (unavailable_requested_backend) { + const std::string_view unavailable_reason + = backend_unavailable_reason(requested_backend); + if (unavailable_reason.empty()) { + draw_disabled_wrapped_text( + "Requested backend is unavailable at runtime and will be ignored when Preferences closes."); + } else { + draw_disabled_wrapped_text( + Strutil::fmt::format( + "Requested backend is unavailable at runtime ({}) and will be ignored when Preferences closes.", + unavailable_reason) + .c_str()); + } + } else if (next_launch_backend != active_backend) { + draw_disabled_wrapped_text("Backend change requires restart."); + } + if (requested_backend == BackendKind::Auto) + draw_disabled_wrapped_text( + "Auto selects the first available backend."); + for (const BackendRuntimeInfo& info : runtime_backend_info()) { + if (!info.build_info.compiled || info.available) + continue; + if (info.unavailable_reason.empty()) { + draw_disabled_wrapped_text( + Strutil::fmt::format("{} unavailable", + info.build_info.display_name) + .c_str()); + } else { + draw_disabled_wrapped_text( + Strutil::fmt::format("{} unavailable: {}", + info.build_info.display_name, + info.unavailable_reason) + .c_str()); + } + } + } + + void draw_backend_preferences_section(PlaceholderUiState& ui, + BackendKind active_backend, + float spacing) + { + draw_section_heading( + "System (required restart)", + UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + ImVec2 backend_row_min(0.0f, 0.0f); + ImVec2 backend_row_max(0.0f, 0.0f); + bool have_backend_row_rect = false; + draw_backend_selector_row(ui, spacing, backend_row_min, backend_row_max, + have_backend_row_rect); + if (have_backend_row_rect) { + register_layout_dump_synthetic_rect("button", "Renderer backend", + backend_row_min, + backend_row_max); + } + draw_backend_preference_info(ui, active_backend); + draw_backend_status_messages(ui, active_backend); + } + + void draw_memory_preferences_section(PlaceholderUiState& ui) + { + draw_section_heading( + "Memory", UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + if (begin_two_column_table("##pref_memory", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + table_labeled_row("Image cache max memory"); + draw_right_aligned_int_stepper( + "pref_max_mem", ui.max_memory_ic_mb, 64, "MB", + UiMetrics::Preferences::kStepperButtonWidth, + UiMetrics::Preferences::kStepperValueWidth); + ImGui::EndTable(); + } + } + + void draw_slideshow_preferences_section(PlaceholderUiState& ui) + { + draw_section_heading( + "Slide Show", + UiMetrics::Preferences::kSectionSeparatorTextPaddingY); + if (begin_two_column_table("##pref_slideshow", + UiMetrics::Preferences::kLabelColumnWidth, + ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings)) { + table_labeled_row("Delay"); + draw_right_aligned_int_stepper( + "pref_slide_delay", ui.slide_duration_seconds, 1, "s", + UiMetrics::Preferences::kStepperButtonWidth, + UiMetrics::Preferences::kStepperValueWidth); + ImGui::EndTable(); + } + } + + void draw_preferences_close_button(bool& show_window) + { + ImGui::SetCursorPosY( + ImGui::GetCursorPosY() + + UiMetrics::AuxiliaryWindows::kPreferencesCloseGap); + const float close_button_width + = UiMetrics::Preferences::kCloseButtonWidth; + const float x = ImGui::GetCursorPosX(); + const float available_width = ImGui::GetContentRegionAvail().x; + if (available_width > close_button_width) { + ImGui::SetCursorPosX( + x + (available_width - close_button_width) * 0.5f); + } + if (ImGui::Button("Close", ImVec2(close_button_width, 0.0f))) + show_window = false; + } + +} // namespace + +void +draw_preferences_window(PlaceholderUiState& ui, bool& show_window, + BackendKind active_backend, bool reset_layout) +{ + if (!show_window) + return; + + set_aux_window_defaults(UiMetrics::AuxiliaryWindows::kPreferencesOffset, + UiMetrics::AuxiliaryWindows::kPreferencesSize, + reset_layout); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, + UiMetrics::kAuxWindowPadding); + if (ImGui::Begin(k_preferences_window_title, &show_window)) { + const float close_height = ImGui::GetFrameHeightWithSpacing(); + const float body_height + = std::max(UiMetrics::AuxiliaryWindows::kPreferencesBodyMinHeight, + ImGui::GetContentRegionAvail().y - close_height + - UiMetrics::AuxiliaryWindows::kBodyBottomGap); + ImGui::BeginChild("##iv_prefs_body", ImVec2(0.0f, body_height), false, + ImGuiWindowFlags_None); + + const float spacing = ImGui::GetStyle().ItemSpacing.x; + draw_theme_preferences_section(ui); + draw_pixel_view_preferences_section(ui); + draw_image_rendering_preferences_section(ui); + draw_ocio_preferences_section(ui, spacing); + draw_backend_preferences_section(ui, active_backend, spacing); + draw_memory_preferences_section(ui); + draw_slideshow_preferences_section(ui); + + ImGui::EndChild(); + clamp_placeholder_ui_state(ui); + draw_preferences_close_button(show_window); + register_layout_dump_synthetic_item("text", "iv Preferences content"); + } + ImGui::End(); + ImGui::PopStyleVar(); + + const BackendKind close_backend = sanitize_backend_kind( + ui.renderer_backend); + if (!show_window && close_backend != BackendKind::Auto + && (!backend_kind_is_compiled(close_backend) + || !backend_kind_is_available(close_backend))) { + ui.renderer_backend = static_cast(BackendKind::Auto); + } +} + +} // namespace Imiv diff --git a/src/imiv/imiv_backend.h b/src/imiv/imiv_backend.h new file mode 100644 index 0000000000..afdd767513 --- /dev/null +++ b/src/imiv/imiv_backend.h @@ -0,0 +1,66 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include +#include + +namespace Imiv { + +enum class BackendKind : int { Auto = -1, Vulkan = 0, Metal = 1, OpenGL = 2 }; + +struct BackendInfo { + BackendKind kind = BackendKind::Auto; + const char* cli_name = "auto"; + const char* display_name = "Auto"; + bool compiled = false; + bool active_build = false; + bool platform_default = false; +}; + +struct BackendRuntimeInfo { + BackendInfo build_info; + bool available = false; + bool probed = false; + std::string unavailable_reason; +}; + +BackendKind +sanitize_backend_kind(int value); +bool +parse_backend_kind(std::string_view value, BackendKind& out_kind); +const char* +backend_cli_name(BackendKind kind); +const char* +backend_display_name(BackendKind kind); +const char* +backend_runtime_name(BackendKind kind); +BackendKind +active_build_backend_kind(); +BackendKind +platform_default_backend_kind(); +BackendKind +resolve_backend_request(BackendKind requested_kind); +bool +refresh_runtime_backend_info(bool verbose_logging, std::string& error_message); +void +clear_runtime_backend_info(); +bool +runtime_backend_info_valid(); +bool +backend_kind_is_available(BackendKind kind); +std::string_view +backend_unavailable_reason(BackendKind kind); +bool +backend_kind_is_compiled(BackendKind kind); +const std::array& +compiled_backend_info(); +const std::array& +runtime_backend_info(); +size_t +compiled_backend_count(); + +} // namespace Imiv diff --git a/src/imiv/imiv_backends.md b/src/imiv/imiv_backends.md new file mode 100644 index 0000000000..f5c00b394b --- /dev/null +++ b/src/imiv/imiv_backends.md @@ -0,0 +1,285 @@ +# imiv Backend Coverage + +## Goal + +Keep `imiv` app/viewer/UI code backend-agnostic, and keep renderer-specific +constraints explicit in one place. + +This document is the working contract for: + +- backend feature coverage +- backend-specific constraints +- backend-specific technical debt +- what is allowed in shared code vs renderer code + +## Backend Model + +Current backend selection is: + +- platform: `GLFW` +- renderer: `Vulkan`, `Metal`, or `OpenGL` + +Compiled by CMake with: + +```text +-D OIIO_IMIV_ENABLE_VULKAN=AUTO|ON|OFF +-D OIIO_IMIV_ENABLE_METAL=AUTO|ON|OFF +-D OIIO_IMIV_ENABLE_OPENGL=AUTO|ON|OFF +-D OIIO_IMIV_DEFAULT_RENDERER=auto|vulkan|metal|opengl +``` + +Current default selection: + +- non-Apple: `vulkan` +- Apple: `metal` + +## Support Levels + +- `Primary`: expected to carry the full `imiv` feature set +- `Supported`: expected to work in the current shared verifier and participate + in normal backend validation +- `Skeleton`: bootstrap only; not yet a real viewer backend + +## Shared-Code Rules + +These rules are intentional and should stay true as backend work continues. + +1. Shared app/viewer/UI code must not expose backend-native types. + Examples: no `Vk*`, `MTL*`, or raw GL object types in shared public + interfaces. +2. Backend-specific work belongs behind the renderer seam. + Current seam entry points live in: + - [imiv_renderer.h](/mnt/f/gh/openimageio/src/imiv/imiv_renderer.h) + - [imiv_renderer_backend.h](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_backend.h) +3. Platform-specific window/bootstrap work belongs in: + - [imiv_platform_glfw.h](/mnt/f/gh/openimageio/src/imiv/imiv_platform_glfw.h) + - [imiv_platform_glfw.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_platform_glfw.cpp) +4. Backend-specific code should live in backend-specific translation units: + - Vulkan: `imiv_renderer_vulkan.cpp` + `imiv_vulkan_*` + - Metal: `imiv_renderer_metal.mm` + - OpenGL: `imiv_renderer_opengl.cpp` + +## Coverage Matrix + +Legend: + +- `Yes`: implemented and expected to work +- `No`: not implemented + +| Feature | Vulkan | OpenGL | Metal | +|---|---|---|---| +| App bootstrap and main window | Yes | Yes | Yes | +| Dear ImGui backend | Yes | Yes | Yes | +| Direct image upload | Yes | Yes | Yes | +| Preview rendering | Yes | Yes | Yes | +| Exposure / gamma / offset | Yes | Yes | Yes | +| Channel / luma / heatmap modes | Yes | Yes | Yes | +| Orientation-aware preview | Yes | Yes | Yes | +| Linear / nearest preview sampling | Yes | Yes | Yes | +| Pixel closeup window | Yes | Yes | Yes | +| Area Sample / selection UI | Yes | Yes | Yes | +| Drag and drop | Yes | Yes | Yes | +| Screenshot / readback | Yes | Yes | Yes | +| OCIO display/view | Yes | Yes | Yes | +| Runtime OCIO config switching | Yes | Yes | Yes | +| Automated GUI regression coverage | Yes | Yes | Yes | + +Current note: + +- the shared macOS backend verifier is green on Vulkan, OpenGL, and Metal + +## Vulkan + +Status: + +- `Primary` + +Implementation: + +- renderer seam: + - [imiv_renderer_vulkan.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_vulkan.cpp) +- Vulkan-specific modules: + - [imiv_vulkan_setup.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_setup.cpp) + - [imiv_vulkan_runtime.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_runtime.cpp) + - [imiv_vulkan_texture.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_texture.cpp) + - [imiv_vulkan_preview.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_preview.cpp) + - [imiv_vulkan_ocio.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_ocio.cpp) + - [imiv_capture.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_capture.cpp) + +Constraints: + +- uses runtime shader compilation for OCIO +- uses Vulkan SPIR-V pipelines embedded into the binary at build time for the + static upload/preview stages +- keeps external `.spv` loading only as a fallback path for unusual build + layouts +- remains the canonical backend for feature parity and regressions + +Notes: + +- New viewer features should land on Vulkan first if they need renderer work. +- Other backends should match Vulkan behavior, not invent incompatible UI + semantics. + +## OpenGL + +Status: + +- `Supported` + +Implementation: + +- [imiv_renderer_opengl.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_opengl.cpp) + +Hard constraints: + +1. OpenGL must stay a non-compute backend. +2. OpenGL must not use SPIR-V. +3. OpenGL preview shaders must be native GLSL compiled with the GL driver. +4. Direct source upload is preferred over a Vulkan-style upload/compute stage. +5. OCIO must use OCIO GLSL output, not the Vulkan runtime shader + path. + +Notes: + +- OpenGL does not use embedded binary shader blobs. +- Static preview and OCIO shaders remain native GLSL source compiled at + runtime by the GL driver. + +Target API level: + +- macOS: `OpenGL 3.2 Core` +- non-Apple: keep the feature set within the same envelope where practical + +Current design: + +- source image is uploaded directly as a GL texture +- preview is rendered through a GLSL fragment shader into a preview texture +- OCIO preview uses a separate GLSL program built from OCIO GPU shader output +- no compute stage +- no SPIR-V stage + +Current coverage: + +- direct source upload +- basic preview controls +- orientation-aware preview +- screenshot/readback +- OCIO config selection and builtin/global/user fallback +- OCIO GLSL preview path +- OpenGL-only screenshot smoke regression +- OpenGL live OCIO update regressions +- OpenGL selection regression target + +Current note: + +- the shared backend verifier is green on macOS OpenGL + +OCIO notes: + +- startup preflight for OpenGL now validates the OCIO runtime/config path + without using Vulkan SPIR-V compilation +- OpenGL OCIO regressions now include dedicated live view/display switching + coverage without relying on Vulkan runtime glslang + +## Metal + +Status: + +- `Supported` + +Implementation: + +- [imiv_renderer_metal.mm](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_metal.mm) + +Current scope: + +- GLFW + Cocoa window hookup +- Metal device / command queue creation +- CAMetalLayer setup +- Dear ImGui Metal backend hookup +- GPU-native Metal preview rendering for the shared preview controls +- backend-specific Dear ImGui Metal texture bindings with per-texture sampler + control +- Metal screenshot/readback for GUI verification +- Metal OCIO runtime path using OCIO MSL output + +Planned direction: + +- use backend-native Metal rendering +- do not try to reuse Vulkan pipeline code directly +- keep Metal OCIO on the MSL path, not the Vulkan shader path + +Important constraints: + +- keep Metal-specific sampler control in the local ImGui Metal backend fork + instead of assuming upstream Dear ImGui provides nearest sampling control + +Manual verification: + +- canonical cross-platform backend verifier: + - [imiv_backend_verify.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_backend_verify.py) +- shared RGB-input regression: + - [imiv_rgb_input_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_rgb_input_regression.py) +- shared nearest-vs-linear sampling regression: + - [imiv_sampling_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_sampling_regression.py) +- compatibility frontends: + - [imiv_macos_backend_verify.sh](/mnt/f/gh/openimageio/src/imiv/tools/imiv_macos_backend_verify.sh) + - [imiv_linux_backend_verify.sh](/mnt/f/gh/openimageio/src/imiv/tools/imiv_linux_backend_verify.sh) + - [imiv_windows_backend_verify.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_windows_backend_verify.py) +- Metal smoke regression without screenshot/readback dependency: + - [imiv_metal_smoke_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_metal_smoke_regression.py) +- Metal screenshot smoke regression: + - [imiv_metal_screenshot_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_metal_screenshot_regression.py) +- Metal orientation regression: + - [imiv_metal_orientation_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_metal_orientation_regression.py) + +Current automated coverage: + +- when Metal is compiled into the current build, `ctest` can run: + - `imiv_metal_screenshot_regression` + - `imiv_metal_sampling_regression` + - `imiv_metal_orientation_regression` + - `imiv_metal_ocio_live_update_regression` + - `imiv_metal_ocio_live_display_update_regression` + +Current note: + +- the shared backend verifier is green on macOS Metal + +## Feature Mapping Rules + +These should stay consistent across backends where the feature exists. + +1. Viewer/UI state is shared. + Examples: + - selection + - Area Sample + - loaded-image list + - OCIO UI state +2. Preview behavior should match across backends where implemented. + Examples: + - orientation + - channel display modes + - exposure/gamma/offset +3. Backend-specific gaps should degrade explicitly, not silently. + Examples: + - non-implemented screenshot paths should report that clearly + +## Current Priority Order + +1. Keep Vulkan stable as the reference backend. +2. Keep OpenGL behavior aligned with Vulkan where features overlap. +3. Keep Metal behavior aligned with Vulkan where features overlap. +4. Preserve green shared-suite coverage on macOS for all compiled backends. +5. Do not let shared app code regress back into Vulkan-only assumptions. + +## Change Checklist + +When adding or changing backend behavior, update this file if the answer to any +of these is `yes`: + +- Did feature coverage change? +- Did a backend constraint change? +- Did a backend become usable for a new class of tests? +- Did the canonical path for OCIO / preview / capture change? diff --git a/src/imiv/imiv_build_config.h.in b/src/imiv/imiv_build_config.h.in new file mode 100644 index 0000000000..80fbe4d3f7 --- /dev/null +++ b/src/imiv/imiv_build_config.h.in @@ -0,0 +1,8 @@ +#pragma once + +#cmakedefine01 IMIV_WITH_VULKAN +#cmakedefine01 IMIV_WITH_METAL +#cmakedefine01 IMIV_WITH_OPENGL +#cmakedefine01 IMIV_EMBED_FONTS + +#define IMIV_BUILD_DEFAULT_BACKEND_KIND @IMIV_BUILD_DEFAULT_BACKEND_KIND@ diff --git a/src/imiv/imiv_capture.cpp b/src/imiv/imiv_capture.cpp new file mode 100644 index 0000000000..c0740bc60a --- /dev/null +++ b/src/imiv/imiv_capture.cpp @@ -0,0 +1,542 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_renderer.h" +#include "imiv_vulkan_types.h" + +#include +#include +#include +#include +#include + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +namespace { + + bool ensure_immediate_submit_resources(VulkanState& vk_state, + std::string& error_message) + { + if (vk_state.immediate_command_pool != VK_NULL_HANDLE + && vk_state.immediate_command_buffer != VK_NULL_HANDLE + && vk_state.immediate_submit_fence != VK_NULL_HANDLE) { + return true; + } + + destroy_immediate_submit_resources(vk_state); + + VkResult err = VK_SUCCESS; + VkCommandPoolCreateInfo pool_ci = {}; + pool_ci.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_ci.flags = VK_COMMAND_POOL_CREATE_TRANSIENT_BIT + | VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_ci.queueFamilyIndex = vk_state.queue_family; + err = vkCreateCommandPool(vk_state.device, &pool_ci, vk_state.allocator, + &vk_state.immediate_command_pool); + if (err != VK_SUCCESS) { + error_message = "vkCreateCommandPool failed for immediate submit"; + destroy_immediate_submit_resources(vk_state); + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_COMMAND_POOL, + vk_state.immediate_command_pool, + "imiv.immediate_submit.command_pool"); + + VkCommandBufferAllocateInfo command_alloc = {}; + command_alloc.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + command_alloc.commandPool = vk_state.immediate_command_pool; + command_alloc.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + command_alloc.commandBufferCount = 1; + err = vkAllocateCommandBuffers(vk_state.device, &command_alloc, + &vk_state.immediate_command_buffer); + if (err != VK_SUCCESS) { + error_message + = "vkAllocateCommandBuffers failed for immediate submit"; + destroy_immediate_submit_resources(vk_state); + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_COMMAND_BUFFER, + vk_state.immediate_command_buffer, + "imiv.immediate_submit.command_buffer"); + + VkFenceCreateInfo fence_ci = {}; + fence_ci.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + fence_ci.flags = VK_FENCE_CREATE_SIGNALED_BIT; + err = vkCreateFence(vk_state.device, &fence_ci, vk_state.allocator, + &vk_state.immediate_submit_fence); + if (err != VK_SUCCESS) { + error_message = "vkCreateFence failed for immediate submit"; + destroy_immediate_submit_resources(vk_state); + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_FENCE, + vk_state.immediate_submit_fence, + "imiv.immediate_submit.fence"); + return true; + } + + bool capture_swapchain_region_rgba8_from_layout( + VulkanState& vk_state, int x, int y, int w, int h, unsigned int* pixels, + VkImageLayout source_layout, const char* source_layout_name, + std::string& error_message) + { + if (pixels == nullptr || w <= 0 || h <= 0) { + error_message = "invalid Vulkan capture buffer or size"; + return false; + } + + ImGui_ImplVulkanH_Window* wd = &vk_state.window_data; + if (wd->FrameIndex >= static_cast(wd->Frames.Size)) { + error_message = "invalid Vulkan swapchain frame index"; + return false; + } + if (x < 0 || y < 0 || x + w > wd->Width || y + h > wd->Height) { + error_message + = "capture rectangle is outside the Vulkan swapchain image"; + return false; + } + + VkImage image = wd->Frames[wd->FrameIndex].Backbuffer; + if (image == VK_NULL_HANDLE) { + error_message = "Vulkan swapchain backbuffer is null"; + return false; + } + + const int full_width = wd->Width; + const int full_height = wd->Height; + if (full_width <= 0 || full_height <= 0) { + error_message = "Vulkan swapchain image size is invalid"; + return false; + } + + VkResult err = vkQueueWaitIdle(vk_state.queue); + if (err != VK_SUCCESS) { + error_message + = "vkQueueWaitIdle failed before Vulkan screen capture"; + return false; + } + + VkBuffer staging_buffer = VK_NULL_HANDLE; + VkDeviceMemory staging_memory = VK_NULL_HANDLE; + VkCommandBuffer command_buf = VK_NULL_HANDLE; + const VkDeviceSize full_buffer_size + = static_cast(full_width) + * static_cast(full_height) * 4; + bool ok = false; + + do { + VkBufferCreateInfo buffer_ci = {}; + buffer_ci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + buffer_ci.size = full_buffer_size; + buffer_ci.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + buffer_ci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + err = vkCreateBuffer(vk_state.device, &buffer_ci, + vk_state.allocator, &staging_buffer); + if (err != VK_SUCCESS) { + error_message + = "vkCreateBuffer failed for Vulkan capture staging buffer"; + break; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_BUFFER, staging_buffer, + "imiv.capture.readback.staging_buffer"); + + VkMemoryRequirements memory_reqs = {}; + vkGetBufferMemoryRequirements(vk_state.device, staging_buffer, + &memory_reqs); + + uint32_t memory_type = 0; + if (!find_memory_type(vk_state.physical_device, + memory_reqs.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT + | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + memory_type)) { + error_message + = "failed to find Vulkan host-visible staging memory type"; + break; + } + + VkMemoryAllocateInfo alloc_info = {}; + alloc_info.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + alloc_info.allocationSize = memory_reqs.size; + alloc_info.memoryTypeIndex = memory_type; + err = vkAllocateMemory(vk_state.device, &alloc_info, + vk_state.allocator, &staging_memory); + if (err != VK_SUCCESS) { + error_message + = "vkAllocateMemory failed for Vulkan capture staging buffer"; + break; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_DEVICE_MEMORY, + staging_memory, + "imiv.capture.readback.staging_memory"); + + err = vkBindBufferMemory(vk_state.device, staging_buffer, + staging_memory, 0); + if (err != VK_SUCCESS) { + error_message = "vkBindBufferMemory failed for Vulkan capture"; + break; + } + + std::string immediate_error; + if (!begin_immediate_submit(vk_state, command_buf, + immediate_error)) { + error_message = immediate_error; + break; + } + + VkImageMemoryBarrier to_transfer = {}; + to_transfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + to_transfer.oldLayout = source_layout; + to_transfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.image = image; + to_transfer.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + to_transfer.subresourceRange.baseMipLevel = 0; + to_transfer.subresourceRange.levelCount = 1; + to_transfer.subresourceRange.baseArrayLayer = 0; + to_transfer.subresourceRange.layerCount = 1; + to_transfer.srcAccessMask = VK_ACCESS_MEMORY_READ_BIT + | VK_ACCESS_MEMORY_WRITE_BIT; + to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + vkCmdPipelineBarrier(command_buf, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, + 0, nullptr, 1, &to_transfer); + + VkBufferImageCopy copy_region = {}; + copy_region.bufferOffset = 0; + copy_region.bufferRowLength = 0; + copy_region.bufferImageHeight = 0; + copy_region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + copy_region.imageSubresource.mipLevel = 0; + copy_region.imageSubresource.baseArrayLayer = 0; + copy_region.imageSubresource.layerCount = 1; + copy_region.imageOffset.x = 0; + copy_region.imageOffset.y = 0; + copy_region.imageOffset.z = 0; + copy_region.imageExtent.width = static_cast(full_width); + copy_region.imageExtent.height = static_cast(full_height); + copy_region.imageExtent.depth = 1; + vkCmdCopyImageToBuffer(command_buf, image, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + staging_buffer, 1, ©_region); + + VkImageMemoryBarrier restore_layout = {}; + restore_layout.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + restore_layout.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + restore_layout.newLayout = source_layout; + restore_layout.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + restore_layout.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + restore_layout.image = image; + restore_layout.subresourceRange.aspectMask + = VK_IMAGE_ASPECT_COLOR_BIT; + restore_layout.subresourceRange.baseMipLevel = 0; + restore_layout.subresourceRange.levelCount = 1; + restore_layout.subresourceRange.baseArrayLayer = 0; + restore_layout.subresourceRange.layerCount = 1; + restore_layout.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + restore_layout.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT; + vkCmdPipelineBarrier(command_buf, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, + nullptr, 0, nullptr, 1, &restore_layout); + + if (!end_immediate_submit(vk_state, command_buf, immediate_error)) { + error_message = immediate_error; + break; + } + command_buf = VK_NULL_HANDLE; + + void* mapped = nullptr; + err = vkMapMemory(vk_state.device, staging_memory, 0, + full_buffer_size, 0, &mapped); + if (err != VK_SUCCESS || mapped == nullptr) { + error_message = "vkMapMemory failed for Vulkan capture readback"; + break; + } + + const unsigned char* src = static_cast( + mapped); + for (int row = 0; row < h; ++row) { + const int src_y = y + row; + const size_t src_offset = (static_cast(src_y) + * static_cast(full_width) + + static_cast(x)) + * 4; + std::memcpy(pixels + + static_cast(row) + * static_cast(w), + src + src_offset, static_cast(w) * 4); + } + vkUnmapMemory(vk_state.device, staging_memory); + + const VkFormat format = wd->SurfaceFormat.format; + const bool bgra_source = (format == VK_FORMAT_B8G8R8A8_UNORM + || format == VK_FORMAT_B8G8R8A8_SRGB); + if (bgra_source) { + unsigned char* bytes = reinterpret_cast(pixels); + const size_t pixel_count = static_cast(w) + * static_cast(h); + for (size_t i = 0; i < pixel_count; ++i) { + unsigned char* px = bytes + i * 4; + std::swap(px[0], px[2]); + } + } + ok = true; + } while (false); + + if (staging_buffer != VK_NULL_HANDLE) + vkDestroyBuffer(vk_state.device, staging_buffer, + vk_state.allocator); + if (staging_memory != VK_NULL_HANDLE) + vkFreeMemory(vk_state.device, staging_memory, vk_state.allocator); + + if (!ok && error_message.empty()) { + error_message = std::string( + "unknown Vulkan screen capture failure from ") + + source_layout_name; + } + return ok; + } + +} // namespace + +void +destroy_immediate_submit_resources(VulkanState& vk_state) +{ + if (vk_state.immediate_submit_fence != VK_NULL_HANDLE) { + vkDestroyFence(vk_state.device, vk_state.immediate_submit_fence, + vk_state.allocator); + vk_state.immediate_submit_fence = VK_NULL_HANDLE; + } + vk_state.immediate_command_buffer = VK_NULL_HANDLE; + if (vk_state.immediate_command_pool != VK_NULL_HANDLE) { + vkDestroyCommandPool(vk_state.device, vk_state.immediate_command_pool, + vk_state.allocator); + vk_state.immediate_command_pool = VK_NULL_HANDLE; + } +} + +bool +begin_immediate_submit(VulkanState& vk_state, VkCommandBuffer& out_command, + std::string& error_message) +{ + out_command = VK_NULL_HANDLE; + if (!ensure_immediate_submit_resources(vk_state, error_message)) + return false; + + VkResult err = vkWaitForFences(vk_state.device, 1, + &vk_state.immediate_submit_fence, VK_TRUE, + UINT64_MAX); + if (err != VK_SUCCESS) { + error_message = "vkWaitForFences failed for immediate submit"; + return false; + } + err = vkResetCommandPool(vk_state.device, vk_state.immediate_command_pool, + 0); + if (err != VK_SUCCESS) { + error_message = "vkResetCommandPool failed for immediate submit"; + return false; + } + + out_command = vk_state.immediate_command_buffer; + VkCommandBufferBeginInfo begin_info = {}; + begin_info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin_info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + err = vkBeginCommandBuffer(out_command, &begin_info); + if (err != VK_SUCCESS) { + error_message = "vkBeginCommandBuffer failed for immediate submit"; + out_command = VK_NULL_HANDLE; + return false; + } + return true; +} + +bool +end_immediate_submit(VulkanState& vk_state, VkCommandBuffer command_buffer, + std::string& error_message) +{ + if (command_buffer == VK_NULL_HANDLE + || command_buffer != vk_state.immediate_command_buffer) { + error_message = "invalid immediate-submit command buffer"; + return false; + } + + VkResult err = vkEndCommandBuffer(command_buffer); + if (err != VK_SUCCESS) { + error_message = "vkEndCommandBuffer failed for immediate submit"; + return false; + } + + err = vkResetFences(vk_state.device, 1, &vk_state.immediate_submit_fence); + if (err != VK_SUCCESS) { + error_message = "vkResetFences failed for immediate submit"; + return false; + } + + VkSubmitInfo submit_info = {}; + submit_info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit_info.commandBufferCount = 1; + submit_info.pCommandBuffers = &command_buffer; + err = vkQueueSubmit(vk_state.queue, 1, &submit_info, + vk_state.immediate_submit_fence); + if (err != VK_SUCCESS) { + error_message = "vkQueueSubmit failed for immediate submit"; + destroy_immediate_submit_resources(vk_state); + return false; + } + err = vkWaitForFences(vk_state.device, 1, &vk_state.immediate_submit_fence, + VK_TRUE, UINT64_MAX); + if (err != VK_SUCCESS) { + error_message = "vkWaitForFences failed after immediate submit"; + return false; + } + return true; +} + +bool +capture_swapchain_region_rgba8(VulkanState& vk_state, int x, int y, int w, + int h, unsigned int* pixels) +{ + std::string error_message; + if (capture_swapchain_region_rgba8_from_layout( + vk_state, x, y, w, h, pixels, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + "VK_IMAGE_LAYOUT_PRESENT_SRC_KHR", error_message)) { + return true; + } + + if (capture_swapchain_region_rgba8_from_layout(vk_state, x, y, w, h, pixels, + VK_IMAGE_LAYOUT_GENERAL, + "VK_IMAGE_LAYOUT_GENERAL", + error_message)) { + return true; + } + + std::fprintf(stderr, "imiv: Vulkan screen capture failed: %s\n", + error_message.empty() ? "unknown error" + : error_message.c_str()); + return false; +} + +bool +imiv_vulkan_screen_capture(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data) +{ + if (pixels == nullptr || w <= 0 || h <= 0) + return false; + + // renderer_screen_capture() forwards the owning RendererState as user data. + RendererState* renderer_state = reinterpret_cast(user_data); + if (renderer_state == nullptr || renderer_state->backend == nullptr) + return false; + VulkanState* vk_state = reinterpret_cast( + renderer_state->backend); + + int capture_x = x; + int capture_y = y; + int capture_w = w; + int capture_h = h; + bool use_full_capture = false; + ImGuiViewport* viewport = ImGui::FindViewportByID(viewport_id); + if (viewport != nullptr && vk_state->window_data.Width > 0 + && vk_state->window_data.Height > 0 && viewport->Size.x > 0.0f + && viewport->Size.y > 0.0f) { + const double scale_x = static_cast(vk_state->window_data.Width) + / static_cast(viewport->Size.x); + const double scale_y = static_cast(vk_state->window_data.Height) + / static_cast(viewport->Size.y); + capture_x = static_cast(std::lround( + (static_cast(x) - static_cast(viewport->Pos.x)) + * scale_x)); + capture_y = static_cast(std::lround( + (static_cast(y) - static_cast(viewport->Pos.y)) + * scale_y)); + capture_w = std::max(1, static_cast(std::lround( + static_cast(w) * scale_x))); + capture_h = std::max(1, static_cast(std::lround( + static_cast(h) * scale_y))); + } else if (x < 0 || y < 0) { + use_full_capture = true; + } + + if (!use_full_capture) { + if (capture_x < 0) { + capture_w += capture_x; + capture_x = 0; + } + if (capture_y < 0) { + capture_h += capture_y; + capture_y = 0; + } + if (capture_x < vk_state->window_data.Width + && capture_y < vk_state->window_data.Height) { + capture_w = std::min(capture_w, + vk_state->window_data.Width - capture_x); + capture_h = std::min(capture_h, + vk_state->window_data.Height - capture_y); + } + if (capture_w <= 0 || capture_h <= 0) + use_full_capture = true; + } + + if (use_full_capture) { + capture_x = 0; + capture_y = 0; + capture_w = std::max(1, vk_state->window_data.Width); + capture_h = std::max(1, vk_state->window_data.Height); + } + + if (capture_w == w && capture_h == h) + return capture_swapchain_region_rgba8(*vk_state, capture_x, capture_y, + capture_w, capture_h, pixels); + + std::vector captured_pixels(static_cast(capture_w) + * static_cast(capture_h)); + if (!capture_swapchain_region_rgba8(*vk_state, capture_x, capture_y, + capture_w, capture_h, + captured_pixels.data())) { + return false; + } + + const unsigned char* src_bytes = reinterpret_cast( + captured_pixels.data()); + unsigned char* dst_bytes = reinterpret_cast(pixels); + const double sample_scale_x = static_cast(capture_w) + / static_cast(w); + const double sample_scale_y = static_cast(capture_h) + / static_cast(h); + for (int row = 0; row < h; ++row) { + unsigned char* dst_row + = dst_bytes + static_cast(row) * static_cast(w) * 4; + const int sample_row = std::clamp(static_cast(std::floor( + (static_cast(row) + 0.5) + * sample_scale_y)), + 0, capture_h - 1); + const unsigned char* src_row = src_bytes + + static_cast(sample_row) + * static_cast(capture_w) + * 4; + for (int col = 0; col < w; ++col) { + const int sample_col = std::clamp( + static_cast(std::floor((static_cast(col) + 0.5) + * sample_scale_x)), + 0, capture_w - 1); + const unsigned char* src = src_row + + static_cast(sample_col) * 4; + unsigned char* dst = dst_row + static_cast(col) * 4; + dst[0] = src[0]; + dst[1] = src[1]; + dst[2] = src[2]; + dst[3] = src[3]; + } + } + + return true; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_developer_tools.cpp b/src/imiv/imiv_developer_tools.cpp new file mode 100644 index 0000000000..205288234e --- /dev/null +++ b/src/imiv/imiv_developer_tools.cpp @@ -0,0 +1,693 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_developer_tools.h" + +#include "imiv_file_actions.h" +#include "imiv_ocio.h" +#include "imiv_parse.h" +#include "imiv_workspace_ui.h" + +#include +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +void +apply_test_engine_ocio_overrides(PlaceholderUiState& ui_state) +{ + const int apply_frame + = env_int_value("IMIV_IMGUI_TEST_ENGINE_OCIO_APPLY_FRAME", 0); + if (ImGui::GetFrameCount() < apply_frame) + return; + + std::string value; + env_read_bool_value("IMIV_IMGUI_TEST_ENGINE_OCIO_USE", ui_state.use_ocio); + + if (read_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_DISPLAY", value)) { + ui_state.use_ocio = true; + ui_state.ocio_display = std::string(Strutil::strip(value)); + } + if (read_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_VIEW", value)) { + ui_state.use_ocio = true; + ui_state.ocio_view = std::string(Strutil::strip(value)); + } + if (read_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_IMAGE_COLOR_SPACE", value)) { + ui_state.use_ocio = true; + ui_state.ocio_image_color_space = std::string(Strutil::strip(value)); + } + + env_read_bool_value("IMIV_IMGUI_TEST_ENGINE_LINEAR_INTERPOLATION", + ui_state.linear_interpolation); +} + + + +void +apply_test_engine_view_activation_override(MultiViewWorkspace& workspace) +{ + const int apply_frame + = env_int_value("IMIV_IMGUI_TEST_ENGINE_VIEW_APPLY_FRAME", -1); + if (apply_frame < 0 || ImGui::GetFrameCount() < apply_frame) + return; + + const int activate_view_index + = env_int_value("IMIV_IMGUI_TEST_ENGINE_ACTIVATE_VIEW_INDEX", -1); + if (activate_view_index >= 0 + && activate_view_index + < static_cast(workspace.view_windows.size())) { + ImageViewWindow* target + = workspace.view_windows[static_cast(activate_view_index)] + .get(); + if (target != nullptr) { + workspace.active_view_id = target->id; + target->request_focus = true; + } + } +} + + + +void +apply_test_engine_view_recipe_overrides(PlaceholderUiState& ui_state) +{ + const int apply_frame + = env_int_value("IMIV_IMGUI_TEST_ENGINE_VIEW_APPLY_FRAME", -1); + if (apply_frame < 0 || ImGui::GetFrameCount() < apply_frame) + return; + + std::string backend_value; + if (read_env_value("IMIV_IMGUI_TEST_ENGINE_RENDERER_BACKEND", + backend_value)) { + BackendKind backend_kind = BackendKind::Auto; + if (parse_backend_kind(backend_value, backend_kind)) { + ui_state.renderer_backend = static_cast(backend_kind); + } + } + + bool found = false; + const float exposure = env_float_value("IMIV_IMGUI_TEST_ENGINE_EXPOSURE", + ui_state.exposure, &found); + if (found) + ui_state.exposure = exposure; + + const float gamma = env_float_value("IMIV_IMGUI_TEST_ENGINE_GAMMA", + ui_state.gamma, &found); + if (found) + ui_state.gamma = gamma; + + const float offset = env_float_value("IMIV_IMGUI_TEST_ENGINE_OFFSET", + ui_state.offset, &found); + if (found) + ui_state.offset = offset; +} + + + +void +apply_test_engine_drop_overrides(ViewerState& viewer) +{ + const int apply_frame + = env_int_value("IMIV_IMGUI_TEST_ENGINE_DROP_APPLY_FRAME", -1); + if (apply_frame < 0 || ImGui::GetFrameCount() != apply_frame) + return; + + std::string path_file; + if (!read_env_value("IMIV_IMGUI_TEST_ENGINE_DROP_PATHS_FILE", path_file)) + return; + path_file = std::string(Strutil::strip(path_file)); + if (path_file.empty()) + return; + + std::ifstream input(path_file); + if (!input) + return; + + std::vector drop_paths; + std::string line; + while (std::getline(input, line)) { + const std::string trimmed = std::string(Strutil::strip(line)); + if (trimmed.empty()) + continue; + drop_paths.emplace_back(trimmed); + } + + if (drop_paths.empty()) + return; + + viewer.pending_drop_paths = std::move(drop_paths); + viewer.drag_overlay_active = false; +} + + + +void +begin_developer_screenshot_request(DeveloperUiState& developer_ui, + ViewerState& viewer) +{ + if (!developer_ui.enabled) + return; + if (!developer_ui.request_screenshot || developer_ui.screenshot_busy) + return; + developer_ui.request_screenshot = false; + developer_ui.screenshot_busy = true; + developer_ui.screenshot_due_time = ImGui::GetTime() + 3.0; + viewer.last_error.clear(); + viewer.status_message + = "Screenshot queued; capturing main window in 3 seconds"; + print("imiv: developer screenshot queued (3 second delay)\n"); +} + + + +void +draw_developer_windows(DeveloperUiState& developer_ui) +{ + if (!developer_ui.enabled) + return; + if (developer_ui.show_imgui_demo_window) + ImGui::ShowDemoWindow(&developer_ui.show_imgui_demo_window); + if (developer_ui.show_imgui_style_editor) { + if (ImGui::Begin("Dear ImGui Style Editor", + &developer_ui.show_imgui_style_editor)) { + ImGui::ShowStyleEditor(); + } + ImGui::End(); + } + if (developer_ui.show_imgui_metrics_window) { + ImGui::ShowMetricsWindow(&developer_ui.show_imgui_metrics_window); + } + if (developer_ui.show_imgui_debug_log_window) { + ImGui::ShowDebugLogWindow(&developer_ui.show_imgui_debug_log_window); + } + if (developer_ui.show_imgui_id_stack_window) { + ImGui::ShowIDStackToolWindow(&developer_ui.show_imgui_id_stack_window); + } + if (developer_ui.show_imgui_about_window) { + ImGui::ShowAboutWindow(&developer_ui.show_imgui_about_window); + } +} + + + +#if defined(IMGUI_ENABLE_TEST_ENGINE) +namespace { + + void test_engine_json_write_escaped(FILE* f, const char* s) + { + std::fputc('"', f); + for (const unsigned char* p = reinterpret_cast( + s ? s : ""); + *p; ++p) { + const unsigned char c = *p; + switch (c) { + case '\\': std::fputs("\\\\", f); break; + case '"': std::fputs("\\\"", f); break; + case '\b': std::fputs("\\b", f); break; + case '\f': std::fputs("\\f", f); break; + case '\n': std::fputs("\\n", f); break; + case '\r': std::fputs("\\r", f); break; + case '\t': std::fputs("\\t", f); break; + default: + if (c < 0x20) + std::fprintf(f, "\\u%04x", static_cast(c)); + else + std::fputc(static_cast(c), f); + break; + } + } + std::fputc('"', f); + } + + void test_engine_json_write_vec2(FILE* f, const ImVec2& v) + { + std::fprintf(f, "[%.3f,%.3f]", static_cast(v.x), + static_cast(v.y)); + } + + void + test_engine_json_write_string_array(FILE* f, + const std::vector& values) + { + std::fputc('[', f); + for (size_t i = 0, e = values.size(); i < e; ++i) { + if (i > 0) + std::fputs(", ", f); + test_engine_json_write_escaped(f, values[i].c_str()); + } + std::fputc(']', f); + } + + void test_engine_json_write_ocio_state(FILE* f, + const PlaceholderUiState& ui_state) + { + OcioConfigSelection config_selection; + resolve_ocio_config_selection(ui_state, config_selection); + + std::vector image_color_spaces; + std::vector displays; + std::vector views; + std::vector>> + views_by_display; + std::string resolved_display; + std::string resolved_view; + std::string menu_error; + const bool menu_data_ok + = query_ocio_menu_data(ui_state, image_color_spaces, displays, + views, resolved_display, resolved_view, + menu_error); + + if (menu_data_ok) { + views_by_display.reserve(displays.size()); + for (const std::string& display_name : displays) { + PlaceholderUiState probe_state = ui_state; + probe_state.ocio_display = display_name; + probe_state.ocio_view = "default"; + + std::vector probe_color_spaces; + std::vector probe_displays; + std::vector probe_views; + std::string probe_resolved_display; + std::string probe_resolved_view; + std::string probe_error; + if (query_ocio_menu_data(probe_state, probe_color_spaces, + probe_displays, probe_views, + probe_resolved_display, + probe_resolved_view, probe_error)) { + views_by_display.emplace_back(display_name, + std::move(probe_views)); + } else { + views_by_display.emplace_back(display_name, + std::vector()); + } + } + } + + std::fputs(",\n \"ocio\": {\n", f); + std::fputs(" \"use_ocio\": ", f); + std::fputs(ui_state.use_ocio ? "true" : "false", f); + std::fputs(",\n \"requested_source\": ", f); + test_engine_json_write_escaped(f, + ocio_config_source_name( + config_selection.requested_source)); + std::fputs(",\n \"resolved_source\": ", f); + test_engine_json_write_escaped(f, + ocio_config_source_name( + config_selection.resolved_source)); + std::fputs(",\n \"fallback_applied\": ", f); + std::fputs(config_selection.fallback_applied ? "true" : "false", f); + std::fputs(",\n \"resolved_config_path\": ", f); + test_engine_json_write_escaped(f, + config_selection.resolved_path.c_str()); + std::fputs(",\n \"display\": ", f); + test_engine_json_write_escaped(f, ui_state.ocio_display.c_str()); + std::fputs(",\n \"view\": ", f); + test_engine_json_write_escaped(f, ui_state.ocio_view.c_str()); + std::fputs(",\n \"image_color_space\": ", f); + test_engine_json_write_escaped(f, + ui_state.ocio_image_color_space.c_str()); + std::fputs(",\n \"resolved_display\": ", f); + test_engine_json_write_escaped(f, resolved_display.c_str()); + std::fputs(",\n \"resolved_view\": ", f); + test_engine_json_write_escaped(f, resolved_view.c_str()); + std::fputs(",\n \"menu_data_ok\": ", f); + std::fputs(menu_data_ok ? "true" : "false", f); + std::fputs(",\n \"menu_error\": ", f); + test_engine_json_write_escaped(f, menu_error.c_str()); + std::fputs(",\n \"available_image_color_spaces\": ", f); + test_engine_json_write_string_array(f, image_color_spaces); + std::fputs(",\n \"available_displays\": ", f); + test_engine_json_write_string_array(f, displays); + std::fputs(",\n \"available_views\": ", f); + test_engine_json_write_string_array(f, views); + std::fputs(",\n \"views_by_display\": {\n", f); + for (size_t i = 0, e = views_by_display.size(); i < e; ++i) { + if (i > 0) + std::fputs(",\n", f); + std::fputs(" ", f); + test_engine_json_write_escaped(f, + views_by_display[i].first.c_str()); + std::fputs(": ", f); + test_engine_json_write_string_array(f, views_by_display[i].second); + } + std::fputs("\n }\n }", f); + } + + void test_engine_json_write_backend_state( + FILE* f, const PlaceholderUiState& ui_state, BackendKind active_backend) + { + const BackendKind requested_backend = sanitize_backend_kind( + ui_state.renderer_backend); + const BackendKind next_launch_backend = resolve_backend_request( + requested_backend); + const bool requested_backend_compiled + = requested_backend == BackendKind::Auto + || backend_kind_is_compiled(requested_backend); + const bool requested_backend_available + = requested_backend == BackendKind::Auto + || backend_kind_is_available(requested_backend); + + std::vector compiled_backends; + std::vector not_compiled_backends; + std::vector available_backends; + std::vector unavailable_backends; + compiled_backends.reserve(runtime_backend_info().size()); + not_compiled_backends.reserve(runtime_backend_info().size()); + available_backends.reserve(runtime_backend_info().size()); + unavailable_backends.reserve(runtime_backend_info().size()); + for (const BackendRuntimeInfo& info : runtime_backend_info()) { + if (info.build_info.compiled) + compiled_backends.emplace_back(info.build_info.cli_name); + else + not_compiled_backends.emplace_back(info.build_info.cli_name); + if (!info.build_info.compiled) + continue; + if (info.available) + available_backends.emplace_back(info.build_info.cli_name); + else + unavailable_backends.emplace_back(info.build_info.cli_name); + } + + std::fputs(",\n \"backend\": {\n", f); + std::fputs(" \"active\": ", f); + test_engine_json_write_escaped(f, backend_cli_name(active_backend)); + std::fputs(",\n \"active_runtime\": ", f); + test_engine_json_write_escaped(f, backend_runtime_name(active_backend)); + std::fputs(",\n \"requested\": ", f); + test_engine_json_write_escaped(f, backend_cli_name(requested_backend)); + std::fputs(",\n \"next_launch\": ", f); + test_engine_json_write_escaped(f, + backend_cli_name(next_launch_backend)); + std::fputs(",\n \"requested_compiled\": ", f); + std::fputs(requested_backend_compiled ? "true" : "false", f); + std::fputs(",\n \"requested_available\": ", f); + std::fputs(requested_backend_available ? "true" : "false", f); + std::fputs(",\n \"restart_required\": ", f); + std::fputs(next_launch_backend != active_backend ? "true" : "false", f); + std::fputs(",\n \"availability_probed\": ", f); + std::fputs(runtime_backend_info_valid() ? "true" : "false", f); + std::fputs(",\n \"compiled\": ", f); + test_engine_json_write_string_array(f, compiled_backends); + std::fputs(",\n \"available\": ", f); + test_engine_json_write_string_array(f, available_backends); + std::fputs(",\n \"unavailable\": ", f); + test_engine_json_write_string_array(f, unavailable_backends); + std::fputs(",\n \"not_compiled\": ", f); + test_engine_json_write_string_array(f, not_compiled_backends); + std::fputs(",\n \"unavailable_reason\": {\n", f); + bool first_reason = true; + for (const BackendRuntimeInfo& info : runtime_backend_info()) { + if (!info.build_info.compiled || info.available) + continue; + if (!first_reason) + std::fputs(",\n", f); + first_reason = false; + std::fputs(" ", f); + test_engine_json_write_escaped(f, info.build_info.cli_name); + std::fputs(": ", f); + test_engine_json_write_escaped(f, info.unavailable_reason.c_str()); + } + std::fputs("\n }", f); + std::fputs("\n }", f); + } + + void test_engine_json_write_view_recipe_state(FILE* f, + const ViewerState& viewer) + { + std::fputs(",\n \"view_recipe\": {\n", f); + std::fputs(" \"use_ocio\": ", f); + std::fputs(viewer.recipe.use_ocio ? "true" : "false", f); + std::fputs(",\n \"linear_interpolation\": ", f); + std::fputs(viewer.recipe.linear_interpolation ? "true" : "false", f); + std::fputs(",\n \"current_channel\": ", f); + std::fprintf(f, "%d", viewer.recipe.current_channel); + std::fputs(",\n \"color_mode\": ", f); + std::fprintf(f, "%d", viewer.recipe.color_mode); + std::fputs(",\n \"exposure\": ", f); + std::fprintf(f, "%.6f", static_cast(viewer.recipe.exposure)); + std::fputs(",\n \"gamma\": ", f); + std::fprintf(f, "%.6f", static_cast(viewer.recipe.gamma)); + std::fputs(",\n \"offset\": ", f); + std::fprintf(f, "%.6f", static_cast(viewer.recipe.offset)); + std::fputs(",\n \"ocio_display\": ", f); + test_engine_json_write_escaped(f, viewer.recipe.ocio_display.c_str()); + std::fputs(",\n \"ocio_view\": ", f); + test_engine_json_write_escaped(f, viewer.recipe.ocio_view.c_str()); + std::fputs(",\n \"ocio_image_color_space\": ", f); + test_engine_json_write_escaped( + f, viewer.recipe.ocio_image_color_space.c_str()); + std::fputs("\n }", f); + } + +} // namespace + +bool +write_test_engine_viewer_state_json(const std::filesystem::path& out_path, + void* user_data, std::string& error_message) +{ + error_message.clear(); + const ViewerStateJsonWriteContext* ctx + = reinterpret_cast(user_data); + if (ctx == nullptr || ctx->viewer == nullptr || ctx->ui_state == nullptr) { + error_message = "viewer state is unavailable"; + return false; + } + + std::error_code ec; + if (!out_path.parent_path().empty()) + std::filesystem::create_directories(out_path.parent_path(), ec); + + FILE* f = nullptr; +# if defined(_WIN32) + if (fopen_s(&f, out_path.string().c_str(), "wb") != 0) + f = nullptr; +# else + f = std::fopen(out_path.string().c_str(), "wb"); +# endif + if (f == nullptr) { + error_message = Strutil::fmt::format("failed to open output file: {}", + out_path.string()); + return false; + } + + const ImageViewWindow* state_view = ctx->workspace != nullptr + ? active_image_view(*ctx->workspace) + : nullptr; + const ViewerState& viewer = (state_view != nullptr) ? state_view->viewer + : *ctx->viewer; + const PlaceholderUiState& ui_state = *ctx->ui_state; + int display_width = viewer.image.width; + int display_height = viewer.image.height; + if (!viewer.image.path.empty()) + oriented_image_dimensions(viewer.image, display_width, display_height); + ImVec2 viewport_origin(0.0f, 0.0f); + ImVec2 viewport_size(0.0f, 0.0f); + if (ImGuiViewport* viewport = ImGui::GetMainViewport()) { + viewport_origin = viewport->Pos; + viewport_size = viewport->Size; + } + + std::fputs("{\n", f); + std::fputs(" \"image_loaded\": ", f); + std::fputs(viewer.image.path.empty() ? "false" : "true", f); + std::fputs(",\n \"image_path\": ", f); + test_engine_json_write_escaped(f, viewer.image.path.c_str()); + std::fputs(",\n \"zoom\": ", f); + std::fprintf(f, "%.6f", static_cast(viewer.zoom)); + std::fputs(",\n \"auto_subimage\": ", f); + std::fputs(viewer.auto_subimage ? "true" : "false", f); + std::fputs(",\n \"scroll\": ", f); + test_engine_json_write_vec2(f, viewer.scroll); + std::fputs(",\n \"norm_scroll\": ", f); + test_engine_json_write_vec2(f, viewer.norm_scroll); + std::fputs(",\n \"max_scroll\": ", f); + test_engine_json_write_vec2(f, viewer.max_scroll); + std::fputs(",\n \"fit_image_to_window\": ", f); + std::fputs(ui_state.fit_image_to_window ? "true" : "false", f); + std::fputs(",\n \"loaded_image_count\": ", f); + std::fprintf(f, "%d", static_cast(viewer.loaded_image_paths.size())); + std::fputs(",\n \"current_image_index\": ", f); + std::fprintf(f, "%d", viewer.current_path_index); + std::fputs(",\n \"view_count\": ", f); + std::fprintf(f, "%d", + ctx->workspace != nullptr + ? static_cast(ctx->workspace->view_windows.size()) + : 1); + std::fputs(",\n \"loaded_view_count\": ", f); + { + int loaded_view_count = 0; + if (ctx->workspace != nullptr) { + for (const std::unique_ptr& view : + ctx->workspace->view_windows) { + if (view != nullptr && !view->viewer.image.path.empty()) + ++loaded_view_count; + } + } else if (!viewer.image.path.empty()) { + loaded_view_count = 1; + } + std::fprintf(f, "%d", loaded_view_count); + } + std::fputs(",\n \"active_view_id\": ", f); + std::fprintf(f, "%d", + ctx->workspace != nullptr ? ctx->workspace->active_view_id + : 0); + std::fputs(",\n \"active_view_docked\": ", f); + { + const ImageViewWindow* active_view + = ctx->workspace != nullptr ? active_image_view(*ctx->workspace) + : nullptr; + std::fputs((active_view != nullptr && active_view->is_docked) ? "true" + : "false", + f); + } + std::fputs(",\n \"image_list_visible\": ", f); + std::fputs((ctx->workspace != nullptr + && ctx->workspace->show_image_list_window) + ? "true" + : "false", + f); + std::fputs(",\n \"image_list_drawn\": ", f); + std::fputs((ctx->workspace != nullptr + && ctx->workspace->image_list_was_drawn) + ? "true" + : "false", + f); + std::fputs(",\n \"image_list_docked\": ", f); + std::fputs((ctx->workspace != nullptr + && ctx->workspace->image_list_is_docked) + ? "true" + : "false", + f); + std::fputs(",\n \"image_list_pos\": ", f); + test_engine_json_write_vec2(f, ctx->workspace != nullptr + ? ctx->workspace->image_list_pos + : ImVec2(0.0f, 0.0f)); + std::fputs(",\n \"image_list_size\": ", f); + test_engine_json_write_vec2(f, ctx->workspace != nullptr + ? ctx->workspace->image_list_size + : ImVec2(0.0f, 0.0f)); + std::fputs(",\n \"image_list_item_rects\": [", f); + if (ctx->workspace != nullptr) { + for (size_t i = 0; i < ctx->workspace->image_list_item_rects.size(); + ++i) { + const ImVec4 rect = ctx->workspace->image_list_item_rects[i]; + if (i > 0) + std::fputs(", ", f); + std::fputs("[", f); + std::fprintf(f, "%.3f,%.3f,%.3f,%.3f", static_cast(rect.x), + static_cast(rect.y), + static_cast(rect.z), + static_cast(rect.w)); + std::fputs("]", f); + } + } + std::fputs("]", f); + std::fputs(",\n \"image_list_open_view_ids\": [", f); + if (ctx->workspace != nullptr) { + for (size_t i = 0; i < ctx->viewer->loaded_image_paths.size(); ++i) { + if (i > 0) + std::fputs(", ", f); + const std::vector view_ids + = image_list_open_view_ids(*ctx->workspace, + ctx->viewer->loaded_image_paths[i]); + std::fputs("[", f); + for (size_t j = 0; j < view_ids.size(); ++j) { + if (j > 0) + std::fputs(", ", f); + std::fprintf(f, "%d", view_ids[j]); + } + std::fputs("]", f); + } + } + std::fputs("]", f); + std::fputs(",\n \"subimage\": ", f); + std::fprintf(f, "%d", viewer.image.subimage); + std::fputs(",\n \"miplevel\": ", f); + std::fprintf(f, "%d", viewer.image.miplevel); + std::fputs(",\n \"drag_overlay_active\": ", f); + std::fputs(viewer.drag_overlay_active ? "true" : "false", f); + std::fputs(",\n \"area_probe_drag_active\": ", f); + std::fputs(viewer.area_probe_drag_active ? "true" : "false", f); + std::fputs(",\n \"selection_active\": ", f); + std::fputs(has_image_selection(viewer) ? "true" : "false", f); + std::fputs(",\n \"selection_bounds\": [", f); + std::fprintf(f, "%d,%d,%d,%d", viewer.selection_xbegin, + viewer.selection_ybegin, viewer.selection_xend, + viewer.selection_yend); + std::fputs("]", f); + std::fputs(",\n \"image_size\": [", f); + std::fprintf(f, "%d,%d", viewer.image.width, viewer.image.height); + std::fputs("],\n \"display_size\": [", f); + std::fprintf(f, "%d,%d", display_width, display_height); + std::fputs("]", f); + std::fputs(",\n \"viewport_origin\": ", f); + test_engine_json_write_vec2(f, viewport_origin); + std::fputs(",\n \"viewport_size\": ", f); + test_engine_json_write_vec2(f, viewport_size); + std::fputs(",\n \"orientation\": ", f); + std::fprintf(f, "%d", viewer.image.orientation); + std::fputs(",\n \"probe_valid\": ", f); + std::fputs(viewer.probe_valid ? "true" : "false", f); + std::fputs(",\n \"probe_pos\": [", f); + std::fprintf(f, "%d,%d", viewer.probe_x, viewer.probe_y); + std::fputs("]", f); + std::fputs(",\n \"probe_channel_count\": ", f); + std::fprintf(f, "%zu", viewer.probe_channels.size()); + std::fputs(",\n \"area_probe_lines\": [", f); + for (size_t i = 0; i < viewer.area_probe_lines.size(); ++i) { + if (i > 0) + std::fputs(", ", f); + test_engine_json_write_escaped(f, viewer.area_probe_lines[i].c_str()); + } + std::fputs("]", f); + test_engine_json_write_view_recipe_state(f, viewer); + test_engine_json_write_backend_state(f, ui_state, ctx->active_backend); + test_engine_json_write_ocio_state(f, ui_state); + std::fputs("\n}\n", f); + std::fflush(f); + std::fclose(f); + return true; +} +#endif + + + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +void +process_developer_post_render_actions(DeveloperUiState& developer_ui, + ViewerState& viewer, + RendererState& vk_state) +{ + if (!developer_ui.enabled) + return; + if (!developer_ui.screenshot_busy) + return; + if (ImGui::GetTime() < developer_ui.screenshot_due_time) + return; + + std::string out_path; + if (!capture_main_viewport_screenshot_action(vk_state, viewer, out_path)) { + if (viewer.last_error.empty()) + viewer.last_error = "screenshot capture failed"; + print(stderr, "imiv: developer screenshot failed: {}\n", + viewer.last_error); + } else { + print("imiv: developer screenshot saved {}\n", out_path); + } + developer_ui.screenshot_busy = false; + developer_ui.screenshot_due_time = -1.0; +} +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_developer_tools.h b/src/imiv/imiv_developer_tools.h new file mode 100644 index 0000000000..204f0ce293 --- /dev/null +++ b/src/imiv/imiv_developer_tools.h @@ -0,0 +1,65 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_backend.h" +#include "imiv_renderer.h" +#include "imiv_ui.h" + +#include +#include + +namespace Imiv { + +struct DeveloperUiState { + bool enabled = false; + bool show_imgui_demo_window = false; + bool show_imgui_style_editor = false; + bool show_imgui_metrics_window = false; + bool show_imgui_debug_log_window = false; + bool show_imgui_id_stack_window = false; + bool show_imgui_about_window = false; + bool request_screenshot = false; + bool screenshot_busy = false; + double screenshot_due_time = -1.0; +}; + +void +apply_test_engine_ocio_overrides(PlaceholderUiState& ui_state); +void +apply_test_engine_view_activation_override(MultiViewWorkspace& workspace); +void +apply_test_engine_view_recipe_overrides(PlaceholderUiState& ui_state); +void +apply_test_engine_drop_overrides(ViewerState& viewer); +void +begin_developer_screenshot_request(DeveloperUiState& developer_ui, + ViewerState& viewer); +void +draw_developer_windows(DeveloperUiState& developer_ui); + +#if defined(IMGUI_ENABLE_TEST_ENGINE) +struct ViewerStateJsonWriteContext { + const ViewerState* viewer = nullptr; + const MultiViewWorkspace* workspace = nullptr; + const PlaceholderUiState* ui_state = nullptr; + BackendKind active_backend = BackendKind::Auto; +}; + +bool +write_test_engine_viewer_state_json(const std::filesystem::path& out_path, + void* user_data, + std::string& error_message); +#endif + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +void +process_developer_post_render_actions(DeveloperUiState& developer_ui, + ViewerState& viewer, + RendererState& renderer_state); +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_drag_drop.cpp b/src/imiv/imiv_drag_drop.cpp new file mode 100644 index 0000000000..484b2dcb2e --- /dev/null +++ b/src/imiv/imiv_drag_drop.cpp @@ -0,0 +1,172 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_drag_drop.h" + +#include "external/dnd_glfw/dnd_glfw.h" +#include "imiv_actions.h" +#include "imiv_image_library.h" + +#include +#include +#include +#include + +#include + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +# define GLFW_INCLUDE_NONE +# include +#endif + +namespace Imiv { + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +namespace { + + void on_dnd_drag_enter(GLFWwindow* window, const dnd_glfw::DragEvent& event, + void* user_data) + { + (void)window; + ViewerState* viewer = static_cast(user_data); + if (viewer == nullptr) + return; + if (event.kind == dnd_glfw::PayloadKind::Files) + viewer->drag_overlay_active = true; + } + + void on_dnd_drag_over(GLFWwindow* window, const dnd_glfw::DragEvent& event, + void* user_data) + { + (void)window; + (void)event; + (void)user_data; + } + + void on_dnd_drag_leave(GLFWwindow* window, void* user_data) + { + (void)window; + ViewerState* viewer = static_cast(user_data); + if (viewer == nullptr) + return; + viewer->drag_overlay_active = false; + } + + void on_dnd_drop(GLFWwindow* window, const dnd_glfw::DropEvent& event, + void* user_data) + { + (void)window; + ViewerState* viewer = static_cast(user_data); + if (viewer == nullptr) + return; + viewer->pending_drop_paths = event.paths; + viewer->drag_overlay_active = false; + } + + void on_dnd_drag_cancel(GLFWwindow* window, void* user_data) + { + (void)window; + ViewerState* viewer = static_cast(user_data); + if (viewer == nullptr) + return; + viewer->drag_overlay_active = false; + } + +} // namespace + +void +install_drag_drop(GLFWwindow* window, ViewerState& viewer) +{ + dnd_glfw::Callbacks callbacks = {}; + callbacks.dragEnter = &on_dnd_drag_enter; + callbacks.dragOver = &on_dnd_drag_over; + callbacks.dragLeave = &on_dnd_drag_leave; + callbacks.drop = &on_dnd_drop; + callbacks.dragCancel = &on_dnd_drag_cancel; + dnd_glfw::init(window, callbacks, &viewer); +} + +void +uninstall_drag_drop(GLFWwindow* window) +{ + dnd_glfw::shutdown(window); +} + +void +process_pending_drop_paths(RendererState& vk_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state) +{ + if (viewer.pending_drop_paths.empty()) + return; + + std::vector drop_paths; + drop_paths.swap(viewer.pending_drop_paths); + + int first_added_index = -1; + append_loaded_image_paths(library, drop_paths, &first_added_index); + sync_viewer_library_state(viewer, library); + + std::string target_path; + if (first_added_index >= 0 + && first_added_index + < static_cast(library.loaded_image_paths.size())) { + target_path + = library.loaded_image_paths[static_cast(first_added_index)]; + } else { + for (const std::string& path : drop_paths) { + if (!set_current_loaded_image_path(library, viewer, path)) + continue; + if (viewer.current_path_index < 0 + || viewer.current_path_index + >= static_cast(library.loaded_image_paths.size())) { + continue; + } + target_path = library.loaded_image_paths[static_cast( + viewer.current_path_index)]; + break; + } + } + + if (target_path.empty()) { + viewer.status_message = "No dropped image paths were accepted"; + viewer.last_error.clear(); + return; + } + + (void)load_viewer_image(vk_state, viewer, library, &ui_state, target_path, + ui_state.subimage_index, ui_state.miplevel_index); +} +#endif + +void +draw_drag_drop_overlay(const ViewerState& viewer) +{ + if (!viewer.drag_overlay_active) + return; + + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->Pos); + ImGui::SetNextWindowSize(viewport->Size); + ImGui::SetNextWindowViewport(viewport->ID); + + const ImGuiWindowFlags flags + = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings + | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize + | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking + | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoInputs; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.5f)); + ImGui::Begin("imiv DragDropOverlay", nullptr, flags); + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_drag_drop.h b/src/imiv/imiv_drag_drop.h new file mode 100644 index 0000000000..d5d0f4337c --- /dev/null +++ b/src/imiv/imiv_drag_drop.h @@ -0,0 +1,32 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_renderer.h" +#include "imiv_viewer.h" + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +struct GLFWwindow; +#endif + +namespace Imiv { + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +void +install_drag_drop(GLFWwindow* window, ViewerState& viewer); +void +uninstall_drag_drop(GLFWwindow* window); +void +process_pending_drop_paths(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state); +#endif + +void +draw_drag_drop_overlay(const ViewerState& viewer); + +} // namespace Imiv diff --git a/src/imiv/imiv_file_actions.cpp b/src/imiv/imiv_file_actions.cpp new file mode 100644 index 0000000000..864ec74142 --- /dev/null +++ b/src/imiv/imiv_file_actions.cpp @@ -0,0 +1,943 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_file_actions.h" + +#include "imiv_actions.h" +#include "imiv_file_dialog.h" +#include "imiv_image_library.h" +#include "imiv_loaded_image.h" +#include "imiv_ocio.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + enum class SaveDialogKind { + SaveImage = 0, + SaveWindow, + SaveSelection, + ExportSelection + }; + + std::filesystem::path executable_directory_path() + { + const std::string program_path = Sysutil::this_program_path(); + if (program_path.empty()) + return std::filesystem::path(); + return std::filesystem::path(program_path).parent_path(); + } + + std::filesystem::path default_screenshot_output_path() + { + std::filesystem::path base_dir = executable_directory_path(); + if (base_dir.empty()) + base_dir = std::filesystem::current_path(); + base_dir /= "screenshots"; + + std::tm local_tm = {}; + const std::time_t now = std::time(nullptr); +#if defined(_WIN32) + localtime_s(&local_tm, &now); +#else + localtime_r(&now, &local_tm); +#endif + + char timestamp[64] = {}; + if (std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", + &local_tm) + == 0) { + std::snprintf(timestamp, sizeof(timestamp), "%lld", + static_cast(now)); + } + + std::filesystem::path path + = base_dir / Strutil::fmt::format("imiv_{}.png", timestamp); + for (int suffix = 1; std::filesystem::exists(path); ++suffix) { + path = base_dir + / Strutil::fmt::format("imiv_{}_{:02d}.png", timestamp, + suffix); + } + return path; + } + + std::vector + collect_action_target_viewers(MultiViewWorkspace* workspace, + ViewerState& active_view) + { + std::vector viewers; + if (workspace == nullptr) { + viewers.push_back(&active_view); + return viewers; + } + viewers.reserve(workspace->view_windows.size()); + for (const std::unique_ptr& view : + workspace->view_windows) { + if (view != nullptr) + viewers.push_back(&view->viewer); + } + if (viewers.empty()) + viewers.push_back(&active_view); + return viewers; + } + + bool build_selection_source_image(const LoadedImage& image, + const ViewerState& viewer, + ImageBuf& result, + std::string& error_message) + { + error_message.clear(); + if (!has_image_selection(viewer)) { + error_message = "no active selection"; + return false; + } + + ImageBuf source; + if (!imagebuf_from_loaded_image(image, source, error_message)) + return false; + + const ROI selection_roi(viewer.selection_xbegin, viewer.selection_xend, + viewer.selection_ybegin, viewer.selection_yend, + 0, 1, 0, image.nchannels); + result = ImageBufAlgo::cut(source, selection_roi); + if (result.has_error()) { + error_message = result.geterror(); + if (error_message.empty()) + error_message = "failed to crop selected image region"; + return false; + } + + result.specmod().attribute("Orientation", image.orientation); + return true; + } + + float selected_channel(float r, float g, float b, float a, int channel) + { + if (channel == 1) + return r; + if (channel == 2) + return g; + if (channel == 3) + return b; + if (channel == 4) + return a; + return r; + } + + void apply_heatmap(float value, float& out_r, float& out_g, float& out_b) + { + const float t = std::clamp(value, 0.0f, 1.0f); + if (t < 0.33f) { + const float u = t / 0.33f; + out_r = 0.0f; + out_g = 0.9f * u; + out_b = 0.5f + (1.0f - 0.5f) * u; + return; + } + if (t < 0.66f) { + const float u = (t - 0.33f) / 0.33f; + out_r = u; + out_g = 0.9f + (1.0f - 0.9f) * u; + out_b = 1.0f - u; + return; + } + const float u = (t - 0.66f) / 0.34f; + out_r = 1.0f; + out_g = 1.0f - u; + out_b = 0.0f; + } + + void apply_window_recipe_to_rgba(std::vector& rgba_pixels, + const ViewRecipe& recipe, + bool exposure_gamma_already_applied) + { + const size_t pixel_count = rgba_pixels.size() / 4; + for (size_t pixel = 0; pixel < pixel_count; ++pixel) { + float& r = rgba_pixels[pixel * 4 + 0]; + float& g = rgba_pixels[pixel * 4 + 1]; + float& b = rgba_pixels[pixel * 4 + 2]; + float& a = rgba_pixels[pixel * 4 + 3]; + + r += recipe.offset; + g += recipe.offset; + b += recipe.offset; + + if (recipe.color_mode == 1) { + a = 1.0f; + } else if (recipe.color_mode == 2) { + const float v = selected_channel(r, g, b, a, + recipe.current_channel); + r = v; + g = v; + b = v; + a = 1.0f; + } else if (recipe.color_mode == 3) { + const float y = 0.2126f * r + 0.7152f * g + 0.0722f * b; + r = y; + g = y; + b = y; + a = 1.0f; + } else if (recipe.color_mode == 4) { + const float v = selected_channel(r, g, b, a, + recipe.current_channel); + apply_heatmap(v, r, g, b); + a = 1.0f; + } + + if (recipe.current_channel > 0 && recipe.color_mode != 2 + && recipe.color_mode != 4) { + const float v = selected_channel(r, g, b, a, + recipe.current_channel); + r = v; + g = v; + b = v; + a = 1.0f; + } + + if (!exposure_gamma_already_applied) { + const float exposure_scale = std::exp2(recipe.exposure); + r = std::pow(std::max(r * exposure_scale, 0.0f), + 1.0f / std::max(recipe.gamma, 0.01f)); + g = std::pow(std::max(g * exposure_scale, 0.0f), + 1.0f / std::max(recipe.gamma, 0.01f)); + b = std::pow(std::max(b * exposure_scale, 0.0f), + 1.0f / std::max(recipe.gamma, 0.01f)); + } + } + } + + bool build_view_export_rgba_image(const ImageBuf& source_image, + const LoadedImage& image_metadata, + const ViewRecipe& recipe, + const PlaceholderUiState& ui_state, + ImageBuf& output, + std::string& error_message) + { + error_message.clear(); + ImageBuf oriented = source_image; + const int orientation + = source_image.spec().get_int_attribute("Orientation", 1); + if (orientation != 1) { + oriented = ImageBufAlgo::reorient(source_image); + if (oriented.has_error()) { + error_message = oriented.geterror(); + if (error_message.empty()) + error_message = "failed to orient export image"; + return false; + } + } + + const int width = oriented.spec().width; + const int height = oriented.spec().height; + const int nchannels = oriented.nchannels(); + if (width <= 0 || height <= 0 || nchannels <= 0) { + error_message = "window export source image is invalid"; + return false; + } + + std::vector src_pixels(static_cast(width) + * static_cast(height) + * static_cast(nchannels)); + if (!oriented.get_pixels(ROI::All(), TypeFloat, src_pixels.data())) { + error_message = oriented.geterror(); + if (error_message.empty()) + error_message = "failed to read source pixels for window export"; + return false; + } + + std::vector rgba_pixels(static_cast(width) + * static_cast(height) * 4u, + 0.0f); + const size_t pixel_count = static_cast(width) + * static_cast(height); + for (size_t pixel = 0; pixel < pixel_count; ++pixel) { + const float* src + = &src_pixels[pixel * static_cast(nchannels)]; + float& r = rgba_pixels[pixel * 4 + 0]; + float& g = rgba_pixels[pixel * 4 + 1]; + float& b = rgba_pixels[pixel * 4 + 2]; + float& a = rgba_pixels[pixel * 4 + 3]; + if (nchannels == 1) { + r = src[0]; + g = src[0]; + b = src[0]; + a = 1.0f; + } else if (nchannels == 2) { + r = src[0]; + g = src[0]; + b = src[0]; + a = src[1]; + } else { + r = src[0]; + g = src[1]; + b = src[2]; + a = nchannels >= 4 ? src[3] : 1.0f; + } + } + + bool ocio_applied = false; + if (recipe.use_ocio) { + PlaceholderUiState export_ui_state = ui_state; + apply_view_recipe_to_ui_state(recipe, export_ui_state); + OCIO::ConstProcessorRcPtr processor; + std::string resolved_display; + std::string resolved_view; + if (!build_ocio_cpu_display_processor( + export_ui_state, &image_metadata, recipe.exposure, + recipe.gamma, processor, resolved_display, resolved_view, + error_message)) { + return false; + } + if (!processor) { + error_message = "OCIO window export processor is unavailable"; + return false; + } + try { + OCIO::ConstCPUProcessorRcPtr cpu_processor + = processor->getDefaultCPUProcessor(); + if (!cpu_processor) { + error_message + = "OCIO CPU processor is unavailable for window export"; + return false; + } + OCIO::PackedImageDesc desc(rgba_pixels.data(), width, height, + 4); + cpu_processor->apply(desc); + } catch (const OCIO::Exception& e) { + error_message = e.what(); + return false; + } + ocio_applied = true; + } + + apply_window_recipe_to_rgba(rgba_pixels, recipe, ocio_applied); + + ImageSpec spec(width, height, 4, TypeFloat); + spec.attribute("Orientation", 1); + spec.channelnames = { "R", "G", "B", "A" }; + output.reset(spec); + if (!output.set_pixels(ROI::All(), TypeFloat, rgba_pixels.data())) { + error_message = output.geterror(); + if (error_message.empty()) + error_message = "failed to store window export pixels"; + return false; + } + return true; + } + + bool build_window_export_rgba_image(const LoadedImage& image, + const ViewRecipe& recipe, + const PlaceholderUiState& ui_state, + ImageBuf& output, + std::string& error_message) + { + error_message.clear(); + + ImageBuf source; + if (!imagebuf_from_loaded_image(image, source, error_message)) + return false; + + return build_view_export_rgba_image(source, image, recipe, ui_state, + output, error_message); + } + + bool save_selection_image(const LoadedImage& image, + const ViewerState& viewer, + const std::string& path, + std::string& error_message) + { + error_message.clear(); + if (path.empty()) { + error_message = "save path is empty"; + return false; + } + + ImageBuf result; + if (!build_selection_source_image(image, viewer, result, error_message)) + return false; + + const int orientation = result.spec().get_int_attribute("Orientation", + 1); + if (orientation != 1) { + ImageBuf oriented = ImageBufAlgo::reorient(result); + if (oriented.has_error()) { + error_message = oriented.geterror(); + if (error_message.empty()) + error_message = "failed to orient selection export"; + return false; + } + result = std::move(oriented); + } + + result.specmod().attribute("Orientation", 1); + if (!result.write(path, result.spec().format)) { + error_message = result.geterror(); + if (error_message.empty()) + error_message = "image write failed"; + return false; + } + return true; + } + + bool save_window_image(const LoadedImage& image, const ViewRecipe& recipe, + const PlaceholderUiState& ui_state, + const std::string& path, std::string& error_message) + { + error_message.clear(); + if (path.empty()) { + error_message = "save path is empty"; + return false; + } + + ImageBuf output; + if (!build_window_export_rgba_image(image, recipe, ui_state, output, + error_message)) { + return false; + } + + if (!output.write(path, TypeFloat)) { + error_message = output.geterror(); + if (error_message.empty()) + error_message = "image write failed"; + return false; + } + return true; + } + + bool save_export_selection_image(const LoadedImage& image, + const ViewerState& viewer, + const PlaceholderUiState& ui_state, + const std::string& path, + std::string& error_message) + { + error_message.clear(); + if (path.empty()) { + error_message = "save path is empty"; + return false; + } + + ImageBuf selection; + if (!build_selection_source_image(image, viewer, selection, + error_message)) { + return false; + } + + ImageBuf output; + if (!build_view_export_rgba_image(selection, image, viewer.recipe, + ui_state, output, error_message)) { + return false; + } + + if (!output.write(path, TypeFloat)) { + error_message = output.geterror(); + if (error_message.empty()) + error_message = "image write failed"; + return false; + } + return true; + } + + bool save_loaded_image(const LoadedImage& image, const std::string& path, + std::string& error_message) + { + error_message.clear(); + if (path.empty()) { + error_message = "save path is empty"; + return false; + } + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0) { + error_message = "no valid image is loaded"; + return false; + } + + const TypeDesc format = upload_data_type_to_typedesc(image.type); + if (format == TypeUnknown) { + error_message = "unsupported source pixel type for save"; + return false; + } + + const size_t width = static_cast(image.width); + const size_t height = static_cast(image.height); + const size_t channels = static_cast(image.nchannels); + const size_t min_row_pitch = width * channels * image.channel_bytes; + if (image.row_pitch_bytes < min_row_pitch) { + error_message = "image row pitch is invalid"; + return false; + } + const size_t required_bytes = image.row_pitch_bytes * height; + if (image.pixels.size() < required_bytes) { + error_message = "image pixel buffer is incomplete"; + return false; + } + + ImageSpec spec(image.width, image.height, image.nchannels, format); + ImageBuf output(spec); + + const std::byte* begin = reinterpret_cast( + image.pixels.data()); + const cspan byte_span(begin, image.pixels.size()); + const stride_t xstride = static_cast(image.nchannels + * image.channel_bytes); + const stride_t ystride = static_cast(image.row_pitch_bytes); + if (!output.set_pixels(ROI::All(), format, byte_span, begin, xstride, + ystride, AutoStride)) { + error_message = output.geterror(); + if (error_message.empty()) + error_message = "failed to copy pixels into save buffer"; + return false; + } + + if (!output.write(path, format)) { + error_message = output.geterror(); + if (error_message.empty()) + error_message = "image write failed"; + return false; + } + return true; + } + + bool open_directory_into_library(RendererState& renderer_state, + ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, + MultiViewWorkspace* workspace, + const std::string& directory_path) + { + std::vector folder_paths; + std::string error_message; + if (!collect_directory_image_paths(directory_path, library.sort_mode, + library.sort_reverse, folder_paths, + error_message)) { + viewer.last_error = error_message; + viewer.status_message.clear(); + return false; + } + if (folder_paths.empty()) { + viewer.last_error + = Strutil::fmt::format("No supported image files found in '{}'", + directory_path); + viewer.status_message.clear(); + return false; + } + + append_loaded_image_paths(library, folder_paths); + const std::vector viewers + = collect_action_target_viewers(workspace, viewer); + sort_loaded_image_paths(library, viewers); + if (workspace != nullptr) + sync_workspace_library_state(*workspace, library); + else + sync_viewer_library_state(viewer, library); + + for (const std::string& candidate : folder_paths) { + if (!set_current_loaded_image_path(library, viewer, candidate)) + continue; + if (load_viewer_image(renderer_state, viewer, library, &ui_state, + candidate, ui_state.subimage_index, + ui_state.miplevel_index)) { + viewer.status_message = Strutil::fmt::format( + "Opened folder {} ({} supported images)", directory_path, + folder_paths.size()); + return true; + } + } + + if (viewer.last_error.empty()) { + viewer.last_error + = Strutil::fmt::format("Failed to load any image from '{}'", + directory_path); + } + return false; + } + + std::string parent_directory_for_dialog(const std::string& path) + { + if (path.empty()) + return std::string(); + std::filesystem::path p(path); + if (!p.has_parent_path()) + return std::string(); + return p.parent_path().string(); + } + + std::string open_dialog_default_path(const ViewerState& viewer, + const ImageLibraryState& library) + { + if (!viewer.image.path.empty()) + return parent_directory_for_dialog(viewer.image.path); + if (!library.recent_images.empty()) + return parent_directory_for_dialog(library.recent_images.front()); + return std::string(); + } + + std::string save_dialog_default_name(const ViewerState& viewer) + { + if (viewer.image.path.empty()) + return "image.exr"; + std::filesystem::path p(viewer.image.path); + if (p.filename().empty()) + return "image.exr"; + return p.filename().string(); + } + + std::string save_selection_default_name(const ViewerState& viewer) + { + if (viewer.image.path.empty()) + return "selection.exr"; + std::filesystem::path p(viewer.image.path); + const std::string stem = p.stem().empty() ? "selection" + : p.stem().string(); + const std::string ext = p.extension().empty() ? ".exr" + : p.extension().string(); + return stem + "_selection" + ext; + } + + std::string export_selection_default_name(const ViewerState& viewer) + { + if (viewer.image.path.empty()) + return "selection_export.exr"; + std::filesystem::path p(viewer.image.path); + const std::string stem = p.stem().empty() ? "selection_export" + : p.stem().string(); + const std::string ext = p.extension().empty() ? ".exr" + : p.extension().string(); + return stem + "_selection_export" + ext; + } + + std::string save_window_default_name(const ViewerState& viewer) + { + if (viewer.image.path.empty()) + return "window.exr"; + std::filesystem::path p(viewer.image.path); + const std::string stem = p.stem().empty() ? "window" + : p.stem().string(); + const std::string ext = p.extension().empty() ? ".exr" + : p.extension().string(); + return stem + "_window" + ext; + } + + struct SaveDialogSpec { + const char* missing_image_error = "No image loaded to save"; + const char* missing_selection_error = nullptr; + const char* cancel_message = "Save cancelled"; + const char* failure_prefix = "save failed"; + std::string default_name; + }; + + SaveDialogSpec save_dialog_spec(SaveDialogKind kind, + const ViewerState& viewer) + { + SaveDialogSpec spec; + switch (kind) { + case SaveDialogKind::SaveImage: + spec.default_name = save_dialog_default_name(viewer); + return spec; + case SaveDialogKind::SaveWindow: + spec.cancel_message = "Save window cancelled"; + spec.default_name = save_window_default_name(viewer); + return spec; + case SaveDialogKind::SaveSelection: + spec.missing_selection_error = "No selection to save"; + spec.cancel_message = "Save selection cancelled"; + spec.default_name = save_selection_default_name(viewer); + return spec; + case SaveDialogKind::ExportSelection: + spec.missing_image_error = "No image loaded to export"; + spec.missing_selection_error = "No selection to export"; + spec.cancel_message = "Export selection cancelled"; + spec.failure_prefix = "export failed"; + spec.default_name = export_selection_default_name(viewer); + return spec; + } + spec.default_name = save_dialog_default_name(viewer); + return spec; + } + + bool run_save_dialog_operation(SaveDialogKind kind, ViewerState& viewer, + const PlaceholderUiState* ui_state, + const std::string& path, + std::string& error_message) + { + switch (kind) { + case SaveDialogKind::SaveImage: + return save_loaded_image(viewer.image, path, error_message); + case SaveDialogKind::SaveWindow: + if (ui_state == nullptr) { + error_message = "window save action is not configured"; + return false; + } + return save_window_image(viewer.image, viewer.recipe, *ui_state, + path, error_message); + case SaveDialogKind::SaveSelection: + return save_selection_image(viewer.image, viewer, path, + error_message); + case SaveDialogKind::ExportSelection: + if (ui_state == nullptr) { + error_message = "selection export action is not configured"; + return false; + } + return save_export_selection_image(viewer.image, viewer, *ui_state, + path, error_message); + } + error_message = "save action is not configured"; + return false; + } + + void set_save_dialog_success_message(SaveDialogKind kind, + ViewerState& viewer, + const std::string& path) + { + switch (kind) { + case SaveDialogKind::SaveImage: + viewer.status_message = Strutil::fmt::format("Saved {}", path); + break; + case SaveDialogKind::SaveWindow: + viewer.status_message = Strutil::fmt::format("Saved window {}", + path); + break; + case SaveDialogKind::SaveSelection: { + const int width = viewer.selection_xend - viewer.selection_xbegin; + const int height = viewer.selection_yend - viewer.selection_ybegin; + viewer.status_message + = Strutil::fmt::format("Saved selection {} ({}x{})", path, + width, height); + break; + } + case SaveDialogKind::ExportSelection: { + const int width = viewer.selection_xend - viewer.selection_xbegin; + const int height = viewer.selection_yend - viewer.selection_ybegin; + viewer.status_message + = Strutil::fmt::format("Exported selection {} ({}x{})", path, + width, height); + break; + } + } + viewer.last_error.clear(); + } + + void run_save_dialog_action(SaveDialogKind kind, ViewerState& viewer, + const PlaceholderUiState* ui_state) + { + const SaveDialogSpec spec = save_dialog_spec(kind, viewer); + if (viewer.image.path.empty()) { + viewer.last_error = spec.missing_image_error; + return; + } + if (spec.missing_selection_error != nullptr + && !has_image_selection(viewer)) { + viewer.last_error = spec.missing_selection_error; + return; + } + + const ImageLibraryState empty_library; + const std::string default_path + = open_dialog_default_path(viewer, empty_library); + FileDialog::DialogReply reply + = FileDialog::save_image_file(default_path, spec.default_name); + if (reply.result == FileDialog::Result::Okay) { + std::string error_message; + if (run_save_dialog_operation(kind, viewer, ui_state, reply.path, + error_message)) { + set_save_dialog_success_message(kind, viewer, reply.path); + return; + } + viewer.last_error = Strutil::fmt::format("{}: {}", + spec.failure_prefix, + error_message); + return; + } + if (reply.result == FileDialog::Result::Cancel) { + viewer.status_message = spec.cancel_message; + viewer.last_error.clear(); + return; + } + viewer.last_error = reply.message.empty() ? "Save dialog failed" + : reply.message; + } + +} // namespace + +void +save_as_dialog_action(ViewerState& viewer) +{ + run_save_dialog_action(SaveDialogKind::SaveImage, viewer, nullptr); +} + +void +save_window_as_dialog_action(ViewerState& viewer, + const PlaceholderUiState& ui_state) +{ + run_save_dialog_action(SaveDialogKind::SaveWindow, viewer, &ui_state); +} + +void +save_selection_as_dialog_action(ViewerState& viewer) +{ + run_save_dialog_action(SaveDialogKind::SaveSelection, viewer, nullptr); +} + +void +export_selection_as_dialog_action(ViewerState& viewer, + const PlaceholderUiState& ui_state) +{ + run_save_dialog_action(SaveDialogKind::ExportSelection, viewer, &ui_state); +} + +void +open_image_dialog_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, int requested_subimage, + int requested_miplevel) +{ + FileDialog::DialogReply reply = FileDialog::open_image_files( + open_dialog_default_path(viewer, library)); + if (reply.result == FileDialog::Result::Okay) { + if (reply.paths.empty() && !reply.path.empty()) + reply.paths.push_back(reply.path); + + int first_added_index = -1; + append_loaded_image_paths(library, reply.paths, &first_added_index); + sync_viewer_library_state(viewer, library); + + std::string target_path; + if (first_added_index >= 0 + && first_added_index + < static_cast(library.loaded_image_paths.size())) { + target_path = library.loaded_image_paths[static_cast( + first_added_index)]; + } else { + for (const std::string& path : reply.paths) { + if (!set_current_loaded_image_path(library, viewer, path)) + continue; + if (viewer.current_path_index < 0 + || viewer.current_path_index >= static_cast( + library.loaded_image_paths.size())) { + continue; + } + target_path = library.loaded_image_paths[static_cast( + viewer.current_path_index)]; + break; + } + } + + if (!target_path.empty()) { + load_viewer_image(renderer_state, viewer, library, &ui_state, + target_path, requested_subimage, + requested_miplevel); + } else { + viewer.last_error = "No selected image paths were accepted"; + } + } else if (reply.result == FileDialog::Result::Cancel) { + viewer.status_message = "Open cancelled"; + viewer.last_error.clear(); + } else { + viewer.last_error = reply.message; + } +} + +void +open_folder_dialog_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, + MultiViewWorkspace* workspace) +{ + FileDialog::DialogReply reply = FileDialog::open_folder( + open_dialog_default_path(viewer, library)); + if (reply.result == FileDialog::Result::Okay) { + if (!reply.path.empty()) { + open_directory_into_library(renderer_state, viewer, library, + ui_state, workspace, reply.path); + } else { + viewer.last_error = "No folder was selected"; + viewer.status_message.clear(); + } + } else if (reply.result == FileDialog::Result::Cancel) { + viewer.status_message = "Open folder cancelled"; + viewer.last_error.clear(); + } else { + viewer.last_error = reply.message; + viewer.status_message.clear(); + } +} + +bool +capture_main_viewport_screenshot_action(RendererState& renderer_state, + ViewerState& viewer, + std::string& out_path) +{ + out_path.clear(); + viewer.last_error.clear(); + + int width = std::max(0, renderer_state.framebuffer_width); + int height = std::max(0, renderer_state.framebuffer_height); + if (width <= 0 || height <= 0) { + viewer.last_error = "screenshot failed: main viewport size is invalid"; + return false; + } + + const std::filesystem::path output_path = default_screenshot_output_path(); + std::error_code ec; + std::filesystem::create_directories(output_path.parent_path(), ec); + if (ec) { + viewer.last_error = Strutil::fmt::format( + "screenshot failed: could not create output directory '{}': {}", + output_path.parent_path().string(), ec.message()); + return false; + } + + std::vector pixels(static_cast(width) + * static_cast(height)); + if (!renderer_screen_capture(ImGui::GetMainViewport()->ID, 0, 0, width, + height, pixels.data(), &renderer_state)) { + viewer.last_error = "screenshot failed: framebuffer readback failed"; + return false; + } + + ImageSpec spec(width, height, 4, TypeDesc::UINT8); + ImageBuf output(spec); + const unsigned char* bytes = reinterpret_cast( + pixels.data()); + if (!output.set_pixels(ROI::All(), TypeDesc::UINT8, bytes)) { + viewer.last_error = output.geterror().empty() + ? "screenshot failed: could not populate image" + : Strutil::fmt::format("screenshot failed: {}", + output.geterror()); + return false; + } + if (!output.write(output_path.string())) { + viewer.last_error = output.geterror().empty() + ? "screenshot failed: image write failed" + : Strutil::fmt::format("screenshot failed: {}", + output.geterror()); + return false; + } + + out_path = output_path.string(); + viewer.status_message = Strutil::fmt::format("Saved screenshot {}", + output_path.string()); + viewer.last_error.clear(); + return true; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_file_actions.h b/src/imiv/imiv_file_actions.h new file mode 100644 index 0000000000..0a694b1ed1 --- /dev/null +++ b/src/imiv/imiv_file_actions.h @@ -0,0 +1,39 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_renderer.h" +#include "imiv_viewer.h" + +#include + +namespace Imiv { + +void +save_as_dialog_action(ViewerState& viewer); +void +save_window_as_dialog_action(ViewerState& viewer, + const PlaceholderUiState& ui_state); +void +save_selection_as_dialog_action(ViewerState& viewer); +void +export_selection_as_dialog_action(ViewerState& viewer, + const PlaceholderUiState& ui_state); +void +open_image_dialog_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, int requested_subimage, + int requested_miplevel); +void +open_folder_dialog_action(RendererState& renderer_state, ViewerState& viewer, + ImageLibraryState& library, + PlaceholderUiState& ui_state, + MultiViewWorkspace* workspace); +bool +capture_main_viewport_screenshot_action(RendererState& renderer_state, + ViewerState& viewer, + std::string& out_path); + +} // namespace Imiv diff --git a/src/imiv/imiv_file_dialog.cpp b/src/imiv/imiv_file_dialog.cpp new file mode 100644 index 0000000000..4fb9a9492f --- /dev/null +++ b/src/imiv/imiv_file_dialog.cpp @@ -0,0 +1,292 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_file_dialog.h" + +#ifndef IMIV_HAS_NFD +# define IMIV_HAS_NFD 0 +#endif + +#if IMIV_HAS_NFD +# include +#endif + +namespace Imiv::FileDialog { + +namespace { + + NativeDialogScopeHook g_native_dialog_scope_hook = nullptr; + void* g_native_dialog_scope_user_data = nullptr; + constexpr const char* k_not_configured_message + = "nativefiledialog integration is not configured"; + + const char* env_value(const char* name) + { + const char* value = std::getenv(name); + return (value != nullptr && value[0] != '\0') ? value : nullptr; + } + + DialogReply cancel_reply() + { + return DialogReply { Result::Cancel, std::string(), {}, std::string() }; + } + + DialogReply unsupported_reply() + { + return DialogReply { + Result::Unsupported, std::string(), {}, k_not_configured_message + }; + } + + DialogReply okay_reply(const char* path) + { + DialogReply reply; + reply.result = Result::Okay; + if (path != nullptr && path[0] != '\0') + reply.path = path; + return reply; + } + + const char* optional_c_str(const std::string& value) + { + return value.empty() ? nullptr : value.c_str(); + } + + struct NativeDialogScopeGuard { + bool active = false; + NativeDialogScopeGuard() + { + if (g_native_dialog_scope_hook != nullptr) { + g_native_dialog_scope_hook(true, + g_native_dialog_scope_user_data); + active = true; + } + } + ~NativeDialogScopeGuard() + { + if (active && g_native_dialog_scope_hook != nullptr) + g_native_dialog_scope_hook(false, + g_native_dialog_scope_user_data); + } + }; + +} // namespace + +void +set_native_dialog_scope_hook(NativeDialogScopeHook hook, void* user_data) +{ + g_native_dialog_scope_hook = hook; + g_native_dialog_scope_user_data = user_data; +} + +#if IMIV_HAS_NFD + +namespace { + + const nfdu8filteritem_t k_image_filter[] = { + { "Image Files", + "avif,bmp,dpx,exr,gif,hdr,heic,jpg,jpeg,jxl,png,tif,tiff,tx,webp" } + }; + const nfdu8filteritem_t k_ocio_filter[] + = { { "OCIO Config Files", "ocio,ocioz" } }; + + struct NfdThreadGuard { + bool initialized = false; + NfdThreadGuard() { initialized = (NFD_Init() == NFD_OKAY); } + ~NfdThreadGuard() + { + if (initialized) + NFD_Quit(); + } + }; + + struct NfdDialogScope { + NativeDialogScopeGuard dialog_scope_guard; + NfdThreadGuard thread_guard; + bool initialized() const { return thread_guard.initialized; } + }; + + DialogReply map_error(const char* fallback_message) + { + DialogReply reply; + reply.result = Result::Error; + reply.message = fallback_message; + const char* err = NFD_GetError(); + if (err && err[0] != '\0') + reply.message = err; + return reply; + } + + DialogReply path_reply(nfdresult_t result, nfdu8char_t* selected_path, + const char* fallback_message) + { + if (result == NFD_OKAY) { + DialogReply reply = okay_reply(selected_path); + NFD_FreePathU8(selected_path); + return reply; + } + if (result == NFD_CANCEL) + return cancel_reply(); + return map_error(fallback_message); + } + +} // namespace + + + +bool +available() +{ + return true; +} + +DialogReply +open_image_files(const std::string& default_path) +{ + NfdDialogScope scope; + if (!scope.initialized()) + return map_error("nativefiledialog initialization failed"); + + nfdopendialogu8args_t args = {}; + args.filterList = k_image_filter; + args.filterCount = 1; + args.defaultPath = optional_c_str(default_path); + + const nfdpathset_t* selected_paths = nullptr; + nfdresult_t result = NFD_OpenDialogMultipleU8_With(&selected_paths, &args); + if (result == NFD_OKAY) { + DialogReply reply; + reply.result = Result::Okay; + nfdpathsetsize_t path_count = 0; + if (NFD_PathSet_GetCount(selected_paths, &path_count) == NFD_OKAY) { + reply.paths.reserve(static_cast(path_count)); + for (nfdpathsetsize_t i = 0; i < path_count; ++i) { + nfdu8char_t* selected_path = nullptr; + if (NFD_PathSet_GetPathU8(selected_paths, i, &selected_path) + != NFD_OKAY) { + continue; + } + if (selected_path != nullptr && selected_path[0] != '\0') + reply.paths.emplace_back(selected_path); + NFD_PathSet_FreePathU8(selected_path); + } + } + if (!reply.paths.empty()) + reply.path = reply.paths.front(); + NFD_PathSet_Free(selected_paths); + return reply; + } + if (result == NFD_CANCEL) + return cancel_reply(); + return map_error("nativefiledialog open dialog failed"); +} + +DialogReply +open_folder(const std::string& default_path) +{ + NfdDialogScope scope; + if (!scope.initialized()) + return map_error("nativefiledialog initialization failed"); + + nfdpickfolderu8args_t args = {}; + args.defaultPath = optional_c_str(default_path); + + nfdu8char_t* selected_path = nullptr; + nfdresult_t result = NFD_PickFolderU8_With(&selected_path, &args); + return path_reply(result, selected_path, + "nativefiledialog folder dialog failed"); +} + +DialogReply +open_ocio_config_file(const std::string& default_path) +{ + NfdDialogScope scope; + if (!scope.initialized()) + return map_error("nativefiledialog initialization failed"); + + nfdopendialogu8args_t args = {}; + args.filterList = k_ocio_filter; + args.filterCount = 1; + args.defaultPath = optional_c_str(default_path); + + nfdu8char_t* selected_path = nullptr; + nfdresult_t result = NFD_OpenDialogU8_With(&selected_path, &args); + return path_reply(result, selected_path, + "nativefiledialog OCIO config dialog failed"); +} + +DialogReply +save_image_file(const std::string& default_path, + const std::string& default_name) +{ + if (const char* override_path = env_value("IMIV_TEST_SAVE_IMAGE_PATH")) { + DialogReply reply; + reply.result = Result::Okay; + reply.path = override_path; + return reply; + } + + NfdDialogScope scope; + if (!scope.initialized()) + return map_error("nativefiledialog initialization failed"); + + nfdsavedialogu8args_t args = {}; + args.filterList = k_image_filter; + args.filterCount = 1; + args.defaultPath = optional_c_str(default_path); + args.defaultName = optional_c_str(default_name); + + nfdu8char_t* selected_path = nullptr; + nfdresult_t result = NFD_SaveDialogU8_With(&selected_path, &args); + return path_reply(result, selected_path, + "nativefiledialog save dialog failed"); +} + +#else + +bool +available() +{ + return false; +} + +DialogReply +open_image_files(const std::string& default_path) +{ + (void)default_path; + return unsupported_reply(); +} + +DialogReply +open_ocio_config_file(const std::string& default_path) +{ + (void)default_path; + return unsupported_reply(); +} + +DialogReply +open_folder(const std::string& default_path) +{ + (void)default_path; + return unsupported_reply(); +} + +DialogReply +save_image_file(const std::string& default_path, + const std::string& default_name) +{ + (void)default_path; + (void)default_name; + if (const char* override_path = env_value("IMIV_TEST_SAVE_IMAGE_PATH")) { + DialogReply reply; + reply.result = Result::Okay; + reply.path = override_path; + return reply; + } + return unsupported_reply(); +} + +#endif + +} // namespace Imiv::FileDialog diff --git a/src/imiv/imiv_file_dialog.h b/src/imiv/imiv_file_dialog.h new file mode 100644 index 0000000000..d34d111e73 --- /dev/null +++ b/src/imiv/imiv_file_dialog.h @@ -0,0 +1,37 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include + +namespace Imiv::FileDialog { + +enum class Result { Okay = 0, Cancel, Error, Unsupported }; + +struct DialogReply { + Result result = Result::Unsupported; + std::string path; + std::vector paths; + std::string message; +}; + +using NativeDialogScopeHook = void (*)(bool begin_scope, void* user_data); + +bool +available(); +void +set_native_dialog_scope_hook(NativeDialogScopeHook hook, void* user_data); +DialogReply +open_image_files(const std::string& default_path); +DialogReply +open_folder(const std::string& default_path); +DialogReply +open_ocio_config_file(const std::string& default_path); +DialogReply +save_image_file(const std::string& default_path, + const std::string& default_name); + +} // namespace Imiv::FileDialog diff --git a/src/imiv/imiv_frame.cpp b/src/imiv/imiv_frame.cpp new file mode 100644 index 0000000000..59f3566238 --- /dev/null +++ b/src/imiv/imiv_frame.cpp @@ -0,0 +1,372 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_frame.h" + +#include "imiv_actions.h" +#include "imiv_backend.h" +#include "imiv_developer_tools.h" +#include "imiv_drag_drop.h" +#include "imiv_frame_actions.h" +#include "imiv_image_view.h" +#include "imiv_menu.h" +#include "imiv_parse.h" +#include "imiv_test_engine.h" +#include "imiv_ui.h" +#include "imiv_ui_metrics.h" +#include "imiv_workspace_ui.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + constexpr const char* k_dockspace_host_title = "imiv DockSpace"; + + bool consume_focus_request(PlaceholderUiState& ui_state, + const char* window_name) + { + if (ui_state.focus_window_name == nullptr || window_name == nullptr) + return false; + if (std::strcmp(ui_state.focus_window_name, window_name) != 0) + return false; + ui_state.focus_window_name = nullptr; + return true; + } + + std::vector + collect_workspace_viewers(MultiViewWorkspace& workspace) + { + std::vector viewers; + viewers.reserve(workspace.view_windows.size()); + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr) + viewers.push_back(&view->viewer); + } + return viewers; + } + + std::string image_view_window_title(const ImageViewWindow& view, + bool primary) + { + if (primary) + return std::string(k_image_window_title); + return Strutil::fmt::format("Image {}###imiv_image_view_{}", view.id, + view.id); + } + +} // namespace + + + +ImGuiID +begin_main_dockspace_host() +{ + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos, ImGuiCond_Always); + ImGui::SetNextWindowSize(viewport->WorkSize, ImGuiCond_Always); + ImGui::SetNextWindowViewport(viewport->ID); + + ImGuiWindowFlags host_flags + = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse + | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove + | ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoBringToFrontOnFocus + | ImGuiWindowFlags_NoNavFocus; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin(k_dockspace_host_title, nullptr, host_flags); + ImGui::PopStyleVar(3); + + const ImGuiID dockspace_id = ImGui::GetID("imiv.main.dockspace"); + ImGui::DockSpace(dockspace_id, ImVec2(0.0f, 0.0f), ImGuiDockNodeFlags_None); + ImGui::End(); + return dockspace_id; +} + + + +void +setup_image_window_policy(ImGuiID dockspace_id, bool force_dock) +{ + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowSize(viewport->WorkSize, ImGuiCond_FirstUseEver); + ImGui::SetNextWindowDockID(dockspace_id, force_dock + ? ImGuiCond_Always + : ImGuiCond_FirstUseEver); + + ImGuiWindowClass window_class; + window_class.ClassId = ImGui::GetID("imiv.image.window"); + window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_AutoHideTabBar + | ImGuiDockNodeFlags_NoUndocking; + ImGui::SetNextWindowClass(&window_class); +} + + + +void +draw_viewer_ui(MultiViewWorkspace& workspace, ImageLibraryState& library, + PlaceholderUiState& ui_state, DeveloperUiState& developer_ui, + const AppFonts& fonts, bool& request_exit +#if defined(IMGUI_ENABLE_TEST_ENGINE) + , + bool* show_test_engine_windows +#endif +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + , + GLFWwindow* window, RendererState& vk_state +#endif +) +{ + ImageViewWindow& primary_view = ensure_primary_image_view(workspace); + apply_test_engine_view_activation_override(workspace); + ImageViewWindow* active_view_window = active_image_view(workspace); + if (active_view_window == nullptr) + active_view_window = &primary_view; + const int frame_active_view_id = active_view_window->id; + ViewerState& viewer = active_view_window->viewer; + apply_view_recipe_to_ui_state(viewer.recipe, ui_state); + reset_layout_dump_synthetic_items(); + reset_test_engine_mouse_space(); + ViewerFrameActions actions; + std::vector workspace_viewers = collect_workspace_viewers( + workspace); + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + if (window != nullptr) { + std::string fullscreen_error; + set_full_screen_mode(window, viewer, ui_state.full_screen_mode, + fullscreen_error); + if (!fullscreen_error.empty()) { + viewer.last_error = fullscreen_error; + ui_state.full_screen_mode = viewer.fullscreen_applied; + } + } +#endif + + if (env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_SHOW_DRAG_OVERLAY")) + primary_view.viewer.drag_overlay_active = true; + apply_test_engine_ocio_overrides(ui_state); + apply_test_engine_view_recipe_overrides(ui_state); + set_area_sample_enabled(viewer, ui_state, ui_state.show_area_probe_window); + update_image_list_visibility_policy(workspace, library); + + collect_viewer_shortcuts(viewer, ui_state, developer_ui, actions, + request_exit); +#if defined(IMGUI_ENABLE_TEST_ENGINE) + const bool show_test_menu = show_test_engine_windows != nullptr + && env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_SHOW_MENU"); + draw_viewer_main_menu(viewer, ui_state, library, workspace_viewers, + developer_ui, actions, request_exit, + workspace.show_image_list_window, + workspace.image_list_request_focus, show_test_menu, + show_test_engine_windows); +#else + draw_viewer_main_menu(viewer, ui_state, library, workspace_viewers, + developer_ui, actions, request_exit, + workspace.show_image_list_window, + workspace.image_list_request_focus); +#endif + begin_developer_screenshot_request(developer_ui, viewer); + execute_viewer_frame_actions(viewer, ui_state, library, &workspace, actions +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + , + window, vk_state +#endif + ); +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + if (&primary_view.viewer != &viewer + && !primary_view.viewer.pending_drop_paths.empty()) { + viewer.pending_drop_paths.swap(primary_view.viewer.pending_drop_paths); + primary_view.viewer.drag_overlay_active = false; + } + apply_test_engine_drop_overrides(viewer); + process_pending_drop_paths(vk_state, viewer, library, ui_state); + sync_workspace_library_state(workspace, library); + (void)apply_pending_auto_subimage_action(vk_state, viewer, library, + ui_state); +#endif + clamp_placeholder_ui_state(ui_state); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, + UiMetrics::kAppFramePadding); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, UiMetrics::kAppItemSpacing); + + if (!viewer.image.path.empty()) { + ui_state.subimage_index = viewer.image.subimage; + ui_state.miplevel_index = viewer.image.miplevel; + } else { + ui_state.subimage_index = 0; + ui_state.miplevel_index = 0; + } + + const ImGuiID main_dockspace_id = begin_main_dockspace_host(); + if (actions.reset_windows_requested) { + reset_window_layouts(workspace, ui_state, main_dockspace_id); + } + ensure_image_list_default_layout(workspace, main_dockspace_id); + ImGuiWindowFlags main_window_flags = ImGuiWindowFlags_NoCollapse + | ImGuiWindowFlags_NoScrollbar + | ImGuiWindowFlags_NoScrollWithMouse; + for (size_t i = 0, e = workspace.view_windows.size(); i < e; ++i) { + ImageViewWindow& image_view = *workspace.view_windows[i]; + const bool primary = (image_view.id == primary_view.id); + const bool active = (image_view.id == workspace.active_view_id); + const ImGuiID image_dock_id = workspace.image_view_dock_id != 0 + ? workspace.image_view_dock_id + : main_dockspace_id; + const bool force_dock = primary ? ui_state.image_window_force_dock + : image_view.force_dock; + setup_image_window_policy(image_dock_id, force_dock); + if (image_view.request_focus) { + ImGui::SetNextWindowFocus(); + image_view.request_focus = false; + } + const std::string title = image_view_window_title(image_view, primary); + PlaceholderUiState view_ui_state = ui_state; + apply_view_recipe_to_ui_state(image_view.viewer.recipe, view_ui_state); + clamp_placeholder_ui_state(view_ui_state); + if (!image_view.viewer.image.path.empty()) { + PreviewControls preview_controls = {}; + preview_controls.exposure = view_ui_state.exposure; + preview_controls.gamma = view_ui_state.gamma; + preview_controls.offset = view_ui_state.offset; + preview_controls.color_mode = view_ui_state.color_mode; + preview_controls.channel = view_ui_state.current_channel; + preview_controls.use_ocio = view_ui_state.use_ocio ? 1 : 0; + preview_controls.orientation = image_view.viewer.image.orientation; + preview_controls.linear_interpolation + = view_ui_state.linear_interpolation ? 1 : 0; + std::string preview_error; + if (!renderer_update_preview_texture( + vk_state, image_view.viewer.texture, + &image_view.viewer.image, view_ui_state, preview_controls, + preview_error)) { + if (!preview_error.empty()) + image_view.viewer.last_error = preview_error; + } + } + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + bool open = image_view.open; + ImGui::Begin(title.c_str(), primary ? nullptr : &open, + main_window_flags); + ImGui::PopStyleVar(); + image_view.open = open; + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) + || ImGui::IsWindowHovered( + ImGuiHoveredFlags_AllowWhenBlockedByActiveItem)) { + workspace.active_view_id = image_view.id; + } + image_view.is_docked = ImGui::IsWindowDocked(); + if (primary) { + view_ui_state.image_window_force_dock = !image_view.is_docked; + } else { + image_view.force_dock = !image_view.is_docked; + } + draw_image_window_contents(image_view.viewer, view_ui_state, fonts, + active ? actions.pending_zoom + : PendingZoomRequest(), + active && actions.recenter_requested); + ImGui::End(); + if (active) { + ui_state.fit_image_to_window = view_ui_state.fit_image_to_window; + ui_state.image_window_force_dock + = view_ui_state.image_window_force_dock; + } + } + + for (const std::unique_ptr& image_view : + workspace.view_windows) { + if (image_view == nullptr || image_view->open + || image_view->id == primary_view.id) { + continue; + } + std::string ignored_error; + renderer_quiesce_texture_preview_submission(vk_state, + image_view->viewer.texture, + ignored_error); + renderer_destroy_texture(vk_state, image_view->viewer.texture); + } + erase_closed_image_views(workspace); + active_view_window = active_image_view(workspace); + if (active_view_window == nullptr) + active_view_window = &ensure_primary_image_view(workspace); + if (active_view_window->id != frame_active_view_id) { + apply_view_recipe_to_ui_state(active_view_window->viewer.recipe, + ui_state); + } + apply_test_engine_image_list_visibility_override(workspace); + draw_image_list_window(workspace, library, active_view_window->viewer, + ui_state, vk_state, actions.reset_windows_requested); + if (consume_focus_request(ui_state, k_info_window_title)) + ImGui::SetNextWindowFocus(); + draw_info_window(active_view_window->viewer, ui_state.show_info_window, + actions.reset_windows_requested); + if (consume_focus_request(ui_state, k_preferences_window_title)) + ImGui::SetNextWindowFocus(); + draw_preferences_window(ui_state, ui_state.show_preferences_window, + renderer_active_backend(vk_state), + actions.reset_windows_requested); + if (consume_focus_request(ui_state, k_preview_window_title)) + ImGui::SetNextWindowFocus(); + draw_preview_window(ui_state, ui_state.show_preview_window, + actions.reset_windows_requested); + capture_view_recipe_from_ui_state(ui_state, + active_view_window->viewer.recipe); + clamp_view_recipe(active_view_window->viewer.recipe); + apply_view_recipe_to_ui_state(active_view_window->viewer.recipe, ui_state); + + if (ui_state.show_about_window) { + const ImGuiViewport* main_viewport = ImGui::GetMainViewport(); + if (main_viewport != nullptr) { + ImGui::SetNextWindowPos(ImVec2(main_viewport->GetCenter().x, + main_viewport->GetCenter().y), + ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + } + if (consume_focus_request(ui_state, k_about_window_title)) + ImGui::SetNextWindowFocus(); + if (ImGui::Begin(k_about_window_title, &ui_state.show_about_window, + ImGuiWindowFlags_AlwaysAutoResize + | ImGuiWindowFlags_NoDocking)) { + ImGui::TextUnformatted("imiv is the image viewer for OpenImageIO."); + register_layout_dump_synthetic_item("text", "About imiv title"); + ImGui::Separator(); + ImGui::TextUnformatted( + "(c) Copyright Contributors to the OpenImageIO project."); + ImGui::TextUnformatted("See https://openimageio.org for details."); + ImGui::TextUnformatted("Dear ImGui port of iv."); + register_layout_dump_synthetic_item("text", "About imiv body"); + if (ImGui::Button("OK") || ImGui::IsKeyPressed(ImGuiKey_Enter) + || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) { + ui_state.show_about_window = false; + } + ImGui::SetItemDefaultFocus(); + } + ImGui::End(); + } + + draw_developer_windows(developer_ui); + draw_drag_drop_overlay(primary_view.viewer); + ImGui::PopStyleVar(2); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_frame.h b/src/imiv/imiv_frame.h new file mode 100644 index 0000000000..49f495ae97 --- /dev/null +++ b/src/imiv/imiv_frame.h @@ -0,0 +1,36 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_renderer.h" +#include "imiv_ui.h" + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +struct GLFWwindow; +#endif + +namespace Imiv { + +struct DeveloperUiState; + +inline constexpr const char* k_image_window_title = "Image"; + +void +draw_viewer_ui(MultiViewWorkspace& workspace, ImageLibraryState& library, + PlaceholderUiState& ui_state, DeveloperUiState& developer_ui, + const AppFonts& fonts, bool& request_exit +#if defined(IMGUI_ENABLE_TEST_ENGINE) + , + bool* show_test_engine_windows +#endif +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + , + GLFWwindow* window, RendererState& renderer_state +#endif +); + +} // namespace Imiv diff --git a/src/imiv/imiv_frame_actions.h b/src/imiv/imiv_frame_actions.h new file mode 100644 index 0000000000..63b9974a06 --- /dev/null +++ b/src/imiv/imiv_frame_actions.h @@ -0,0 +1,66 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_navigation.h" +#include "imiv_renderer.h" +#include "imiv_types.h" +#include "imiv_viewer.h" + +#include + +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) +struct GLFWwindow; +#endif + +namespace Imiv { + +struct ViewerFrameActions { + bool open_requested = false; + bool open_folder_requested = false; + bool save_as_requested = false; + bool clear_recent_requested = false; + bool reload_requested = false; + bool close_requested = false; + bool prev_requested = false; + bool next_requested = false; + bool toggle_requested = false; + bool prev_subimage_requested = false; + bool next_subimage_requested = false; + bool prev_mip_requested = false; + bool next_mip_requested = false; + bool save_window_as_requested = false; + bool save_selection_as_requested = false; + bool export_selection_as_requested = false; + bool select_all_requested = false; + bool deselect_selection_requested = false; + bool fit_window_to_image_requested = false; + bool recenter_requested = false; + bool new_view_requested = false; + bool reset_windows_requested = false; + bool delete_from_disk_requested = false; + bool full_screen_toggle_requested = false; + bool rotate_left_requested = false; + bool rotate_right_requested = false; + bool flip_horizontal_requested = false; + bool flip_vertical_requested = false; + PendingZoomRequest pending_zoom; + std::string recent_open_path; +}; + +void +execute_viewer_frame_actions(ViewerState& viewer, PlaceholderUiState& ui_state, + ImageLibraryState& library, + MultiViewWorkspace* workspace, + ViewerFrameActions& actions +#if defined(IMIV_BACKEND_VULKAN_GLFW) || defined(IMIV_BACKEND_METAL_GLFW) \ + || defined(IMIV_BACKEND_OPENGL_GLFW) + , + GLFWwindow* window, RendererState& renderer_state +#endif +); + +} // namespace Imiv diff --git a/src/imiv/imiv_image_library.cpp b/src/imiv/imiv_image_library.cpp new file mode 100644 index 0000000000..013b705285 --- /dev/null +++ b/src/imiv/imiv_image_library.cpp @@ -0,0 +1,473 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_image_library.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace OIIO; + +namespace Imiv { +namespace { + + constexpr size_t k_max_recent_images = 16; + + std::unordered_set build_readable_image_extensions() + { + std::unordered_set result; + const auto extension_map = get_extension_map(); + const auto input_formats + = Strutil::splits(get_string_attribute("input_format_list"), ","); + std::unordered_set readable_formats; + readable_formats.reserve(input_formats.size()); + for (std::string format_name : input_formats) { + Strutil::to_lower(format_name); + if (!format_name.empty()) + readable_formats.insert(std::move(format_name)); + } + + for (const auto& entry : extension_map) { + std::string format_name = entry.first; + Strutil::to_lower(format_name); + if (readable_formats.find(format_name) == readable_formats.end()) + continue; + for (std::string one_ext : entry.second) { + Strutil::to_lower(one_ext); + if (one_ext.empty()) + continue; + if (one_ext.front() != '.') + one_ext.insert(one_ext.begin(), '.'); + result.insert(std::move(one_ext)); + } + } + return result; + } + + const std::unordered_set& readable_image_extensions() + { + static const std::unordered_set readable_extensions + = build_readable_image_extensions(); + return readable_extensions; + } + + bool has_supported_image_extension(const std::filesystem::path& path) + { + std::string ext = path.extension().string(); + Strutil::to_lower(ext); + if (ext.empty()) + return false; + return readable_image_extensions().find(ext) + != readable_image_extensions().end(); + } + + bool datetime_to_time_t(std::string_view datetime, std::time_t& out_time) + { + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int min = 0; + int sec = 0; + if (!Strutil::scan_datetime(datetime, year, month, day, hour, min, sec)) + return false; + + std::tm tm_value = {}; + tm_value.tm_sec = sec; + tm_value.tm_min = min; + tm_value.tm_hour = hour; + tm_value.tm_mday = day; + tm_value.tm_mon = month - 1; + tm_value.tm_year = year - 1900; + out_time = std::mktime(&tm_value); + return out_time != static_cast(-1); + } + + bool file_last_write_time(const std::string& path, std::time_t& out_time) + { + std::error_code ec; + const auto file_time = std::filesystem::last_write_time(path, ec); + if (ec) + return false; + const auto system_time + = std::chrono::time_point_cast( + file_time - std::filesystem::file_time_type::clock::now() + + std::chrono::system_clock::now()); + out_time = std::chrono::system_clock::to_time_t(system_time); + return true; + } + + bool image_datetime(const std::string& path, std::time_t& out_time) + { + auto input = ImageInput::open(path); + if (!input) + return false; + const ImageSpec spec = input->spec(); + input->close(); + + const std::string datetime = spec.get_string_attribute("DateTime"); + if (datetime.empty()) + return false; + return datetime_to_time_t(datetime, out_time); + } + + std::string normalize_path_for_viewer_list(const std::string& path) + { + if (path.empty()) + return std::string(); + std::filesystem::path p(path); + std::error_code ec; + if (!p.is_absolute()) { + const std::filesystem::path abs = std::filesystem::absolute(p, ec); + if (!ec) + p = abs; + } + return p.lexically_normal().string(); + } + + int find_path_index(const std::vector& paths, + const std::string& path) + { + if (path.empty()) + return -1; + const auto it = std::find(paths.begin(), paths.end(), path); + if (it == paths.end()) + return -1; + return static_cast(std::distance(paths.begin(), it)); + } + + std::string filename_key(const std::string& path) + { + return std::filesystem::path(path).filename().string(); + } + + std::string path_key(const std::string& path) + { + return std::filesystem::path(path).lexically_normal().string(); + } + + bool sort_path_by_name(const std::string& a, const std::string& b) + { + const std::string a_name = filename_key(a); + const std::string b_name = filename_key(b); + if (a_name == b_name) + return path_key(a) < path_key(b); + return a_name < b_name; + } + + bool sort_path_by_path(const std::string& a, const std::string& b) + { + return path_key(a) < path_key(b); + } + + bool sort_path_by_image_date(const std::string& a, const std::string& b) + { + std::time_t a_time = {}; + std::time_t b_time = {}; + const bool a_ok = image_datetime(a, a_time) + || file_last_write_time(a, a_time); + const bool b_ok = image_datetime(b, b_time) + || file_last_write_time(b, b_time); + if (a_ok != b_ok) + return a_ok; + if (!a_ok && !b_ok) + return filename_key(a) < filename_key(b); + if (a_time == b_time) + return filename_key(a) < filename_key(b); + return a_time < b_time; + } + + bool sort_path_by_file_date(const std::string& a, const std::string& b) + { + std::time_t a_time = {}; + std::time_t b_time = {}; + const bool a_ok = file_last_write_time(a, a_time); + const bool b_ok = file_last_write_time(b, b_time); + if (a_ok != b_ok) + return a_ok; + if (!a_ok && !b_ok) + return filename_key(a) < filename_key(b); + if (a_time == b_time) + return filename_key(a) < filename_key(b); + return a_time < b_time; + } + + void sort_image_path_list(std::vector& paths, + ImageSortMode sort_mode, bool sort_reverse) + { + if (paths.empty()) + return; + + switch (sort_mode) { + case ImageSortMode::ByName: + std::sort(paths.begin(), paths.end(), sort_path_by_name); + break; + case ImageSortMode::ByPath: + std::sort(paths.begin(), paths.end(), sort_path_by_path); + break; + case ImageSortMode::ByImageDate: + std::sort(paths.begin(), paths.end(), sort_path_by_image_date); + break; + case ImageSortMode::ByFileDate: + std::sort(paths.begin(), paths.end(), sort_path_by_file_date); + break; + } + + if (sort_reverse) + std::reverse(paths.begin(), paths.end()); + } + +} // namespace + +bool +collect_directory_image_paths(const std::string& directory_path, + ImageSortMode sort_mode, bool sort_reverse, + std::vector& out_paths, + std::string& error_message) +{ + out_paths.clear(); + error_message.clear(); + + std::error_code ec; + std::filesystem::path dir(directory_path); + if (dir.empty()) { + error_message = "directory path is empty"; + return false; + } + if (!std::filesystem::exists(dir, ec) || ec) { + error_message = Strutil::fmt::format("directory '{}' does not exist", + dir.string()); + return false; + } + if (!std::filesystem::is_directory(dir, ec) || ec) { + error_message = Strutil::fmt::format("'{}' is not a directory", + dir.string()); + return false; + } + + std::filesystem::directory_iterator it( + dir, std::filesystem::directory_options::skip_permission_denied, ec); + if (ec) { + error_message = Strutil::fmt::format("failed to scan '{}': {}", + dir.string(), ec.message()); + return false; + } + + for (; it != std::filesystem::directory_iterator(); it.increment(ec)) { + if (ec) { + ec.clear(); + continue; + } + const std::filesystem::directory_entry& entry = *it; + if (!entry.is_regular_file(ec)) { + ec.clear(); + continue; + } + if (!has_supported_image_extension(entry.path())) + continue; + out_paths.push_back( + normalize_path_for_viewer_list(entry.path().string())); + } + + sort_image_path_list(out_paths, sort_mode, sort_reverse); + return true; +} + +bool +add_loaded_image_path(ImageLibraryState& library, const std::string& path, + int* out_index) +{ + if (out_index != nullptr) + *out_index = -1; + const std::string normalized = normalize_path_for_viewer_list(path); + if (normalized.empty()) + return false; + + int index = find_path_index(library.loaded_image_paths, normalized); + if (index < 0) { + library.loaded_image_paths.push_back(normalized); + index = static_cast(library.loaded_image_paths.size()) - 1; + } + if (out_index != nullptr) + *out_index = index; + return index >= 0; +} + +bool +append_loaded_image_paths(ImageLibraryState& library, + const std::vector& paths, + int* out_first_added_index) +{ + if (out_first_added_index != nullptr) + *out_first_added_index = -1; + + bool added_any = false; + std::string first_added_path; + for (const std::string& path : paths) { + const std::string normalized = normalize_path_for_viewer_list(path); + if (normalized.empty()) + continue; + if (find_path_index(library.loaded_image_paths, normalized) >= 0) + continue; + library.loaded_image_paths.push_back(normalized); + if (first_added_path.empty()) + first_added_path = normalized; + added_any = true; + } + + if (!added_any) + return false; + + if (out_first_added_index != nullptr && !first_added_path.empty()) { + *out_first_added_index = find_path_index(library.loaded_image_paths, + first_added_path); + } + return true; +} + +bool +remove_loaded_image_path(ImageLibraryState& library, ViewerState* viewer, + const std::string& path) +{ + const std::string normalized = normalize_path_for_viewer_list(path); + const int remove_index = find_path_index(library.loaded_image_paths, + normalized); + if (remove_index < 0) + return false; + + library.loaded_image_paths.erase(library.loaded_image_paths.begin() + + remove_index); + if (viewer != nullptr) { + if (viewer->current_path_index == remove_index) { + viewer->current_path_index = -1; + } else if (viewer->current_path_index > remove_index) { + --viewer->current_path_index; + } + + if (viewer->last_path_index == remove_index) { + viewer->last_path_index = -1; + } else if (viewer->last_path_index > remove_index) { + --viewer->last_path_index; + } + } + return true; +} + +bool +set_current_loaded_image_path(const ImageLibraryState& library, + ViewerState& viewer, const std::string& path) +{ + const std::string normalized = normalize_path_for_viewer_list(path); + const int new_index = find_path_index(library.loaded_image_paths, + normalized); + if (new_index < 0) + return false; + if (viewer.current_path_index >= 0 + && viewer.current_path_index != new_index) { + viewer.last_path_index = viewer.current_path_index; + } + viewer.current_path_index = new_index; + return true; +} + +bool +pick_loaded_image_path(const ImageLibraryState& library, + const ViewerState& viewer, int delta, + std::string& out_path) +{ + out_path.clear(); + if (library.loaded_image_paths.empty() || viewer.current_path_index < 0) + return false; + const int count = static_cast(library.loaded_image_paths.size()); + int index = viewer.current_path_index + delta; + while (index < 0) + index += count; + index %= count; + out_path = library.loaded_image_paths[static_cast(index)]; + return !out_path.empty(); +} + +void +sort_loaded_image_paths(ImageLibraryState& library, + const std::vector& viewers) +{ + std::vector current_paths; + std::vector last_paths; + current_paths.reserve(viewers.size()); + last_paths.reserve(viewers.size()); + for (const ViewerState* viewer : viewers) { + current_paths.emplace_back(viewer != nullptr ? viewer->image.path + : std::string()); + const std::string last_path + = (viewer != nullptr && viewer->last_path_index >= 0 + && viewer->last_path_index + < static_cast(library.loaded_image_paths.size())) + ? library.loaded_image_paths[static_cast( + viewer->last_path_index)] + : std::string(); + last_paths.emplace_back(last_path); + } + + sort_image_path_list(library.loaded_image_paths, library.sort_mode, + library.sort_reverse); + for (size_t i = 0, e = viewers.size(); i < e; ++i) { + ViewerState* viewer = viewers[i]; + if (viewer == nullptr) + continue; + viewer->current_path_index + = find_path_index(library.loaded_image_paths, + normalize_path_for_viewer_list(current_paths[i])); + viewer->last_path_index + = find_path_index(library.loaded_image_paths, + normalize_path_for_viewer_list(last_paths[i])); + } +} + +void +sync_viewer_library_state(ViewerState& viewer, const ImageLibraryState& library) +{ + const std::string current_path = viewer.image.path; + const std::string last_path + = (viewer.last_path_index >= 0 + && viewer.last_path_index + < static_cast(viewer.loaded_image_paths.size())) + ? viewer.loaded_image_paths[static_cast( + viewer.last_path_index)] + : std::string(); + viewer.loaded_image_paths = library.loaded_image_paths; + viewer.recent_images = library.recent_images; + viewer.sort_mode = library.sort_mode; + viewer.sort_reverse = library.sort_reverse; + viewer.current_path_index + = find_path_index(viewer.loaded_image_paths, + normalize_path_for_viewer_list(current_path)); + viewer.last_path_index + = find_path_index(viewer.loaded_image_paths, + normalize_path_for_viewer_list(last_path)); +} + +void +add_recent_image_path(ImageLibraryState& library, const std::string& path) +{ + const std::string normalized = normalize_path_for_viewer_list(path); + if (normalized.empty()) + return; + + auto it = std::remove(library.recent_images.begin(), + library.recent_images.end(), normalized); + library.recent_images.erase(it, library.recent_images.end()); + library.recent_images.insert(library.recent_images.begin(), normalized); + if (library.recent_images.size() > k_max_recent_images) + library.recent_images.resize(k_max_recent_images); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_image_library.h b/src/imiv/imiv_image_library.h new file mode 100644 index 0000000000..0259be57ae --- /dev/null +++ b/src/imiv/imiv_image_library.h @@ -0,0 +1,42 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_viewer.h" + +namespace Imiv { + +bool +collect_directory_image_paths(const std::string& directory_path, + ImageSortMode sort_mode, bool sort_reverse, + std::vector& out_paths, + std::string& error_message); +bool +add_loaded_image_path(ImageLibraryState& library, const std::string& path, + int* out_index = nullptr); +bool +append_loaded_image_paths(ImageLibraryState& library, + const std::vector& paths, + int* out_first_added_index = nullptr); +bool +remove_loaded_image_path(ImageLibraryState& library, ViewerState* viewer, + const std::string& path); +bool +set_current_loaded_image_path(const ImageLibraryState& library, + ViewerState& viewer, const std::string& path); +bool +pick_loaded_image_path(const ImageLibraryState& library, + const ViewerState& viewer, int delta, + std::string& out_path); +void +sort_loaded_image_paths(ImageLibraryState& library, + const std::vector& viewers); +void +sync_viewer_library_state(ViewerState& viewer, + const ImageLibraryState& library); +void +add_recent_image_path(ImageLibraryState& library, const std::string& path); + +} // namespace Imiv diff --git a/src/imiv/imiv_image_view.cpp b/src/imiv/imiv_image_view.cpp new file mode 100644 index 0000000000..745ed21b9c --- /dev/null +++ b/src/imiv/imiv_image_view.cpp @@ -0,0 +1,490 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_image_view.h" + +#include "imiv_actions.h" +#include "imiv_parse.h" +#include "imiv_probe_overlay.h" +#include "imiv_renderer.h" +#include "imiv_test_engine.h" +#include "imiv_ui_metrics.h" + +#include +#include +#include +#include + +#include + +#include + +namespace Imiv { + +namespace { + + bool apply_forced_probe_from_env(ViewerState& viewer) + { + int forced_x = -1; + int forced_y = -1; + if (!env_read_int_value("IMIV_IMGUI_TEST_ENGINE_PROBE_X", forced_x) + || !env_read_int_value("IMIV_IMGUI_TEST_ENGINE_PROBE_Y", forced_y) + || forced_x < 0 || forced_y < 0 || viewer.image.path.empty()) { + return false; + } + + const int px = std::clamp(forced_x, 0, + std::max(0, viewer.image.width - 1)); + const int py = std::clamp(forced_y, 0, + std::max(0, viewer.image.height - 1)); + std::vector sampled; + if (!sample_loaded_pixel(viewer.image, px, py, sampled)) + return false; + + viewer.probe_valid = true; + viewer.probe_x = px; + viewer.probe_y = py; + viewer.probe_channels = std::move(sampled); + return true; + } + + void current_child_visible_rect(const ImVec2& padding, bool scroll_x, + bool scroll_y, ImVec2& out_min, + ImVec2& out_max) + { + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 window_size = ImGui::GetWindowSize(); + const ImGuiStyle& style = ImGui::GetStyle(); + out_min = ImVec2(window_pos.x + padding.x, window_pos.y + padding.y); + out_max = ImVec2(window_pos.x + window_size.x - padding.x, + window_pos.y + window_size.y - padding.y); + if (scroll_y) + out_max.x -= style.ScrollbarSize; + if (scroll_x) + out_max.y -= style.ScrollbarSize; + out_max.x = std::max(out_min.x, out_max.x); + out_max.y = std::max(out_min.y, out_max.y); + } + +} // namespace + +void +draw_image_window_contents(ViewerState& viewer, PlaceholderUiState& ui_state, + const AppFonts& fonts, + const PendingZoomRequest& shortcut_zoom_request, + bool recenter_requested) +{ + const float status_bar_height + = std::max(UiMetrics::StatusBar::kMinHeight, + ImGui::GetTextLineHeightWithSpacing() + + ImGui::GetStyle().FramePadding.y * 2.0f + + UiMetrics::StatusBar::kExtraHeight); + ImVec2 content_avail = ImGui::GetContentRegionAvail(); + const float viewport_h = std::max(UiMetrics::ImageView::kViewportMinHeight, + content_avail.y - status_bar_height); + + const ImVec2 viewport_padding(0.0f, 0.0f); + ImageViewportLayout image_layout; + int display_width = 0; + int display_height = 0; + if (!viewer.image.path.empty()) { + const ImVec2 child_size(content_avail.x, viewport_h); + if (viewer.last_viewport_size.x > 0.0f + && viewer.last_viewport_size.y > 0.0f + && (std::abs(child_size.x - viewer.last_viewport_size.x) > 0.5f + || std::abs(child_size.y - viewer.last_viewport_size.y) + > 0.5f)) { + viewer.scroll_sync_frames_left + = std::max(viewer.scroll_sync_frames_left, 2); + } + display_width = viewer.image.width; + display_height = viewer.image.height; + oriented_image_dimensions(viewer.image, display_width, display_height); + if ((viewer.fit_request || ui_state.fit_image_to_window) + && display_width > 0 && display_height > 0) { + viewer.zoom = compute_fit_zoom(child_size, viewport_padding, + display_width, display_height); + viewer.zoom_pivot_pending = false; + viewer.zoom_pivot_frames_left = 0; + viewer.norm_scroll = ImVec2(0.5f, 0.5f); + viewer.fit_request = false; + viewer.scroll_sync_frames_left + = std::max(viewer.scroll_sync_frames_left, 2); + } + + const ImVec2 image_size(static_cast(display_width) * viewer.zoom, + static_cast(display_height) + * viewer.zoom); + if (recenter_requested) + recenter_view(viewer, image_size); + image_layout + = compute_image_viewport_layout(ImVec2(content_avail.x, viewport_h), + viewport_padding, image_size, + ImGui::GetStyle().ScrollbarSize); + sync_view_scroll_from_display_scroll( + viewer, + ImVec2(viewer.norm_scroll.x * image_size.x, + viewer.norm_scroll.y * image_size.y), + image_size); + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, viewport_padding); + if (!viewer.image.path.empty() + && (image_layout.scroll_x || image_layout.scroll_y)) { + ImGui::SetNextWindowContentSize(image_layout.content_size); + if (viewer.scroll_sync_frames_left > 0) + ImGui::SetNextWindowScroll(viewer.scroll); + } + ImGui::BeginChild("Viewport", ImVec2(0.0f, viewport_h), false, + ImGuiWindowFlags_HorizontalScrollbar + | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + + if (!viewer.last_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 90, 90, 255)); + ImGui::TextWrapped("%s", viewer.last_error.c_str()); + ImGui::PopStyleColor(); + register_layout_dump_synthetic_item("text", viewer.last_error.c_str()); + } + + const bool has_image = !viewer.image.path.empty(); + if (has_image) { + if (ui_state.show_area_probe_window && viewer.area_probe_lines.empty()) + reset_area_probe_overlay(viewer); + if (!ui_state.show_area_probe_window) + viewer.area_probe_drag_active = false; + + const ImVec2 image_size = image_layout.image_size; + ImTextureRef main_texture_ref; + ImTextureRef closeup_texture_ref; + bool has_main_texture = false; + bool has_closeup_texture = false; + bool image_canvas_hovered = false; + bool image_canvas_active = false; + PendingZoomRequest pending_zoom = shortcut_zoom_request; + renderer_get_viewer_texture_refs(viewer, ui_state, main_texture_ref, + has_main_texture, closeup_texture_ref, + has_closeup_texture); + ImageCoordinateMap coord_map; + coord_map.source_width = viewer.image.width; + coord_map.source_height = viewer.image.height; + coord_map.orientation = viewer.image.orientation; + current_child_visible_rect(viewport_padding, image_layout.scroll_x, + image_layout.scroll_y, + coord_map.viewport_rect_min, + coord_map.viewport_rect_max); + const ImVec2 viewport_center = rect_center(coord_map.viewport_rect_min, + coord_map.viewport_rect_max); + const bool can_scroll_x = image_layout.scroll_x; + const bool can_scroll_y = image_layout.scroll_y; + if (viewer.zoom_pivot_pending || viewer.zoom_pivot_frames_left > 0) { + apply_pending_zoom_pivot(viewer, coord_map, image_size, + can_scroll_x, can_scroll_y); + } else if (viewer.scroll_sync_frames_left > 0) { + if (can_scroll_x) + ImGui::SetScrollX(viewer.scroll.x); + else + ImGui::SetScrollX(0.0f); + if (can_scroll_y) + ImGui::SetScrollY(viewer.scroll.y); + else + ImGui::SetScrollY(0.0f); + --viewer.scroll_sync_frames_left; + } else { + ImVec2 imgui_scroll = viewer.scroll; + if (can_scroll_x) + imgui_scroll.x = ImGui::GetScrollX(); + if (can_scroll_y) + imgui_scroll.y = ImGui::GetScrollY(); + sync_view_scroll_from_display_scroll(viewer, imgui_scroll, + image_size); + } + coord_map.valid = (image_size.x > 0.0f && image_size.y > 0.0f); + coord_map.image_rect_min = ImVec2(viewport_center.x - viewer.scroll.x, + viewport_center.y - viewer.scroll.y); + coord_map.image_rect_max + = ImVec2(coord_map.image_rect_min.x + image_size.x, + coord_map.image_rect_min.y + image_size.y); + update_test_engine_mouse_space( + coord_map.viewport_rect_min, coord_map.viewport_rect_max, + coord_map.valid ? coord_map.image_rect_min : ImVec2(0.0f, 0.0f), + coord_map.valid ? coord_map.image_rect_max : ImVec2(0.0f, 0.0f)); + coord_map.window_pos = ImGui::GetWindowPos(); + if (has_main_texture && coord_map.valid) { + ImGui::SetCursorScreenPos(coord_map.image_rect_min); + ImGui::InvisibleButton("##image_canvas", image_size, + ImGuiButtonFlags_MouseButtonLeft + | ImGuiButtonFlags_MouseButtonRight + | ImGuiButtonFlags_MouseButtonMiddle); + image_canvas_hovered = ImGui::IsItemHovered( + ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + image_canvas_active = ImGui::IsItemActive(); + register_layout_dump_synthetic_item("image", "Image"); + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + draw_list->PushClipRect(coord_map.viewport_rect_min, + coord_map.viewport_rect_max, true); + draw_list->AddImage(main_texture_ref, coord_map.image_rect_min, + coord_map.image_rect_max); + draw_list->PopClipRect(); + } else if (!has_main_texture) { + const bool texture_loading = renderer_texture_is_loading( + viewer.texture); + ImGui::TextUnformatted(texture_loading ? "Loading texture" + : "No texture"); + register_layout_dump_synthetic_item("text", texture_loading + ? "Loading texture" + : "No texture"); + } + + if (ui_state.show_window_guides && coord_map.valid) { + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRect(coord_map.image_rect_min, + coord_map.image_rect_max, + IM_COL32(250, 210, 80, 255), 0.0f, 0, 1.5f); + draw_list->AddRect(coord_map.viewport_rect_min, + coord_map.viewport_rect_max, + IM_COL32(80, 200, 255, 220), 0.0f, 0, 1.0f); + + ImVec2 center_screen(0.0f, 0.0f); + if (source_uv_to_screen(coord_map, ImVec2(0.5f, 0.5f), + center_screen)) { + const float r = 6.0f; + draw_list->AddLine(ImVec2(center_screen.x - r, center_screen.y), + ImVec2(center_screen.x + r, center_screen.y), + IM_COL32(255, 170, 60, 255), 1.3f); + draw_list->AddLine(ImVec2(center_screen.x, center_screen.y - r), + ImVec2(center_screen.x, center_screen.y + r), + IM_COL32(255, 170, 60, 255), 1.3f); + } + } + + const ImGuiIO& io = ImGui::GetIO(); + const ImVec2 mouse = io.MousePos; + const bool area_probe_mode = ui_state.show_area_probe_window; + const bool selection_capable_mode = area_probe_mode; + const bool mouse_in_image = point_in_rect(mouse, + coord_map.image_rect_min, + coord_map.image_rect_max); + const bool mouse_in_viewport + = point_in_rect(mouse, coord_map.viewport_rect_min, + coord_map.viewport_rect_max); + const bool viewport_hovered = ImGui::IsWindowHovered( + ImGuiHoveredFlags_None); + const bool viewport_accepts_mouse = viewport_hovered + && mouse_in_viewport; + const bool image_canvas_accepts_mouse = image_canvas_hovered + || image_canvas_active; + const bool empty_viewport_clicked_left + = viewport_accepts_mouse && !mouse_in_image + && ImGui::IsMouseClicked(ImGuiMouseButton_Left); + const ImVec2 clamped_mouse + = clamp_pos_to_rect(mouse, coord_map.image_rect_min, + coord_map.image_rect_max); + ImVec2 selection_source_uv(0.5f, 0.5f); + const bool have_selection_source_uv + = screen_to_source_uv(coord_map, clamped_mouse, + selection_source_uv); + + ImVec2 source_uv(0.0f, 0.0f); + int px = 0; + int py = 0; + std::vector sampled; + if (viewport_accepts_mouse && mouse_in_image + && screen_to_source_uv(coord_map, mouse, source_uv) + && source_uv_to_pixel(coord_map, source_uv, px, py) + && sample_loaded_pixel(viewer.image, px, py, sampled)) { + viewer.probe_valid = true; + viewer.probe_x = px; + viewer.probe_y = py; + viewer.probe_channels = std::move(sampled); + } else if (ui_state.pixelview_follows_mouse + && (!viewport_accepts_mouse || !mouse_in_image)) { + viewer.probe_valid = false; + viewer.probe_channels.clear(); + } + + const bool selection_can_start = selection_capable_mode && !io.KeyAlt; + if (!viewer.selection_press_active && selection_can_start + && image_canvas_accepts_mouse + && ImGui::IsMouseDown(ImGuiMouseButton_Left) + && have_selection_source_uv) { + clear_image_selection(viewer); + if (area_probe_mode) + reset_area_probe_overlay(viewer); + viewer.selection_press_active = true; + viewer.selection_drag_active = false; + viewer.selection_drag_start_uv = selection_source_uv; + viewer.selection_drag_end_uv = selection_source_uv; + viewer.selection_drag_start_screen = mouse; + } + if (viewer.selection_press_active) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + if (have_selection_source_uv) + viewer.selection_drag_end_uv = selection_source_uv; + if (!viewer.selection_drag_active + && (std::abs(mouse.x - viewer.selection_drag_start_screen.x) + >= 3.0f + || std::abs(mouse.y + - viewer.selection_drag_start_screen.y) + >= 3.0f)) { + viewer.selection_drag_active = true; + } + if (area_probe_mode) { + viewer.area_probe_drag_active = viewer.selection_drag_active; + viewer.area_probe_drag_start_uv + = viewer.selection_drag_start_uv; + viewer.area_probe_drag_end_uv = viewer.selection_drag_end_uv; + } + } else { + const bool had_drag = viewer.selection_drag_active; + if (had_drag) { + int xbegin = 0; + int ybegin = 0; + int xend = 0; + int yend = 0; + if (source_uv_to_pixel(coord_map, + viewer.selection_drag_start_uv, + xbegin, ybegin) + && source_uv_to_pixel(coord_map, + viewer.selection_drag_end_uv, + xend, yend)) { + set_image_selection(viewer, xbegin, ybegin, xend + 1, + yend + 1); + sync_area_probe_to_selection(viewer, ui_state); + viewer.status_message = OIIO::Strutil::fmt::format( + "Selection: ({}, {}) - ({}, {})", + viewer.selection_xbegin, viewer.selection_ybegin, + viewer.selection_xend, viewer.selection_yend); + viewer.last_error.clear(); + } + } else if ((selection_capable_mode || area_probe_mode) + && (viewport_accepts_mouse + || image_canvas_accepts_mouse)) { + clear_image_selection(viewer); + sync_area_probe_to_selection(viewer, ui_state); + viewer.status_message = "Selection cleared"; + viewer.last_error.clear(); + } + viewer.selection_press_active = false; + viewer.selection_drag_active = false; + viewer.selection_drag_start_screen = ImVec2(0.0f, 0.0f); + viewer.area_probe_drag_active = false; + } + } + if (!viewer.selection_press_active && selection_capable_mode + && empty_viewport_clicked_left) { + clear_image_selection(viewer); + sync_area_probe_to_selection(viewer, ui_state); + viewer.status_message = "Selection cleared"; + viewer.last_error.clear(); + } + + bool want_pan = false; + bool want_zoom_drag = false; + if (viewport_accepts_mouse || image_canvas_accepts_mouse + || viewer.pan_drag_active || viewer.zoom_drag_active) { + if (ui_state.mouse_mode == 1) { + want_pan = (!area_probe_mode + && ImGui::IsMouseDown(ImGuiMouseButton_Left)) + || ImGui::IsMouseDown(ImGuiMouseButton_Middle); + } else if (ui_state.mouse_mode == 0) { + const bool want_left_pan + = !area_probe_mode + && ImGui::IsMouseDown(ImGuiMouseButton_Left) + && (viewer.pan_drag_active || image_canvas_accepts_mouse + || viewport_accepts_mouse); + const bool want_middle_pan + = ImGui::IsMouseDown(ImGuiMouseButton_Middle) + && (viewer.pan_drag_active || image_canvas_accepts_mouse + || viewport_accepts_mouse); + want_pan = want_left_pan || want_middle_pan; + want_zoom_drag = !area_probe_mode + && ImGui::IsMouseDown(ImGuiMouseButton_Right) + && (viewer.zoom_drag_active + || image_canvas_accepts_mouse + || viewport_accepts_mouse); + } + } + + draw_image_selection_overlay(viewer, coord_map); + + if (want_pan) { + if (!viewer.pan_drag_active) { + viewer.pan_drag_active = true; + viewer.drag_prev_mouse = mouse; + } else { + const float dx = mouse.x - viewer.drag_prev_mouse.x; + const float dy = mouse.y - viewer.drag_prev_mouse.y; + sync_view_scroll_from_display_scroll( + viewer, ImVec2(viewer.scroll.x - dx, viewer.scroll.y - dy), + image_size); + viewer.scroll_sync_frames_left + = std::max(viewer.scroll_sync_frames_left, 2); + viewer.drag_prev_mouse = mouse; + viewer.fit_request = false; + ui_state.fit_image_to_window = false; + } + } else { + viewer.pan_drag_active = false; + } + + if (want_zoom_drag) { + if (!viewer.zoom_drag_active) { + viewer.zoom_drag_active = true; + viewer.drag_prev_mouse = mouse; + } else { + const float dx = mouse.x - viewer.drag_prev_mouse.x; + const float dy = mouse.y - viewer.drag_prev_mouse.y; + const float scale = 1.0f + 0.005f * (dy - dx); + if (scale > 0.0f) + request_zoom_scale(pending_zoom, scale, true); + viewer.drag_prev_mouse = mouse; + } + } else { + viewer.zoom_drag_active = false; + } + + if (image_canvas_accepts_mouse && io.MouseWheel != 0.0f) { + const float scale = (io.MouseWheel > 0.0f) ? 1.1f : 0.9f; + request_zoom_scale(pending_zoom, scale, true); + } + + const bool zoom_changed = apply_zoom_request(coord_map, viewer, + ui_state, pending_zoom, + mouse); + + if (apply_forced_probe_from_env(viewer)) + viewer.probe_valid = true; + + if (zoom_changed) + queue_auto_subimage_from_zoom(viewer); + + const OverlayPanelRect pixel_panel + = draw_pixel_closeup_overlay(viewer, ui_state, coord_map, + closeup_texture_ref, + has_closeup_texture, fonts); + draw_area_probe_overlay(viewer, ui_state, coord_map, pixel_panel, + fonts); + } else if (viewer.last_error.empty()) { + viewer.probe_valid = false; + viewer.probe_channels.clear(); + draw_padded_message("No image loaded. Use File/Open to load an image."); + register_layout_dump_synthetic_item("text", "No image loaded."); + } + + ImGui::EndChild(); + viewer.last_viewport_size = ImVec2(content_avail.x, viewport_h); + ImGui::Separator(); + register_layout_dump_synthetic_item("divider", "Main viewport"); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, + UiMetrics::kStatusBarPadding); + ImGui::BeginChild("StatusBarRegion", ImVec2(0.0f, status_bar_height), false, + ImGuiWindowFlags_NoScrollbar + | ImGuiWindowFlags_NoScrollWithMouse); + draw_embedded_status_bar(viewer, ui_state); + ImGui::EndChild(); + ImGui::PopStyleVar(); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_image_view.h b/src/imiv/imiv_image_view.h new file mode 100644 index 0000000000..d6e8819c87 --- /dev/null +++ b/src/imiv/imiv_image_view.h @@ -0,0 +1,17 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_ui.h" + +namespace Imiv { + +void +draw_image_window_contents(ViewerState& viewer, PlaceholderUiState& ui_state, + const AppFonts& fonts, + const PendingZoomRequest& shortcut_zoom_request, + bool recenter_requested); + +} // namespace Imiv diff --git a/src/imiv/imiv_imgui_metal_extras.h b/src/imiv/imiv_imgui_metal_extras.h new file mode 100644 index 0000000000..06a8e2fa5c --- /dev/null +++ b/src/imiv/imiv_imgui_metal_extras.h @@ -0,0 +1,20 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include + +#if defined(__OBJC__) && !defined(IMGUI_DISABLE) + +@protocol MTLTexture; +@protocol MTLSamplerState; + +IMGUI_IMPL_API ImTextureID +ImGui_ImplMetal_CreateUserTextureID(id texture, + id sampler_state); +IMGUI_IMPL_API void +ImGui_ImplMetal_DestroyUserTextureID(ImTextureID tex_id); + +#endif diff --git a/src/imiv/imiv_loaded_image.cpp b/src/imiv/imiv_loaded_image.cpp new file mode 100644 index 0000000000..35a734dad4 --- /dev/null +++ b/src/imiv/imiv_loaded_image.cpp @@ -0,0 +1,126 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_loaded_image.h" + +#include +#include + +using namespace OIIO; + +namespace Imiv { + +bool +describe_loaded_image_layout(const LoadedImage& image, + LoadedImageLayout& layout, + std::string& error_message) +{ + error_message.clear(); + layout = LoadedImageLayout(); + + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0 + || image.channel_bytes == 0) { + error_message = "invalid source image dimensions"; + return false; + } + + const size_t width = static_cast(image.width); + const size_t height = static_cast(image.height); + const size_t channels = static_cast(image.nchannels); + const size_t pixel_stride = channels * image.channel_bytes; + const size_t min_row_pitch = width * pixel_stride; + if (pixel_stride == 0 || image.row_pitch_bytes < min_row_pitch) { + error_message = "invalid source row pitch"; + return false; + } + + const size_t required_bytes = image.row_pitch_bytes * height; + if (image.pixels.size() < required_bytes) { + error_message = "source pixel buffer is smaller than declared stride"; + return false; + } + + layout.pixel_stride_bytes = pixel_stride; + layout.min_row_pitch_bytes = min_row_pitch; + layout.required_bytes = required_bytes; + return true; +} + +bool +loaded_image_pixel_pointer(const LoadedImage& image, int x, int y, + const unsigned char*& out_pixel, + LoadedImageLayout* out_layout, + std::string* error_message) +{ + out_pixel = nullptr; + LoadedImageLayout layout; + std::string local_error; + if (!describe_loaded_image_layout(image, layout, local_error)) { + if (error_message != nullptr) + *error_message = local_error; + return false; + } + if (x < 0 || y < 0 || x >= image.width || y >= image.height) { + if (error_message != nullptr) + *error_message = "requested pixel is outside the loaded image"; + return false; + } + + const size_t row_start = static_cast(y) * image.row_pitch_bytes; + const size_t pixel_start = static_cast(x) + * layout.pixel_stride_bytes; + const size_t offset = row_start + pixel_start; + if (offset + layout.pixel_stride_bytes > image.pixels.size()) { + if (error_message != nullptr) + *error_message = "requested pixel lies outside the loaded buffer"; + return false; + } + + out_pixel = image.pixels.data() + offset; + if (out_layout != nullptr) + *out_layout = layout; + if (error_message != nullptr) + error_message->clear(); + return true; +} + +bool +imagebuf_from_loaded_image(const LoadedImage& image, ImageBuf& out, + std::string& error_message) +{ + error_message.clear(); + const TypeDesc format = upload_data_type_to_typedesc(image.type); + if (format == TypeUnknown) { + error_message = "unsupported source pixel type"; + return false; + } + + LoadedImageLayout layout; + if (!describe_loaded_image_layout(image, layout, error_message)) + return false; + + ImageSpec spec(image.width, image.height, image.nchannels, format); + if (image.channel_names.size() == static_cast(image.nchannels)) + spec.channelnames = image.channel_names; + spec.attribute("Orientation", image.orientation); + if (!image.metadata_color_space.empty()) + spec.attribute("oiio:ColorSpace", image.metadata_color_space); + + out.reset(spec); + const std::byte* begin = reinterpret_cast( + image.pixels.data()); + const cspan byte_span(begin, layout.required_bytes); + const stride_t xstride = static_cast(layout.pixel_stride_bytes); + const stride_t ystride = static_cast(image.row_pitch_bytes); + if (!out.set_pixels(ROI::All(), format, byte_span, begin, xstride, ystride, + AutoStride)) { + error_message = out.geterror(); + if (error_message.empty()) + error_message = "failed to copy source pixels into ImageBuf"; + return false; + } + return true; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_loaded_image.h b/src/imiv/imiv_loaded_image.h new file mode 100644 index 0000000000..afe8566ed5 --- /dev/null +++ b/src/imiv/imiv_loaded_image.h @@ -0,0 +1,37 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_types.h" + +#include +#include + +#include + +namespace Imiv { + +struct LoadedImageLayout { + size_t pixel_stride_bytes = 0; + size_t min_row_pitch_bytes = 0; + size_t required_bytes = 0; +}; + +bool +describe_loaded_image_layout(const LoadedImage& image, + LoadedImageLayout& layout, + std::string& error_message); + +bool +loaded_image_pixel_pointer(const LoadedImage& image, int x, int y, + const unsigned char*& out_pixel, + LoadedImageLayout* out_layout = nullptr, + std::string* error_message = nullptr); + +bool +imagebuf_from_loaded_image(const LoadedImage& image, OIIO::ImageBuf& out, + std::string& error_message); + +} // namespace Imiv diff --git a/src/imiv/imiv_main.cpp b/src/imiv/imiv_main.cpp new file mode 100644 index 0000000000..6cdaba868e --- /dev/null +++ b/src/imiv/imiv_main.cpp @@ -0,0 +1,165 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_app.h" +#include "imiv_file_dialog.h" + +#include + +#include +#include +#include +#include + +using namespace OIIO; + +static ArgParse +getargs(int argc, char* argv[]) +{ + ArgParse ap; + // clang-format off + ap.intro("imiv -- Dear ImGui image viewer\n" + OIIO_INTRO_STRING) + .usage("imiv [options] [filename...]") + .add_version(OIIO_VERSION_STRING); + + ap.arg("filename") + .action(ArgParse::append()) + .hidden(); + ap.arg("-v") + .help("Verbose status messages") + .dest("verbose"); + ap.arg("-F") + .help("Foreground mode") + .dest("foreground_mode") + .store_true(); + ap.arg("--no-autopremult") + .help("Turn off automatic premultiplication of images with unassociated alpha") + .store_true(); + ap.arg("--rawcolor") + .help("Do not automatically transform to RGB"); + ap.arg("--display") + .help("OCIO display") + .metavar("STRING") + .defaultval("") + .action(ArgParse::store()); + ap.arg("--image-color-space") + .help("OCIO image color space") + .metavar("STRING") + .defaultval("") + .action(ArgParse::store()); + ap.arg("--view") + .help("OCIO view") + .metavar("STRING") + .defaultval("") + .action(ArgParse::store()); + ap.arg("--open-dialog") + .help("Open a native file-open dialog and report the selected file path") + .store_true(); + ap.arg("--open-folder-dialog") + .help("Open a native folder dialog and report the selected folder path") + .store_true(); + ap.arg("--save-dialog") + .help("Open a native file-save dialog and report the selected file path") + .store_true(); + ap.arg("--backend") + .help("Renderer backend request: auto, vulkan, metal, opengl") + .metavar("STRING") + .defaultval("") + .action(ArgParse::store()); + ap.arg("--list-backends") + .help("List backend support compiled into this imiv binary and exit") + .store_true(); + ap.arg("--devmode") + .help("Enable Developer menu and developer tools") + .store_true(); + + ap.parse(argc, (const char**)argv); + return ap; + // clang-format on +} + +int +main(int argc, char* argv[]) +{ + Sysutil::setup_crash_stacktrace("stdout"); + Filesystem::convert_native_arguments(argc, (const char**)argv); + ArgParse ap = getargs(argc, argv); + + Imiv::AppConfig config; + config.verbose = ap["verbose"].get() != 0; + config.foreground_mode = ap["foreground_mode"].get() != 0; + config.no_autopremult = ap["no-autopremult"].get() != 0; + config.rawcolor = ap["rawcolor"].get() != 0; + config.open_dialog = ap["open-dialog"].get() != 0; + const bool open_folder_dialog = ap["open-folder-dialog"].get() != 0; + config.save_dialog = ap["save-dialog"].get() != 0; + config.list_backends = ap["list-backends"].get() != 0; + config.developer_mode = ap["devmode"].get() != 0; + config.developer_mode_explicit = config.developer_mode; + config.ocio_display = ap["display"].as_string(""); + config.ocio_image_color_space = ap["image-color-space"].as_string(""); + config.ocio_view = ap["view"].as_string(""); + config.input_paths = ap["filename"].as_vec(); + + const std::string backend_arg = ap["backend"].as_string(""); + if (!backend_arg.empty() + && !Imiv::parse_backend_kind(backend_arg, config.requested_backend)) { + print( + stderr, + "imiv: invalid backend '{}'; expected auto, vulkan, metal, or opengl\n", + backend_arg); + return EXIT_FAILURE; + } + + if (config.list_backends) { + std::string probe_error; + if (!Imiv::refresh_runtime_backend_info(config.verbose, probe_error) + && config.verbose && !probe_error.empty()) { + print(stderr, "imiv: backend availability probe setup failed: {}\n", + probe_error); + } + print("imiv backend support for this build:\n"); + for (const Imiv::BackendRuntimeInfo& info : + Imiv::runtime_backend_info()) { + std::string description = info.build_info.compiled ? "built" + : "not built"; + if (info.build_info.compiled) { + if (info.available) { + description += ", available"; + } else if (!info.unavailable_reason.empty()) { + description += Strutil::fmt::format(", unavailable: {}", + info.unavailable_reason); + } else { + description += ", unavailable"; + } + } + if (info.build_info.active_build) + description += ", build default backend"; + if (info.build_info.platform_default) + description += ", platform default"; + print(" {} ({}) : {}\n", info.build_info.display_name, + info.build_info.cli_name, description); + } + return EXIT_SUCCESS; + } + + if (open_folder_dialog) { + Imiv::FileDialog::DialogReply reply = Imiv::FileDialog::open_folder(""); + if (reply.result == Imiv::FileDialog::Result::Okay + && !reply.path.empty()) { + print("{}\n", reply.path); + return EXIT_SUCCESS; + } + if (reply.result == Imiv::FileDialog::Result::Cancel) + return EXIT_SUCCESS; + print(stderr, "imiv: {}\n", reply.message); + return EXIT_FAILURE; + } + + if (!config.foreground_mode) + Sysutil::put_in_background(); + + return Imiv::run(config); +} diff --git a/src/imiv/imiv_menu.cpp b/src/imiv/imiv_menu.cpp new file mode 100644 index 0000000000..9a6f3cc93d --- /dev/null +++ b/src/imiv/imiv_menu.cpp @@ -0,0 +1,624 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_menu.h" + +#include "imiv_actions.h" +#include "imiv_developer_tools.h" +#include "imiv_image_library.h" +#include "imiv_ocio.h" +#include "imiv_ui.h" +#include "imiv_viewer.h" + +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + bool app_shortcut(ImGuiKeyChord key_chord) + { + return ImGui::Shortcut(key_chord, + ImGuiInputFlags_RouteGlobal + | ImGuiInputFlags_RouteUnlessBgFocused); + } + +} // namespace + +void +collect_viewer_shortcuts(ViewerState& viewer, PlaceholderUiState& ui_state, + DeveloperUiState& developer_ui, + ViewerFrameActions& actions, bool& request_exit) +{ + const bool has_image = !viewer.image.path.empty(); + const bool has_selection = has_image_selection(viewer); + const bool can_prev_subimage = has_image + && (viewer.image.miplevel > 0 + || viewer.image.subimage > 0 + || viewer.image.nsubimages > 1); + const bool can_next_subimage + = has_image + && (viewer.auto_subimage + || viewer.image.miplevel + 1 < viewer.image.nmiplevels + || viewer.image.subimage + 1 < viewer.image.nsubimages); + + const ImGuiIO& global_io = ImGui::GetIO(); + const bool no_mods = !global_io.KeyCtrl && !global_io.KeyAlt + && !global_io.KeySuper; + + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_O)) + actions.open_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_O)) + actions.open_folder_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_R) && has_image) + actions.reload_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_W) && has_image) + actions.close_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_S) && has_image) + actions.save_as_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiMod_Alt | ImGuiKey_S) + && has_selection) { + actions.export_selection_as_requested = true; + } else if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_S) + && has_image) { + actions.save_window_as_requested = true; + } + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Alt | ImGuiKey_S) + && has_selection) { + actions.save_selection_as_requested = true; + } + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_N) && has_image) + actions.new_view_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_A) && has_image) + actions.select_all_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_D) && has_selection) + actions.deselect_selection_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_Comma)) { + ui_state.show_preferences_window = true; + ui_state.focus_window_name = k_preferences_window_title; + } + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_Q)) + request_exit = true; + if (developer_ui.enabled && app_shortcut(ImGuiKey_F12) + && !developer_ui.screenshot_busy) { + developer_ui.request_screenshot = true; + } + if (app_shortcut(ImGuiKey_PageUp)) + actions.prev_requested = true; + if (app_shortcut(ImGuiKey_PageDown)) + actions.next_requested = true; + if (no_mods && app_shortcut(ImGuiKey_T)) + actions.toggle_requested = true; + if ((app_shortcut(ImGuiMod_Ctrl | ImGuiKey_Equal) + || app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Equal) + || app_shortcut(ImGuiMod_Ctrl | ImGuiKey_KeypadAdd)) + && has_image) { + request_zoom_scale(actions.pending_zoom, 2.0f, false); + } + if ((app_shortcut(ImGuiMod_Ctrl | ImGuiKey_Minus) + || app_shortcut(ImGuiMod_Ctrl | ImGuiKey_KeypadSubtract)) + && has_image) { + request_zoom_scale(actions.pending_zoom, 0.5f, false); + } + if ((app_shortcut(ImGuiMod_Ctrl | ImGuiKey_0) + || app_shortcut(ImGuiMod_Ctrl | ImGuiKey_Keypad0)) + && has_image) { + request_zoom_reset(actions.pending_zoom, false); + } + if ((app_shortcut(ImGuiMod_Ctrl | ImGuiKey_Period) + || app_shortcut(ImGuiMod_Ctrl | ImGuiKey_KeypadDecimal)) + && has_image) { + actions.recenter_requested = true; + } + if (no_mods && app_shortcut(ImGuiKey_F) && has_image) + actions.fit_window_to_image_requested = true; + if (app_shortcut(ImGuiMod_Alt | ImGuiKey_F) && has_image) { + ui_state.fit_image_to_window = !ui_state.fit_image_to_window; + viewer.fit_request = true; + } + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_F)) + actions.full_screen_toggle_requested = true; + if (ui_state.full_screen_mode && app_shortcut(ImGuiKey_Escape)) + actions.full_screen_toggle_requested = true; + if (app_shortcut(ImGuiMod_Shift | ImGuiKey_Comma) && can_prev_subimage) + actions.prev_subimage_requested = true; + if (app_shortcut(ImGuiMod_Shift | ImGuiKey_Period) && can_next_subimage) + actions.next_subimage_requested = true; + if (no_mods && app_shortcut(ImGuiKey_C)) + ui_state.current_channel = 0; + if (no_mods && app_shortcut(ImGuiKey_R)) + ui_state.current_channel = 1; + if (no_mods && app_shortcut(ImGuiKey_G)) + ui_state.current_channel = 2; + if (no_mods && app_shortcut(ImGuiKey_B)) + ui_state.current_channel = 3; + if (no_mods && app_shortcut(ImGuiKey_A)) + ui_state.current_channel = 4; + if (no_mods && app_shortcut(ImGuiKey_Comma) && has_image) + ui_state.current_channel = std::max(0, ui_state.current_channel - 1); + if (no_mods && app_shortcut(ImGuiKey_Period) && has_image) + ui_state.current_channel = std::min(4, ui_state.current_channel + 1); + if (no_mods && app_shortcut(ImGuiKey_1)) + ui_state.color_mode = 2; + if (no_mods && app_shortcut(ImGuiKey_L)) + ui_state.color_mode = 3; + if (no_mods && app_shortcut(ImGuiKey_H)) + ui_state.color_mode = 4; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_I)) { + ui_state.show_info_window = !ui_state.show_info_window; + if (ui_state.show_info_window) + ui_state.focus_window_name = k_info_window_title; + } + if (no_mods && app_shortcut(ImGuiKey_P)) + ui_state.show_pixelview_window = !ui_state.show_pixelview_window; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiKey_A)) + set_area_sample_enabled(viewer, ui_state, + !ui_state.show_area_probe_window); + if (app_shortcut(ImGuiMod_Shift | ImGuiKey_LeftBracket)) + ui_state.exposure -= 0.5f; + if (app_shortcut(ImGuiKey_LeftBracket)) + ui_state.exposure -= 0.1f; + if (app_shortcut(ImGuiKey_RightBracket)) + ui_state.exposure += 0.1f; + if (app_shortcut(ImGuiMod_Shift | ImGuiKey_RightBracket)) + ui_state.exposure += 0.5f; + if (app_shortcut(ImGuiMod_Shift | ImGuiKey_9)) + ui_state.gamma = std::max(0.1f, ui_state.gamma - 0.1f); + if (app_shortcut(ImGuiMod_Shift | ImGuiKey_0)) + ui_state.gamma += 0.1f; + if (app_shortcut(ImGuiKey_Delete) && has_image) + actions.delete_from_disk_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_L)) + actions.rotate_left_requested = true; + if (app_shortcut(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_R)) + actions.rotate_right_requested = true; +} + +void +draw_viewer_main_menu(ViewerState& viewer, PlaceholderUiState& ui_state, + ImageLibraryState& library, + const std::vector& viewers, + DeveloperUiState& developer_ui, + ViewerFrameActions& actions, bool& request_exit, + bool& show_image_list_window, + bool& image_list_request_focus +#if defined(IMGUI_ENABLE_TEST_ENGINE) + , + bool show_test_menu, bool* show_test_engine_windows +#endif +) +{ + const bool has_image = !viewer.image.path.empty(); + const bool has_selection = has_image_selection(viewer); + const bool can_prev_subimage = has_image + && (viewer.image.miplevel > 0 + || viewer.image.subimage > 0 + || viewer.image.nsubimages > 1); + const bool can_next_subimage + = has_image + && (viewer.auto_subimage + || viewer.image.miplevel + 1 < viewer.image.nmiplevels + || viewer.image.subimage + 1 < viewer.image.nsubimages); + const bool can_prev_mip = has_image && viewer.image.miplevel > 0; + const bool can_next_mip + = has_image && (viewer.image.miplevel + 1 < viewer.image.nmiplevels); + + if (!ImGui::BeginMainMenuBar()) + return; + + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Open...", "Ctrl+O")) + actions.open_requested = true; + if (ImGui::MenuItem("Open Folder...", "Ctrl+Shift+O")) + actions.open_folder_requested = true; + + if (ImGui::BeginMenu("Open recent...")) { + if (library.recent_images.empty()) { + ImGui::MenuItem("No recent files", nullptr, false, false); + } else { + for (size_t i = 0; i < library.recent_images.size(); ++i) { + const std::string& recent = library.recent_images[i]; + const std::string label + = Strutil::fmt::format("{}: {}##imiv_recent_{}", i + 1, + recent, i); + if (ImGui::MenuItem(label.c_str())) + actions.recent_open_path = recent; + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear recent list", nullptr, false, + !library.recent_images.empty())) { + actions.clear_recent_requested = true; + } + ImGui::EndMenu(); + } + + if (ImGui::MenuItem("Reload image", "Ctrl+R", false, has_image)) + actions.reload_requested = true; + if (ImGui::MenuItem("Close image", "Ctrl+W", false, has_image)) + actions.close_requested = true; + ImGui::Separator(); + if (ImGui::MenuItem("Save As...", "Ctrl+S", false, has_image)) + actions.save_as_requested = true; + if (ImGui::MenuItem("Export As...", "Ctrl+Shift+S", false, has_image)) + actions.save_window_as_requested = true; + if (ImGui::MenuItem("Save Selection As...", "Ctrl+Alt+S", false, + has_selection)) { + actions.save_selection_as_requested = true; + } + if (ImGui::MenuItem("Export Selection As...", "Ctrl+Shift+Alt+S", false, + has_selection)) { + actions.export_selection_as_requested = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("New view from current image", "Ctrl+Shift+N", + false, has_image)) + actions.new_view_requested = true; + if (ImGui::MenuItem("Delete from disk", "Delete", false, has_image)) + actions.delete_from_disk_requested = true; + ImGui::Separator(); + if (ImGui::MenuItem("Preferences...", "Ctrl+,")) { + ui_state.show_preferences_window = true; + ui_state.focus_window_name = k_preferences_window_title; + } + ImGui::Separator(); + if (ImGui::MenuItem("Exit", "Ctrl+Q")) + request_exit = true; + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Select")) { + bool area_sample_enabled = ui_state.show_area_probe_window; + if (ImGui::MenuItem("Toggle Area Sample", "Ctrl+A", + &area_sample_enabled)) { + set_area_sample_enabled(viewer, ui_state, area_sample_enabled); + } + ImGui::Separator(); + if (ImGui::MenuItem("Select All", "Ctrl+Shift+A", false, has_image)) + actions.select_all_requested = true; + if (ImGui::MenuItem("Deselect", "Ctrl+D", false, has_selection)) + actions.deselect_selection_requested = true; + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Previous Image", "PgUp")) + actions.prev_requested = true; + if (ImGui::MenuItem("Next Image", "PgDown")) + actions.next_requested = true; + if (ImGui::MenuItem("Toggle image", "T")) + actions.toggle_requested = true; + ImGui::MenuItem("Show display/data window borders", nullptr, + &ui_state.show_window_guides); + ImGui::Separator(); + + if (ImGui::MenuItem("Zoom In", "Ctrl++", false, has_image)) + request_zoom_scale(actions.pending_zoom, 2.0f, false); + if (ImGui::MenuItem("Zoom Out", "Ctrl+-", false, has_image)) + request_zoom_scale(actions.pending_zoom, 0.5f, false); + if (ImGui::MenuItem("Normal Size (1:1)", "Ctrl+0", false, has_image)) { + request_zoom_reset(actions.pending_zoom, false); + } + if (ImGui::MenuItem("Re-center Image", "Ctrl+.", false, has_image)) + actions.recenter_requested = true; + if (ImGui::MenuItem("Fit Window to Image", "F", false, has_image)) + actions.fit_window_to_image_requested = true; + if (ImGui::MenuItem("Fit Image to Window", "Alt+F", + ui_state.fit_image_to_window, has_image)) { + ui_state.fit_image_to_window = !ui_state.fit_image_to_window; + viewer.fit_request = true; + } + if (ImGui::MenuItem("Full screen", "Ctrl+F", + ui_state.full_screen_mode)) { + actions.full_screen_toggle_requested = true; + } + const bool previous_image_list_visibility = show_image_list_window; + if (ImGui::MenuItem("Image List", nullptr, &show_image_list_window)) { + if (show_image_list_window && !previous_image_list_visibility) + image_list_request_focus = true; + } + ImGui::Separator(); + + if (ImGui::MenuItem("Prev Subimage", "<", false, can_prev_subimage)) + actions.prev_subimage_requested = true; + if (ImGui::MenuItem("Next Subimage", ">", false, can_next_subimage)) + actions.next_subimage_requested = true; + if (ImGui::MenuItem("Prev MIP level", nullptr, false, can_prev_mip)) + actions.prev_mip_requested = true; + if (ImGui::MenuItem("Next MIP level", nullptr, false, can_next_mip)) + actions.next_mip_requested = true; + + if (ImGui::BeginMenu("Channels")) { + if (ImGui::MenuItem("Full Color", "C", + ui_state.current_channel == 0)) + ui_state.current_channel = 0; + if (ImGui::MenuItem("Red", "R", ui_state.current_channel == 1)) + ui_state.current_channel = 1; + if (ImGui::MenuItem("Green", "G", ui_state.current_channel == 2)) + ui_state.current_channel = 2; + if (ImGui::MenuItem("Blue", "B", ui_state.current_channel == 3)) + ui_state.current_channel = 3; + if (ImGui::MenuItem("Alpha", "A", ui_state.current_channel == 4)) + ui_state.current_channel = 4; + ImGui::Separator(); + if (ImGui::MenuItem("Prev Channel", ",", false, has_image)) { + ui_state.current_channel = std::max(0, ui_state.current_channel + - 1); + } + if (ImGui::MenuItem("Next Channel", ".", false, has_image)) { + ui_state.current_channel = std::min(4, ui_state.current_channel + + 1); + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Color mode")) { + if (ImGui::MenuItem("RGBA", nullptr, ui_state.color_mode == 0)) + ui_state.color_mode = 0; + if (ImGui::MenuItem("RGB", nullptr, ui_state.color_mode == 1)) + ui_state.color_mode = 1; + if (ImGui::MenuItem("Single channel", "1", ui_state.color_mode == 2)) + ui_state.color_mode = 2; + if (ImGui::MenuItem("Luminance", "L", ui_state.color_mode == 3)) + ui_state.color_mode = 3; + if (ImGui::MenuItem("Single channel (Heatmap)", "H", + ui_state.color_mode == 4)) { + ui_state.color_mode = 4; + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("OCIO")) { + ImGui::MenuItem("Use OCIO", nullptr, &ui_state.use_ocio); + std::vector ocio_color_spaces; + std::vector ocio_displays; + std::vector ocio_views; + std::string resolved_display; + std::string resolved_view; + std::string ocio_error; + const bool ocio_menu_data_ok = query_ocio_menu_data( + ui_state, ocio_color_spaces, ocio_displays, ocio_views, + resolved_display, resolved_view, ocio_error); + if (ocio_menu_data_ok) { + const auto contains = [](const std::vector& values, + const std::string& value) { + return std::find(values.begin(), values.end(), value) + != values.end(); + }; + if (ui_state.ocio_image_color_space.empty() + || (ui_state.ocio_image_color_space != "auto" + && !contains(ocio_color_spaces, + ui_state.ocio_image_color_space))) { + ui_state.ocio_image_color_space = "auto"; + } + if (ui_state.ocio_display.empty() + || (ui_state.ocio_display != "default" + && ui_state.ocio_display != resolved_display)) { + ui_state.ocio_display = "default"; + ui_state.ocio_view = "default"; + } else if (ui_state.ocio_view.empty() + || (ui_state.ocio_view != "default" + && ui_state.ocio_view != resolved_view)) { + ui_state.ocio_view = "default"; + } + } + if (ImGui::BeginMenu("Image color space")) { + if (!ocio_menu_data_ok) { + ImGui::MenuItem(ocio_error.c_str(), nullptr, false, false); + } else { + for (const std::string& color_space : ocio_color_spaces) { + if (ImGui::MenuItem(color_space.c_str(), nullptr, + ui_state.ocio_image_color_space + == color_space)) { + ui_state.ocio_image_color_space = color_space; + } + } + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Display/View")) { + if (!ocio_menu_data_ok) { + ImGui::MenuItem(ocio_error.c_str(), nullptr, false, false); + } else { + if (ImGui::MenuItem("default / default", nullptr, + ui_state.ocio_display == "default" + && ui_state.ocio_view + == "default")) { + ui_state.ocio_display = "default"; + ui_state.ocio_view = "default"; + } + ImGui::Separator(); + for (const std::string& display_name : ocio_displays) { + if (ImGui::BeginMenu(display_name.c_str())) { + if (display_name == resolved_display + && ImGui::MenuItem("default", nullptr, + ui_state.ocio_display + == "default")) { + ui_state.ocio_display = "default"; + ui_state.ocio_view = "default"; + } + std::vector display_views; + std::vector display_color_spaces; + std::vector display_displays; + std::string ignored_display; + std::string ignored_view; + std::string display_error; + PlaceholderUiState probe_state = ui_state; + probe_state.ocio_display = display_name; + probe_state.ocio_view = "default"; + if (!query_ocio_menu_data( + probe_state, display_color_spaces, + display_displays, display_views, + ignored_display, ignored_view, + display_error)) { + ImGui::MenuItem(display_error.c_str(), nullptr, + false, false); + } else { + for (const std::string& view_name : + display_views) { + const bool selected + = ui_state.ocio_display == display_name + && ((ui_state.ocio_view == "default" + && view_name == ignored_view) + || ui_state.ocio_view + == view_name); + if (ImGui::MenuItem(view_name.c_str(), + nullptr, selected)) { + ui_state.ocio_display = display_name; + ui_state.ocio_view = view_name; + } + } + } + ImGui::EndMenu(); + } + } + } + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Exposure/gamma")) { + if (ImGui::MenuItem("Exposure -1/2 stop", "{")) + ui_state.exposure -= 0.5f; + if (ImGui::MenuItem("Exposure -1/10 stop", "[")) + ui_state.exposure -= 0.1f; + if (ImGui::MenuItem("Exposure +1/10 stop", "]")) + ui_state.exposure += 0.1f; + if (ImGui::MenuItem("Exposure +1/2 stop", "}")) + ui_state.exposure += 0.5f; + if (ImGui::MenuItem("Gamma -0.1", "(")) + ui_state.gamma = std::max(0.1f, ui_state.gamma - 0.1f); + if (ImGui::MenuItem("Gamma +0.1", ")")) + ui_state.gamma += 0.1f; + if (ImGui::MenuItem("Reset exposure/gamma")) { + ui_state.exposure = 0.0f; + ui_state.gamma = 1.0f; + } + ImGui::EndMenu(); + } + + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Tools")) { + if (ImGui::BeginMenu("Slide Show")) { + if (ImGui::MenuItem("Start Slide Show", nullptr, + ui_state.slide_show_running)) { + toggle_slide_show_action(ui_state, viewer); + } + if (ImGui::MenuItem("Loop slide show", nullptr, + ui_state.slide_loop)) { + ui_state.slide_loop = !ui_state.slide_loop; + } + if (ImGui::MenuItem("Stop at end", nullptr, !ui_state.slide_loop)) + ui_state.slide_loop = false; + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Sort")) { + if (ImGui::MenuItem("By Name")) + set_sort_mode_action(library, viewers, ImageSortMode::ByName); + if (ImGui::MenuItem("By File Path")) + set_sort_mode_action(library, viewers, ImageSortMode::ByPath); + if (ImGui::MenuItem("By Image Date")) + set_sort_mode_action(library, viewers, + ImageSortMode::ByImageDate); + if (ImGui::MenuItem("By File Date")) + set_sort_mode_action(library, viewers, + ImageSortMode::ByFileDate); + if (ImGui::MenuItem("Reverse current order")) + toggle_sort_reverse_action(library, viewers); + ImGui::EndMenu(); + } + + ImGui::Separator(); + const bool previous_info_visibility = ui_state.show_info_window; + if (ImGui::MenuItem("Image info...", "Ctrl+I", + &ui_state.show_info_window)) { + if (ui_state.show_info_window && !previous_info_visibility) + ui_state.focus_window_name = k_info_window_title; + } + const bool previous_preview_visibility = ui_state.show_preview_window; + if (ImGui::MenuItem("Preview controls...", nullptr, + &ui_state.show_preview_window)) { + if (ui_state.show_preview_window && !previous_preview_visibility) + ui_state.focus_window_name = k_preview_window_title; + } + ImGui::MenuItem("Pixel closeup view...", "P", + &ui_state.show_pixelview_window); + ImGui::Separator(); + if (ImGui::MenuItem("Rotate Left", "Ctrl+Shift+L")) + actions.rotate_left_requested = true; + if (ImGui::MenuItem("Rotate Right", "Ctrl+Shift+R")) + actions.rotate_right_requested = true; + if (ImGui::MenuItem("Flip Horizontal")) + actions.flip_horizontal_requested = true; + if (ImGui::MenuItem("Flip Vertical")) + actions.flip_vertical_requested = true; + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Window")) { + ImGui::MenuItem("Always on Top", nullptr, + &ui_state.window_always_on_top); + if (ImGui::MenuItem("Reset Windows")) + actions.reset_windows_requested = true; + ImGui::EndMenu(); + } + + if (developer_ui.enabled && ImGui::BeginMenu("Developer")) { + ImGui::MenuItem("ImGui Demo", nullptr, + &developer_ui.show_imgui_demo_window); + ImGui::MenuItem("ImGui Style Editor", nullptr, + &developer_ui.show_imgui_style_editor); + ImGui::MenuItem("ImGui Metrics/Debugger", nullptr, + &developer_ui.show_imgui_metrics_window); + ImGui::MenuItem("ImGui Debug Log", nullptr, + &developer_ui.show_imgui_debug_log_window); + ImGui::MenuItem("ImGui ID Stack Tool", nullptr, + &developer_ui.show_imgui_id_stack_window); + ImGui::MenuItem("ImGui About", nullptr, + &developer_ui.show_imgui_about_window); + ImGui::Separator(); + if (ImGui::MenuItem("Capture Main Window", "F12", false, + !developer_ui.screenshot_busy)) { + developer_ui.request_screenshot = true; + } +#if defined(IMGUI_ENABLE_TEST_ENGINE) + if (show_test_engine_windows != nullptr) { + ImGui::Separator(); + ImGui::MenuItem("Test Engine Windows", nullptr, + show_test_engine_windows); + } +#endif + ImGui::EndMenu(); + } +#if defined(IMGUI_ENABLE_TEST_ENGINE) + if (show_test_engine_windows != nullptr && show_test_menu + && ImGui::BeginMenu("Tests")) { + ImGui::MenuItem("Show test engine windows", nullptr, + show_test_engine_windows); + ImGui::EndMenu(); + } +#endif + + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("About")) { + ui_state.show_about_window = true; + ui_state.focus_window_name = k_about_window_title; + } + ImGui::EndMenu(); + } + + ImGui::EndMainMenuBar(); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_menu.h b/src/imiv/imiv_menu.h new file mode 100644 index 0000000000..a7e20c7e5f --- /dev/null +++ b/src/imiv/imiv_menu.h @@ -0,0 +1,31 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_frame_actions.h" + +namespace Imiv { + +struct DeveloperUiState; + +void +collect_viewer_shortcuts(ViewerState& viewer, PlaceholderUiState& ui_state, + DeveloperUiState& developer_ui, + ViewerFrameActions& actions, bool& request_exit); +void +draw_viewer_main_menu(ViewerState& viewer, PlaceholderUiState& ui_state, + ImageLibraryState& library, + const std::vector& viewers, + DeveloperUiState& developer_ui, + ViewerFrameActions& actions, bool& request_exit, + bool& show_image_list_window, + bool& image_list_request_focus +#if defined(IMGUI_ENABLE_TEST_ENGINE) + , + bool show_test_menu, bool* show_test_engine_windows +#endif +); + +} // namespace Imiv diff --git a/src/imiv/imiv_navigation.cpp b/src/imiv/imiv_navigation.cpp new file mode 100644 index 0000000000..57ee8d18ac --- /dev/null +++ b/src/imiv/imiv_navigation.cpp @@ -0,0 +1,379 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_navigation.h" + +#include +#include + +#include + +namespace Imiv { + +ImVec2 +source_uv_to_display_uv(const ImVec2& src_uv, int orientation) +{ + const float u = src_uv.x; + const float v = src_uv.y; + switch (clamp_orientation(orientation)) { + case 1: return ImVec2(u, v); + case 2: return ImVec2(1.0f - u, v); + case 3: return ImVec2(1.0f - u, 1.0f - v); + case 4: return ImVec2(u, 1.0f - v); + case 5: return ImVec2(v, u); + case 6: return ImVec2(1.0f - v, u); + case 7: return ImVec2(1.0f - v, 1.0f - u); + case 8: return ImVec2(v, 1.0f - u); + default: break; + } + return ImVec2(u, v); +} + +ImVec2 +display_uv_to_source_uv(const ImVec2& display_uv, int orientation) +{ + const float u = display_uv.x; + const float v = display_uv.y; + switch (clamp_orientation(orientation)) { + case 1: return ImVec2(u, v); + case 2: return ImVec2(1.0f - u, v); + case 3: return ImVec2(1.0f - u, 1.0f - v); + case 4: return ImVec2(u, 1.0f - v); + case 5: return ImVec2(v, u); + case 6: return ImVec2(v, 1.0f - u); + case 7: return ImVec2(1.0f - v, 1.0f - u); + case 8: return ImVec2(1.0f - v, u); + default: break; + } + return ImVec2(u, v); +} + +ImVec2 +screen_to_window_coords(const ImageCoordinateMap& map, const ImVec2& screen_pos) +{ + return ImVec2(screen_pos.x - map.window_pos.x, + screen_pos.y - map.window_pos.y); +} + +ImVec2 +window_to_screen_coords(const ImageCoordinateMap& map, const ImVec2& window_pos) +{ + return ImVec2(window_pos.x + map.window_pos.x, + window_pos.y + map.window_pos.y); +} + +bool +screen_to_display_uv(const ImageCoordinateMap& map, const ImVec2& screen_pos, + ImVec2& out_display_uv) +{ + out_display_uv = ImVec2(0.0f, 0.0f); + if (!map.valid) + return false; + const float w = map.image_rect_max.x - map.image_rect_min.x; + const float h = map.image_rect_max.y - map.image_rect_min.y; + if (w <= 0.0f || h <= 0.0f) + return false; + const float u = (screen_pos.x - map.image_rect_min.x) / w; + const float v = (screen_pos.y - map.image_rect_min.y) / h; + out_display_uv = ImVec2(u, v); + return (u >= 0.0f && u <= 1.0f && v >= 0.0f && v <= 1.0f); +} + +bool +screen_to_source_uv(const ImageCoordinateMap& map, const ImVec2& screen_pos, + ImVec2& out_source_uv) +{ + ImVec2 display_uv(0.0f, 0.0f); + if (!screen_to_display_uv(map, screen_pos, display_uv)) + return false; + out_source_uv = display_uv_to_source_uv(display_uv, map.orientation); + out_source_uv.x = std::clamp(out_source_uv.x, 0.0f, 1.0f); + out_source_uv.y = std::clamp(out_source_uv.y, 0.0f, 1.0f); + return true; +} + +bool +source_uv_to_screen(const ImageCoordinateMap& map, const ImVec2& source_uv, + ImVec2& out_screen_pos) +{ + out_screen_pos = ImVec2(0.0f, 0.0f); + if (!map.valid) + return false; + const float w = map.image_rect_max.x - map.image_rect_min.x; + const float h = map.image_rect_max.y - map.image_rect_min.y; + if (w <= 0.0f || h <= 0.0f) + return false; + const ImVec2 display_uv = source_uv_to_display_uv(source_uv, + map.orientation); + out_screen_pos = ImVec2(map.image_rect_min.x + display_uv.x * w, + map.image_rect_min.y + display_uv.y * h); + return true; +} + +bool +point_in_rect(const ImVec2& pos, const ImVec2& rect_min, const ImVec2& rect_max) +{ + const float min_x = std::min(rect_min.x, rect_max.x); + const float max_x = std::max(rect_min.x, rect_max.x); + const float min_y = std::min(rect_min.y, rect_max.y); + const float max_y = std::max(rect_min.y, rect_max.y); + return pos.x >= min_x && pos.x <= max_x && pos.y >= min_y && pos.y <= max_y; +} + +ImVec2 +clamp_pos_to_rect(const ImVec2& pos, const ImVec2& rect_min, + const ImVec2& rect_max) +{ + const float min_x = std::min(rect_min.x, rect_max.x); + const float max_x = std::max(rect_min.x, rect_max.x); + const float min_y = std::min(rect_min.y, rect_max.y); + const float max_y = std::max(rect_min.y, rect_max.y); + return ImVec2(std::clamp(pos.x, min_x, max_x), + std::clamp(pos.y, min_y, max_y)); +} + +ImVec2 +rect_center(const ImVec2& rect_min, const ImVec2& rect_max) +{ + return ImVec2((rect_min.x + rect_max.x) * 0.5f, + (rect_min.y + rect_max.y) * 0.5f); +} + +ImVec2 +rect_size(const ImVec2& rect_min, const ImVec2& rect_max) +{ + return ImVec2(std::abs(rect_max.x - rect_min.x), + std::abs(rect_max.y - rect_min.y)); +} + +bool +viewport_axis_needs_scroll(float image_axis, float inner_axis) +{ + return image_axis > inner_axis + 0.01f; +} + +ImageViewportLayout +compute_image_viewport_layout(const ImVec2& child_size, const ImVec2& padding, + const ImVec2& image_size, float scrollbar_size) +{ + ImageViewportLayout layout; + layout.child_size = child_size; + layout.image_size = image_size; + + const ImVec2 base_inner(std::max(0.0f, child_size.x - padding.x * 2.0f), + std::max(0.0f, child_size.y - padding.y * 2.0f)); + bool scroll_x = viewport_axis_needs_scroll(image_size.x, base_inner.x); + bool scroll_y = viewport_axis_needs_scroll(image_size.y, base_inner.y); + for (int i = 0; i < 3; ++i) { + const ImVec2 inner( + std::max(0.0f, base_inner.x - (scroll_y ? scrollbar_size : 0.0f)), + std::max(0.0f, base_inner.y - (scroll_x ? scrollbar_size : 0.0f))); + const bool next_x = viewport_axis_needs_scroll(image_size.x, inner.x); + const bool next_y = viewport_axis_needs_scroll(image_size.y, inner.y); + layout.inner_size = inner; + if (next_x == scroll_x && next_y == scroll_y) + break; + scroll_x = next_x; + scroll_y = next_y; + } + layout.scroll_x = scroll_x; + layout.scroll_y = scroll_y; + layout.content_size.x = scroll_x ? (image_size.x + layout.inner_size.x) + : layout.inner_size.x; + layout.content_size.y = scroll_y ? (image_size.y + layout.inner_size.y) + : layout.inner_size.y; + return layout; +} + +void +sync_view_scroll_from_display_scroll(ViewerState& viewer, + const ImVec2& display_scroll, + const ImVec2& image_size) +{ + viewer.max_scroll = image_size; + viewer.scroll.x = std::clamp(display_scroll.x, 0.0f, + std::max(0.0f, image_size.x)); + viewer.scroll.y = std::clamp(display_scroll.y, 0.0f, + std::max(0.0f, image_size.y)); + viewer.norm_scroll.x = (image_size.x > 0.0f) + ? (viewer.scroll.x / image_size.x) + : 0.5f; + viewer.norm_scroll.y = (image_size.y > 0.0f) + ? (viewer.scroll.y / image_size.y) + : 0.5f; +} + +void +sync_view_scroll_from_source_uv(ViewerState& viewer, const ImVec2& source_uv, + int orientation, const ImVec2& image_size) +{ + const ImVec2 display_uv + = source_uv_to_display_uv(ImVec2(std::clamp(source_uv.x, 0.0f, 1.0f), + std::clamp(source_uv.y, 0.0f, 1.0f)), + orientation); + viewer.max_scroll = image_size; + viewer.norm_scroll = display_uv; + viewer.scroll = ImVec2(display_uv.x * image_size.x, + display_uv.y * image_size.y); +} + +void +queue_zoom_pivot(ViewerState& viewer, const ImVec2& anchor_screen, + const ImVec2& source_uv) +{ + viewer.zoom_pivot_screen = anchor_screen; + viewer.zoom_pivot_source_uv = ImVec2(std::clamp(source_uv.x, 0.0f, 1.0f), + std::clamp(source_uv.y, 0.0f, 1.0f)); + viewer.zoom_pivot_pending = true; + viewer.zoom_pivot_frames_left = 3; +} + +void +request_zoom_scale(PendingZoomRequest& request, float scale, bool prefer_mouse) +{ + request.scale *= scale; + request.prefer_mouse = request.prefer_mouse || prefer_mouse; +} + +void +request_zoom_reset(PendingZoomRequest& request, bool prefer_mouse) +{ + request.snap_to_one = true; + request.prefer_mouse = request.prefer_mouse || prefer_mouse; +} + +void +recenter_view(ViewerState& viewer, const ImVec2& image_size) +{ + viewer.zoom_pivot_pending = false; + viewer.zoom_pivot_frames_left = 0; + sync_view_scroll_from_display_scroll( + viewer, ImVec2(image_size.x * 0.5f, image_size.y * 0.5f), image_size); + viewer.scroll_sync_frames_left = std::max(viewer.scroll_sync_frames_left, + 2); +} + +float +compute_fit_zoom(const ImVec2& child_size, const ImVec2& padding, + int display_width, int display_height) +{ + if (display_width <= 0 || display_height <= 0) + return 1.0f; + + const ImVec2 fit_inner(std::max(1.0f, child_size.x - padding.x * 2.0f), + std::max(1.0f, child_size.y - padding.y * 2.0f)); + const float fit_x = fit_inner.x / static_cast(display_width); + const float fit_y = fit_inner.y / static_cast(display_height); + if (!(fit_x > 0.0f && fit_y > 0.0f)) + return 1.0f; + + const float fit_zoom = std::min(fit_x, fit_y); + return std::max(0.05f, std::nextafter(fit_zoom, 0.0f)); +} + +void +compute_zoom_pivot(const ImageCoordinateMap& map, const ImVec2& mouse_screen, + bool prefer_mouse_position, ImVec2& out_anchor_screen, + ImVec2& out_source_uv) +{ + out_anchor_screen = rect_center(map.viewport_rect_min, + map.viewport_rect_max); + if (prefer_mouse_position + && point_in_rect(mouse_screen, map.viewport_rect_min, + map.viewport_rect_max)) { + out_anchor_screen = mouse_screen; + } + + ImVec2 sample_screen = out_anchor_screen; + if (!point_in_rect(sample_screen, map.image_rect_min, map.image_rect_max)) { + sample_screen = clamp_pos_to_rect(sample_screen, map.image_rect_min, + map.image_rect_max); + } + + if (!screen_to_source_uv(map, sample_screen, out_source_uv)) + out_source_uv = ImVec2(0.5f, 0.5f); +} + +bool +apply_zoom_request(const ImageCoordinateMap& map, ViewerState& viewer, + PlaceholderUiState& ui_state, + const PendingZoomRequest& request, + const ImVec2& mouse_screen) +{ + if (!map.valid) + return false; + if (!request.snap_to_one && std::abs(request.scale - 1.0f) < 1.0e-6f) + return false; + + const float new_zoom = request.snap_to_one + ? 1.0f + : std::clamp(viewer.zoom * request.scale, 0.05f, + 64.0f); + if (std::abs(new_zoom - viewer.zoom) < 1.0e-6f) + return false; + + ImVec2 anchor_screen(0.0f, 0.0f); + ImVec2 source_uv(0.5f, 0.5f); + compute_zoom_pivot(map, mouse_screen, request.prefer_mouse, anchor_screen, + source_uv); + + viewer.zoom = new_zoom; + ui_state.fit_image_to_window = false; + viewer.fit_request = false; + queue_zoom_pivot(viewer, anchor_screen, source_uv); + return true; +} + +void +apply_pending_zoom_pivot(ViewerState& viewer, const ImageCoordinateMap& map, + const ImVec2& image_size, bool can_scroll_x, + bool can_scroll_y) +{ + if (!(viewer.zoom_pivot_pending || viewer.zoom_pivot_frames_left > 0)) + return; + const ImVec2 viewport_center = rect_center(map.viewport_rect_min, + map.viewport_rect_max); + const ImVec2 display_uv + = source_uv_to_display_uv(viewer.zoom_pivot_source_uv, map.orientation); + const ImVec2 new_scroll((viewport_center.x - viewer.zoom_pivot_screen.x) + + display_uv.x * image_size.x, + (viewport_center.y - viewer.zoom_pivot_screen.y) + + display_uv.y * image_size.y); + sync_view_scroll_from_display_scroll(viewer, new_scroll, image_size); + if (can_scroll_x) + ImGui::SetScrollX(viewer.scroll.x); + else + ImGui::SetScrollX(0.0f); + if (can_scroll_y) + ImGui::SetScrollY(viewer.scroll.y); + else + ImGui::SetScrollY(0.0f); + viewer.zoom_pivot_pending = false; + if (viewer.zoom_pivot_frames_left > 0) + --viewer.zoom_pivot_frames_left; +} + +bool +source_uv_to_pixel(const ImageCoordinateMap& map, const ImVec2& source_uv, + int& out_px, int& out_py) +{ + out_px = 0; + out_py = 0; + if (!map.valid || map.source_width <= 0 || map.source_height <= 0) + return false; + + const float u = std::clamp(source_uv.x, 0.0f, 1.0f); + const float v = std::clamp(source_uv.y, 0.0f, 1.0f); + out_px = std::clamp(static_cast(std::floor( + u * static_cast(map.source_width))), + 0, map.source_width - 1); + out_py = std::clamp(static_cast(std::floor( + v * static_cast(map.source_height))), + 0, map.source_height - 1); + return true; +} + + + +} // namespace Imiv diff --git a/src/imiv/imiv_navigation.h b/src/imiv/imiv_navigation.h new file mode 100644 index 0000000000..ca6a8c3c57 --- /dev/null +++ b/src/imiv/imiv_navigation.h @@ -0,0 +1,108 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_viewer.h" + +namespace Imiv { + +struct ImageCoordinateMap { + bool valid = false; + int source_width = 0; + int source_height = 0; + int orientation = 1; + ImVec2 image_rect_min = ImVec2(0.0f, 0.0f); + ImVec2 image_rect_max = ImVec2(0.0f, 0.0f); + ImVec2 viewport_rect_min = ImVec2(0.0f, 0.0f); + ImVec2 viewport_rect_max = ImVec2(0.0f, 0.0f); + ImVec2 window_pos = ImVec2(0.0f, 0.0f); +}; + +struct ImageViewportLayout { + ImVec2 child_size = ImVec2(0.0f, 0.0f); + ImVec2 inner_size = ImVec2(0.0f, 0.0f); + ImVec2 content_size = ImVec2(0.0f, 0.0f); + ImVec2 image_size = ImVec2(0.0f, 0.0f); + bool scroll_x = false; + bool scroll_y = false; +}; + +struct PendingZoomRequest { + float scale = 1.0f; + bool snap_to_one = false; + bool prefer_mouse = false; +}; + +ImVec2 +source_uv_to_display_uv(const ImVec2& src_uv, int orientation); +ImVec2 +display_uv_to_source_uv(const ImVec2& display_uv, int orientation); +ImVec2 +screen_to_window_coords(const ImageCoordinateMap& map, + const ImVec2& screen_pos); +ImVec2 +window_to_screen_coords(const ImageCoordinateMap& map, + const ImVec2& window_pos); +bool +screen_to_display_uv(const ImageCoordinateMap& map, const ImVec2& screen_pos, + ImVec2& out_display_uv); +bool +screen_to_source_uv(const ImageCoordinateMap& map, const ImVec2& screen_pos, + ImVec2& out_source_uv); +bool +source_uv_to_screen(const ImageCoordinateMap& map, const ImVec2& source_uv, + ImVec2& out_screen_pos); +bool +point_in_rect(const ImVec2& pos, const ImVec2& rect_min, + const ImVec2& rect_max); +ImVec2 +clamp_pos_to_rect(const ImVec2& pos, const ImVec2& rect_min, + const ImVec2& rect_max); +ImVec2 +rect_center(const ImVec2& rect_min, const ImVec2& rect_max); +ImVec2 +rect_size(const ImVec2& rect_min, const ImVec2& rect_max); +bool +viewport_axis_needs_scroll(float image_axis, float inner_axis); +ImageViewportLayout +compute_image_viewport_layout(const ImVec2& child_size, const ImVec2& padding, + const ImVec2& image_size, float scrollbar_size); +void +sync_view_scroll_from_display_scroll(ViewerState& viewer, + const ImVec2& display_scroll, + const ImVec2& image_size); +void +sync_view_scroll_from_source_uv(ViewerState& viewer, const ImVec2& source_uv, + int orientation, const ImVec2& image_size); +void +queue_zoom_pivot(ViewerState& viewer, const ImVec2& anchor_screen, + const ImVec2& source_uv); +void +request_zoom_scale(PendingZoomRequest& request, float scale, bool prefer_mouse); +void +request_zoom_reset(PendingZoomRequest& request, bool prefer_mouse); +void +recenter_view(ViewerState& viewer, const ImVec2& image_size); +float +compute_fit_zoom(const ImVec2& child_size, const ImVec2& padding, + int display_width, int display_height); +void +compute_zoom_pivot(const ImageCoordinateMap& map, const ImVec2& mouse_screen, + bool prefer_mouse_position, ImVec2& out_anchor_screen, + ImVec2& out_source_uv); +bool +apply_zoom_request(const ImageCoordinateMap& map, ViewerState& viewer, + PlaceholderUiState& ui_state, + const PendingZoomRequest& request, + const ImVec2& mouse_screen); +void +apply_pending_zoom_pivot(ViewerState& viewer, const ImageCoordinateMap& map, + const ImVec2& image_size, bool can_scroll_x, + bool can_scroll_y); +bool +source_uv_to_pixel(const ImageCoordinateMap& map, const ImVec2& source_uv, + int& out_px, int& out_py); + +} // namespace Imiv diff --git a/src/imiv/imiv_ocio.cpp b/src/imiv/imiv_ocio.cpp new file mode 100644 index 0000000000..cf92f0531a --- /dev/null +++ b/src/imiv/imiv_ocio.cpp @@ -0,0 +1,1139 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_ocio.h" +#include "imiv_shader_compile.h" + +#include +#include +#include + +#include +#include +#include + +namespace Imiv { + +namespace { + + std::string builtin_ocio_config_path() { return "ocio://default"; } + + OcioConfigSource ocio_config_source_from_int(int value) + { + value = std::clamp(value, static_cast(OcioConfigSource::Global), + static_cast(OcioConfigSource::User)); + return static_cast(value); + } + + bool ocio_source_string_is_usable(std::string_view value) + { + const std::string trimmed = std::string(OIIO::Strutil::strip(value)); + if (trimmed.empty()) + return false; + if (OIIO::Strutil::istarts_with(trimmed, "ocio://")) + return true; + std::error_code ec; + return std::filesystem::exists(std::filesystem::path(trimmed), ec) + && !ec; + } + + OcioUniformType map_ocio_uniform_type(OCIO::UniformDataType type) + { + switch (type) { + case OCIO::UNIFORM_DOUBLE: return OcioUniformType::Double; + case OCIO::UNIFORM_BOOL: return OcioUniformType::Bool; + case OCIO::UNIFORM_FLOAT3: return OcioUniformType::Float3; + case OCIO::UNIFORM_VECTOR_FLOAT: return OcioUniformType::VectorFloat; + case OCIO::UNIFORM_VECTOR_INT: return OcioUniformType::VectorInt; + case OCIO::UNIFORM_UNKNOWN: + default: return OcioUniformType::Unknown; + } + } + + OcioTextureChannel + map_ocio_texture_channel(OCIO::GpuShaderDesc::TextureType type) + { + if (type == OCIO::GpuShaderDesc::TEXTURE_RED_CHANNEL) + return OcioTextureChannel::Red; + return OcioTextureChannel::RGB; + } + + OcioTextureDimensions map_ocio_texture_dimensions( + OCIO::GpuShaderCreator::TextureDimensions dimensions, unsigned height) + { + if (dimensions == OCIO::GpuShaderCreator::TextureDimensions::TEXTURE_1D) + return OcioTextureDimensions::Tex1D; + if (height <= 1) + return OcioTextureDimensions::Tex1D; + return OcioTextureDimensions::Tex2D; + } + + OcioInterpolation map_ocio_interpolation(OCIO::Interpolation interpolation) + { + if (interpolation == OCIO::INTERP_NEAREST) + return OcioInterpolation::Nearest; + if (interpolation == OCIO::INTERP_LINEAR) + return OcioInterpolation::Linear; + return OcioInterpolation::Unknown; + } + + std::string + resolve_scene_linear_color_space(const OCIO::ConstConfigRcPtr& config) + { + if (!config) + return "scene_linear"; + OCIO::ConstColorSpaceRcPtr color_space = config->getColorSpace( + "scene_linear"); + if (color_space != nullptr && color_space->getName() != nullptr) + return color_space->getName(); + const int num_color_spaces = config->getNumColorSpaces(); + if (num_color_spaces > 0) { + const char* fallback = config->getColorSpaceNameByIndex(0); + if (fallback != nullptr && fallback[0] != '\0') + return fallback; + } + return "scene_linear"; + } + + bool load_ocio_config_from_selection(const OcioConfigSelection& selection, + OCIO::ConstConfigRcPtr& config, + std::string& error_message) + { + error_message.clear(); + config.reset(); + try { + if (selection.resolved_path.empty()) { + error_message = "OCIO config path is empty"; + return false; + } + config = OCIO::Config::CreateFromFile( + selection.resolved_path.c_str()); + } catch (const OCIO::Exception& e) { + error_message = e.what(); + return false; + } + + if (!config) { + error_message = "OCIO config is unavailable"; + return false; + } + return true; + } + + std::string match_config_color_space(const OCIO::ConstConfigRcPtr& config, + std::string_view candidate) + { + const std::string trimmed = std::string( + OIIO::Strutil::strip(candidate)); + if (trimmed.empty() || !config) + return std::string(); + + OCIO::ConstColorSpaceRcPtr exact = config->getColorSpace( + trimmed.c_str()); + if (exact != nullptr && exact->getName() != nullptr) + return exact->getName(); + + const int num_color_spaces = config->getNumColorSpaces(); + for (int i = 0; i < num_color_spaces; ++i) { + const char* config_name = config->getColorSpaceNameByIndex(i); + if (config_name == nullptr || config_name[0] == '\0') + continue; + if (OIIO::equivalent_colorspace(trimmed, config_name)) + return config_name; + } + return std::string(); + } + + bool config_has_display(const OCIO::ConstConfigRcPtr& config, + std::string_view display_name) + { + if (!config) + return false; + const std::string trimmed = std::string( + OIIO::Strutil::strip(display_name)); + if (trimmed.empty()) + return false; + const int num_displays = config->getNumDisplays(); + for (int i = 0; i < num_displays; ++i) { + const char* config_display = config->getDisplay(i); + if (config_display == nullptr || config_display[0] == '\0') + continue; + if (trimmed == config_display) + return true; + } + return false; + } + + bool config_has_view(const OCIO::ConstConfigRcPtr& config, + std::string_view display_name, + std::string_view view_name) + { + if (!config) + return false; + const std::string display = std::string( + OIIO::Strutil::strip(display_name)); + const std::string view = std::string(OIIO::Strutil::strip(view_name)); + if (display.empty() || view.empty()) + return false; + const int num_views = config->getNumViews(display.c_str()); + for (int i = 0; i < num_views; ++i) { + const char* config_view = config->getView(display.c_str(), i); + if (config_view == nullptr || config_view[0] == '\0') + continue; + if (view == config_view) + return true; + } + return false; + } + + std::string + resolve_default_display_name(const OCIO::ConstConfigRcPtr& config) + { + if (!config) + return {}; + const char* display_name = config->getDefaultDisplay(); + if (display_name != nullptr && display_name[0] != '\0') + return display_name; + const int num_displays = config->getNumDisplays(); + for (int i = 0; i < num_displays; ++i) { + const char* fallback_name = config->getDisplay(i); + if (fallback_name != nullptr && fallback_name[0] != '\0') + return fallback_name; + } + return {}; + } + + std::string resolve_default_view_name(const OCIO::ConstConfigRcPtr& config, + std::string_view display_name) + { + if (!config) + return {}; + const std::string display = std::string( + OIIO::Strutil::strip(display_name)); + if (display.empty()) + return {}; + const char* view_name = config->getDefaultView(display.c_str()); + if (view_name != nullptr && view_name[0] != '\0') + return view_name; + const int num_views = config->getNumViews(display.c_str()); + for (int i = 0; i < num_views; ++i) { + const char* fallback_name = config->getView(display.c_str(), i); + if (fallback_name != nullptr && fallback_name[0] != '\0') + return fallback_name; + } + return {}; + } + + std::string resolve_input_color_space(const PlaceholderUiState& ui_state, + const LoadedImage* image, + const OCIO::ConstConfigRcPtr& config) + { + if (!ui_state.ocio_image_color_space.empty() + && ui_state.ocio_image_color_space != "auto") { + const std::string matched + = match_config_color_space(config, + ui_state.ocio_image_color_space); + if (!matched.empty()) + return matched; + } + + if (image != nullptr) { + const std::string matched + = match_config_color_space(config, image->metadata_color_space); + if (!matched.empty()) + return matched; + } + + return resolve_scene_linear_color_space(config); + } + + std::string resolve_display_name(const PlaceholderUiState& ui_state, + const OCIO::ConstConfigRcPtr& config) + { + if (!ui_state.ocio_display.empty() && ui_state.ocio_display != "default" + && config_has_display(config, ui_state.ocio_display)) { + return ui_state.ocio_display; + } + return resolve_default_display_name(config); + } + + std::string resolve_view_name(const PlaceholderUiState& ui_state, + const OCIO::ConstConfigRcPtr& config, + const std::string& display_name) + { + if (!ui_state.ocio_view.empty() && ui_state.ocio_view != "default" + && config_has_view(config, display_name, ui_state.ocio_view)) { + return ui_state.ocio_view; + } + return resolve_default_view_name(config, display_name); + } + + bool write_uniform_bytes(std::vector& uniform_bytes, + size_t offset, const void* src, size_t size, + std::string& error_message) + { + if (src == nullptr || size == 0) + return true; + if (offset + size > uniform_bytes.size()) { + error_message = OIIO::Strutil::fmt::format( + "OCIO uniform write overflow at offset {} size {} buffer {}", + offset, size, uniform_bytes.size()); + return false; + } + std::memcpy(uniform_bytes.data() + offset, src, size); + return true; + } + + bool + populate_ocio_shader_blueprint(const PlaceholderUiState& ui_state, + const OCIO::ConstProcessorRcPtr& processor, + const OCIO::GpuShaderDescRcPtr& shader_desc, + OcioShaderBlueprint& blueprint, + std::string& error_message) + { + blueprint.enabled = ui_state.use_ocio; + blueprint.valid = true; + blueprint.wrapper_offset = ui_state.offset; + blueprint.uniform_buffer_size = shader_desc->getUniformBufferSize(); + blueprint.processor_cache_id = processor->getCacheID() + ? processor->getCacheID() + : std::string(); + blueprint.shader_cache_id = shader_desc->getCacheID() + ? shader_desc->getCacheID() + : std::string(); + blueprint.shader_text = shader_desc->getShaderText() + ? shader_desc->getShaderText() + : std::string(); + blueprint.has_dynamic_exposure = shader_desc->hasDynamicProperty( + OCIO::DYNAMIC_PROPERTY_EXPOSURE); + blueprint.has_dynamic_gamma = shader_desc->hasDynamicProperty( + OCIO::DYNAMIC_PROPERTY_GAMMA); + + const unsigned num_uniforms = shader_desc->getNumUniforms(); + blueprint.uniforms.clear(); + blueprint.uniforms.reserve(num_uniforms); + for (unsigned idx = 0; idx < num_uniforms; ++idx) { + OCIO::GpuShaderDesc::UniformData data; + const char* name = shader_desc->getUniform(idx, data); + OcioUniformBlueprint uniform; + if (name) + uniform.name = name; + uniform.type = map_ocio_uniform_type(data.m_type); + uniform.buffer_offset = data.m_bufferOffset; + blueprint.uniforms.push_back(std::move(uniform)); + } + + const unsigned num_textures = shader_desc->getNumTextures(); + blueprint.textures.clear(); + blueprint.textures.reserve(num_textures + + shader_desc->getNum3DTextures()); + for (unsigned idx = 0; idx < num_textures; ++idx) { + const char* texture_name = nullptr; + const char* sampler_name = nullptr; + unsigned width = 0; + unsigned height = 0; + OCIO::GpuShaderDesc::TextureType channel + = OCIO::GpuShaderDesc::TEXTURE_RGB_CHANNEL; + OCIO::GpuShaderCreator::TextureDimensions dimensions + = OCIO::GpuShaderCreator::TextureDimensions::TEXTURE_2D; + OCIO::Interpolation interpolation = OCIO::INTERP_DEFAULT; + shader_desc->getTexture(idx, texture_name, sampler_name, width, + height, channel, dimensions, interpolation); + const float* values = nullptr; + shader_desc->getTextureValues(idx, values); + + OcioTextureBlueprint texture; + if (texture_name) + texture.texture_name = texture_name; + if (sampler_name) + texture.sampler_name = sampler_name; + texture.shader_binding = shader_desc->getTextureShaderBindingIndex( + idx); + texture.width = width; + texture.height = height; + texture.depth = 1; + texture.channel = map_ocio_texture_channel(channel); + texture.dimensions = map_ocio_texture_dimensions(dimensions, + height); + texture.interpolation = map_ocio_interpolation(interpolation); + if (values) { + const size_t texel_count + = static_cast(width) * static_cast(height) + * static_cast( + channel == OCIO::GpuShaderDesc::TEXTURE_RED_CHANNEL + ? 1 + : 3); + texture.values.assign(values, values + texel_count); + } + blueprint.textures.push_back(std::move(texture)); + } + + const unsigned num_textures_3d = shader_desc->getNum3DTextures(); + for (unsigned idx = 0; idx < num_textures_3d; ++idx) { + const char* texture_name = nullptr; + const char* sampler_name = nullptr; + unsigned edge_len = 0; + OCIO::Interpolation interpolation = OCIO::INTERP_DEFAULT; + shader_desc->get3DTexture(idx, texture_name, sampler_name, edge_len, + interpolation); + const float* values = nullptr; + shader_desc->get3DTextureValues(idx, values); + + OcioTextureBlueprint texture; + if (texture_name) + texture.texture_name = texture_name; + if (sampler_name) + texture.sampler_name = sampler_name; + texture.shader_binding + = shader_desc->get3DTextureShaderBindingIndex(idx); + texture.width = edge_len; + texture.height = edge_len; + texture.depth = edge_len; + texture.channel = OcioTextureChannel::RGB; + texture.dimensions = OcioTextureDimensions::Tex3D; + texture.interpolation = map_ocio_interpolation(interpolation); + if (values) { + const size_t texel_count = static_cast(edge_len) + * static_cast(edge_len) + * static_cast(edge_len) * 3u; + texture.values.assign(values, values + texel_count); + } + blueprint.textures.push_back(std::move(texture)); + } + + if (blueprint.display.empty() || blueprint.view.empty()) { + error_message = "OCIO display/view resolution failed"; + return false; + } + return true; + } + + bool build_ocio_shader_runtime_internal(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderTarget target, + OcioShaderRuntime& runtime, + std::string& error_message) + { + runtime = {}; + runtime.enabled = ui_state.use_ocio; + runtime.target = target; + runtime.blueprint.enabled = ui_state.use_ocio; + runtime.blueprint.wrapper_offset = ui_state.offset; + + error_message.clear(); + if (!ui_state.use_ocio) + return true; + + OcioConfigSelection config_selection; + resolve_ocio_config_selection(ui_state, config_selection); + if (!load_ocio_config_from_selection(config_selection, runtime.config, + error_message)) { + return false; + } + + runtime.blueprint.config_selection_key = config_selection.selection_key; + runtime.blueprint.input_color_space + = resolve_input_color_space(ui_state, image, runtime.config); + runtime.blueprint.display = resolve_display_name(ui_state, + runtime.config); + runtime.blueprint.view = resolve_view_name(ui_state, runtime.config, + runtime.blueprint.display); + if (runtime.blueprint.display.empty() + || runtime.blueprint.view.empty()) { + error_message = "Failed to resolve OCIO display/view"; + return false; + } + + OCIO::ConstColorSpaceRcPtr scene_linear_space + = runtime.config->getColorSpace("scene_linear"); + if (!scene_linear_space) { + error_message = "Missing 'scene_linear' color space"; + return false; + } + + OCIO::ColorSpaceTransformRcPtr input_transform + = OCIO::ColorSpaceTransform::Create(); + input_transform->setSrc(runtime.blueprint.input_color_space.c_str()); + input_transform->setDst(scene_linear_space->getName()); + + OCIO::ExposureContrastTransformRcPtr exposure_transform + = OCIO::ExposureContrastTransform::Create(); + exposure_transform->makeExposureDynamic(); + + OCIO::DisplayViewTransformRcPtr display_transform + = OCIO::DisplayViewTransform::Create(); + display_transform->setSrc(scene_linear_space->getName()); + display_transform->setDisplay(runtime.blueprint.display.c_str()); + display_transform->setView(runtime.blueprint.view.c_str()); + + OCIO::ExposureContrastTransformRcPtr gamma_transform + = OCIO::ExposureContrastTransform::Create(); + gamma_transform->makeGammaDynamic(); + gamma_transform->setPivot(1.0); + + OCIO::GroupTransformRcPtr group_transform + = OCIO::GroupTransform::Create(); + group_transform->appendTransform(input_transform); + group_transform->appendTransform(exposure_transform); + group_transform->appendTransform(display_transform); + group_transform->appendTransform(gamma_transform); + + runtime.processor = runtime.config->getProcessor(group_transform); + runtime.gpu_processor = runtime.processor->getOptimizedGPUProcessor( + OCIO::OPTIMIZATION_DEFAULT); + + runtime.shader_desc = OCIO::GpuShaderDesc::CreateShaderDesc(); + if (target == OcioShaderTarget::OpenGL) + runtime.shader_desc->setLanguage(OCIO::GPU_LANGUAGE_GLSL_1_3); + else if (target == OcioShaderTarget::Metal) + runtime.shader_desc->setLanguage(OCIO::GPU_LANGUAGE_MSL_2_0); + else + runtime.shader_desc->setLanguage(OCIO::GPU_LANGUAGE_GLSL_VK_4_6); + runtime.shader_desc->setFunctionName( + runtime.blueprint.function_name.c_str()); + runtime.shader_desc->setResourcePrefix( + runtime.blueprint.resource_prefix.c_str()); + if (target == OcioShaderTarget::Vulkan) { + runtime.shader_desc->setAllowTexture1D(false); + runtime.shader_desc->setDescriptorSetIndex( + runtime.blueprint.descriptor_set_index, + runtime.blueprint.texture_binding_start); + } + runtime.gpu_processor->extractGpuShaderInfo(runtime.shader_desc); + + if (!populate_ocio_shader_blueprint(ui_state, runtime.processor, + runtime.shader_desc, + runtime.blueprint, error_message)) { + runtime = {}; + return false; + } + + if (runtime.shader_desc->hasDynamicProperty( + OCIO::DYNAMIC_PROPERTY_GAMMA)) { + OCIO::DynamicPropertyRcPtr prop + = runtime.shader_desc->getDynamicProperty( + OCIO::DYNAMIC_PROPERTY_GAMMA); + runtime.gamma_property = OCIO::DynamicPropertyValue::AsDouble(prop); + } + if (runtime.shader_desc->hasDynamicProperty( + OCIO::DYNAMIC_PROPERTY_EXPOSURE)) { + OCIO::DynamicPropertyRcPtr prop + = runtime.shader_desc->getDynamicProperty( + OCIO::DYNAMIC_PROPERTY_EXPOSURE); + runtime.exposure_property = OCIO::DynamicPropertyValue::AsDouble( + prop); + } + return true; + } + +} // namespace + +void +reset_ocio_shader_blueprint(OcioShaderBlueprint& blueprint) +{ + blueprint = {}; +} + +const char* +ocio_config_source_name(OcioConfigSource source) +{ + switch (source) { + case OcioConfigSource::Global: return "global"; + case OcioConfigSource::BuiltIn: return "builtin"; + case OcioConfigSource::User: return "user"; + default: return "global"; + } +} + +void +resolve_ocio_config_selection(const PlaceholderUiState& ui_state, + OcioConfigSelection& selection) +{ + selection = {}; + selection.requested_source = ocio_config_source_from_int( + ui_state.ocio_config_source); + selection.env_value = std::string( + OIIO::Strutil::strip(OIIO::Sysutil::getenv("OCIO"))); + const bool env_is_usable = ocio_source_string_is_usable( + selection.env_value); + + const std::string user_path = std::string( + OIIO::Strutil::strip(ui_state.ocio_user_config_path)); + const bool user_exists = ocio_source_string_is_usable(user_path); + + switch (selection.requested_source) { + case OcioConfigSource::Global: + if (env_is_usable) { + selection.resolved_source = OcioConfigSource::Global; + selection.resolved_path = selection.env_value; + } else { + selection.resolved_source = OcioConfigSource::BuiltIn; + selection.resolved_path = builtin_ocio_config_path(); + selection.fallback_applied = true; + } + break; + case OcioConfigSource::BuiltIn: + selection.resolved_source = OcioConfigSource::BuiltIn; + selection.resolved_path = builtin_ocio_config_path(); + break; + case OcioConfigSource::User: + if (user_exists) { + selection.resolved_source = OcioConfigSource::User; + if (OIIO::Strutil::istarts_with(user_path, "ocio://")) { + selection.resolved_path = user_path; + } else { + selection.resolved_path = std::filesystem::path(user_path) + .lexically_normal() + .string(); + } + } else { + selection.resolved_source = OcioConfigSource::BuiltIn; + selection.resolved_path = builtin_ocio_config_path(); + selection.fallback_applied = true; + } + break; + default: selection.resolved_source = OcioConfigSource::Global; break; + } + + selection.selection_key = OIIO::Strutil::fmt::format( + "{}:{}", ocio_config_source_name(selection.resolved_source), + selection.resolved_path); +} + +void +destroy_ocio_shader_runtime(OcioShaderRuntime*& runtime) +{ + delete runtime; + runtime = nullptr; +} + +bool +build_ocio_shader_blueprint(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderBlueprint& blueprint, + std::string& error_message) +{ + OcioShaderRuntime runtime; + try { + if (!build_ocio_shader_runtime_internal(ui_state, image, + OcioShaderTarget::Vulkan, + runtime, error_message)) { + reset_ocio_shader_blueprint(blueprint); + blueprint.enabled = ui_state.use_ocio; + return false; + } + blueprint = runtime.blueprint; + return true; + } catch (const OCIO::Exception& e) { + error_message = e.what(); + reset_ocio_shader_blueprint(blueprint); + blueprint.enabled = ui_state.use_ocio; + return false; + } +} + +namespace { + + bool ensure_ocio_shader_runtime_for_target( + const PlaceholderUiState& ui_state, const LoadedImage* image, + OcioShaderTarget target, OcioShaderRuntime*& runtime, + std::string& error_message) + { + error_message.clear(); + if (!ui_state.use_ocio) { + destroy_ocio_shader_runtime(runtime); + return true; + } + + OcioConfigSelection config_selection; + resolve_ocio_config_selection(ui_state, config_selection); + OCIO::ConstConfigRcPtr config; + if (!load_ocio_config_from_selection(config_selection, config, + error_message)) { + return false; + } + const std::string desired_input + = resolve_input_color_space(ui_state, image, config); + if (runtime != nullptr && runtime->target == target + && runtime->blueprint.config_selection_key + == config_selection.selection_key + && runtime->blueprint.input_color_space == desired_input + && ((ui_state.ocio_display.empty() + && runtime->blueprint.display.empty()) + || ui_state.ocio_display == "default" + || runtime->blueprint.display == ui_state.ocio_display) + && ((ui_state.ocio_view.empty() && runtime->blueprint.view.empty()) + || ui_state.ocio_view == "default" + || runtime->blueprint.view == ui_state.ocio_view)) { + runtime->blueprint.wrapper_offset = ui_state.offset; + return true; + } + + OcioShaderRuntime* rebuilt = new OcioShaderRuntime(); + try { + if (!build_ocio_shader_runtime_internal(ui_state, image, target, + *rebuilt, error_message)) { + delete rebuilt; + return false; + } + } catch (const OCIO::Exception& e) { + error_message = e.what(); + delete rebuilt; + return false; + } + + destroy_ocio_shader_runtime(runtime); + runtime = rebuilt; + return true; + } + +} // namespace + +bool +ensure_ocio_shader_runtime(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderRuntime*& runtime, + std::string& error_message) +{ + return ensure_ocio_shader_runtime_for_target(ui_state, image, + OcioShaderTarget::Vulkan, + runtime, error_message); +} + +bool +ensure_ocio_shader_runtime_glsl(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderRuntime*& runtime, + std::string& error_message) +{ + return ensure_ocio_shader_runtime_for_target(ui_state, image, + OcioShaderTarget::OpenGL, + runtime, error_message); +} + +bool +ensure_ocio_shader_runtime_metal(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderRuntime*& runtime, + std::string& error_message) +{ + return ensure_ocio_shader_runtime_for_target(ui_state, image, + OcioShaderTarget::Metal, + runtime, error_message); +} + +bool +build_ocio_uniform_buffer(OcioShaderRuntime& runtime, + const PreviewControls& controls, + std::vector& uniform_bytes, + std::string& error_message) +{ + error_message.clear(); + uniform_bytes.assign(runtime.blueprint.uniform_buffer_size, 0); + if (runtime.shader_desc == nullptr) + return true; + + if (runtime.exposure_property) { + runtime.exposure_property->setValue( + static_cast(controls.exposure)); + } + if (runtime.gamma_property) { + const double gamma + = 1.0 / std::max(1.0e-6, static_cast(controls.gamma)); + runtime.gamma_property->setValue(gamma); + } + + const unsigned num_uniforms = runtime.shader_desc->getNumUniforms(); + for (unsigned idx = 0; idx < num_uniforms; ++idx) { + OCIO::GpuShaderDesc::UniformData data; + runtime.shader_desc->getUniform(idx, data); + switch (data.m_type) { + case OCIO::UNIFORM_DOUBLE: { + const float value = data.m_getDouble + ? static_cast(data.m_getDouble()) + : 0.0f; + if (!write_uniform_bytes(uniform_bytes, data.m_bufferOffset, &value, + sizeof(value), error_message)) { + return false; + } + break; + } + case OCIO::UNIFORM_BOOL: { + const int32_t value = data.m_getBool && data.m_getBool() ? 1 : 0; + if (!write_uniform_bytes(uniform_bytes, data.m_bufferOffset, &value, + sizeof(value), error_message)) { + return false; + } + break; + } + case OCIO::UNIFORM_FLOAT3: { + float value[3] = { 0.0f, 0.0f, 0.0f }; + if (data.m_getFloat3) { + const OCIO::Float3& src = data.m_getFloat3(); + value[0] = src[0]; + value[1] = src[1]; + value[2] = src[2]; + } + if (!write_uniform_bytes(uniform_bytes, data.m_bufferOffset, value, + sizeof(value), error_message)) { + return false; + } + break; + } + case OCIO::UNIFORM_VECTOR_FLOAT: { + const int count = data.m_vectorFloat.m_getSize + ? data.m_vectorFloat.m_getSize() + : 0; + const float* src = data.m_vectorFloat.m_getVector + ? data.m_vectorFloat.m_getVector() + : nullptr; + for (int i = 0; i < count; ++i) { + const size_t elem_offset = data.m_bufferOffset + + static_cast(i) * 16u; + const float value = src ? src[i] : 0.0f; + if (!write_uniform_bytes(uniform_bytes, elem_offset, &value, + sizeof(value), error_message)) { + return false; + } + } + break; + } + case OCIO::UNIFORM_VECTOR_INT: { + const int count = data.m_vectorInt.m_getSize + ? data.m_vectorInt.m_getSize() + : 0; + const int* src = data.m_vectorInt.m_getVector + ? data.m_vectorInt.m_getVector() + : nullptr; + for (int i = 0; i < count; ++i) { + const size_t elem_offset = data.m_bufferOffset + + static_cast(i) * 16u; + const int32_t value = src ? src[i] : 0; + if (!write_uniform_bytes(uniform_bytes, elem_offset, &value, + sizeof(value), error_message)) { + return false; + } + } + break; + } + case OCIO::UNIFORM_UNKNOWN: + default: break; + } + } + return true; +} + +bool +query_ocio_menu_data(const PlaceholderUiState& ui_state, + std::vector& image_color_spaces, + std::vector& displays, + std::vector& views, + std::string& resolved_display, std::string& resolved_view, + std::string& error_message) +{ + image_color_spaces.clear(); + displays.clear(); + views.clear(); + resolved_display.clear(); + resolved_view.clear(); + error_message.clear(); + + try { + OcioConfigSelection config_selection; + resolve_ocio_config_selection(ui_state, config_selection); + OCIO::ConstConfigRcPtr config; + if (!load_ocio_config_from_selection(config_selection, config, + error_message)) { + return false; + } + + const int num_color_spaces = config->getNumColorSpaces(); + image_color_spaces.reserve(static_cast(num_color_spaces) + 1u); + image_color_spaces.emplace_back("auto"); + for (int i = 0; i < num_color_spaces; ++i) { + const char* color_space = config->getColorSpaceNameByIndex(i); + if (color_space != nullptr && color_space[0] != '\0') + image_color_spaces.emplace_back(color_space); + } + + const int num_displays = config->getNumDisplays(); + displays.reserve(static_cast(num_displays)); + for (int i = 0; i < num_displays; ++i) { + const char* display_name = config->getDisplay(i); + if (display_name != nullptr && display_name[0] != '\0') + displays.emplace_back(display_name); + } + + resolved_display = resolve_display_name(ui_state, config); + resolved_view = resolve_view_name(ui_state, config, resolved_display); + if (!resolved_display.empty()) { + const int num_views = config->getNumViews(resolved_display.c_str()); + views.reserve(static_cast(num_views)); + for (int i = 0; i < num_views; ++i) { + const char* view_name + = config->getView(resolved_display.c_str(), i); + if (view_name != nullptr && view_name[0] != '\0') + views.emplace_back(view_name); + } + } + return true; + } catch (const OCIO::Exception& e) { + error_message = e.what(); + return false; + } +} + +bool +build_ocio_cpu_display_processor(const PlaceholderUiState& ui_state, + const LoadedImage* image, double exposure, + double gamma, + OCIO::ConstProcessorRcPtr& processor, + std::string& resolved_display, + std::string& resolved_view, + std::string& error_message) +{ + processor.reset(); + resolved_display.clear(); + resolved_view.clear(); + error_message.clear(); + + try { + OcioConfigSelection config_selection; + resolve_ocio_config_selection(ui_state, config_selection); + OCIO::ConstConfigRcPtr config; + if (!load_ocio_config_from_selection(config_selection, config, + error_message)) { + return false; + } + + const std::string input_color_space + = resolve_input_color_space(ui_state, image, config); + resolved_display = resolve_display_name(ui_state, config); + resolved_view = resolve_view_name(ui_state, config, resolved_display); + if (input_color_space.empty() || resolved_display.empty() + || resolved_view.empty()) { + error_message = "Failed to resolve OCIO export transform"; + return false; + } + + OCIO::ConstColorSpaceRcPtr scene_linear_space = config->getColorSpace( + "scene_linear"); + if (!scene_linear_space) { + error_message = "Missing 'scene_linear' color space"; + return false; + } + + OCIO::ColorSpaceTransformRcPtr input_transform + = OCIO::ColorSpaceTransform::Create(); + input_transform->setSrc(input_color_space.c_str()); + input_transform->setDst(scene_linear_space->getName()); + + OCIO::ExposureContrastTransformRcPtr exposure_transform + = OCIO::ExposureContrastTransform::Create(); + exposure_transform->setExposure(exposure); + + OCIO::DisplayViewTransformRcPtr display_transform + = OCIO::DisplayViewTransform::Create(); + display_transform->setSrc(scene_linear_space->getName()); + display_transform->setDisplay(resolved_display.c_str()); + display_transform->setView(resolved_view.c_str()); + + OCIO::ExposureContrastTransformRcPtr gamma_transform + = OCIO::ExposureContrastTransform::Create(); + gamma_transform->setGamma(std::max(1.0e-6, gamma)); + gamma_transform->setPivot(1.0); + + OCIO::GroupTransformRcPtr group_transform + = OCIO::GroupTransform::Create(); + group_transform->appendTransform(input_transform); + group_transform->appendTransform(exposure_transform); + group_transform->appendTransform(display_transform); + group_transform->appendTransform(gamma_transform); + + processor = config->getProcessor(group_transform); + if (!processor) { + error_message = "OCIO CPU display processor is unavailable"; + return false; + } + return true; + } catch (const OCIO::Exception& e) { + error_message = e.what(); + return false; + } +} + +bool +build_ocio_preview_fragment_source(const OcioShaderBlueprint& blueprint, + std::string& shader_source, + std::string& error_message) +{ + shader_source.clear(); + error_message.clear(); + if (!blueprint.enabled) { + error_message = "OCIO preview source requested while OCIO is disabled"; + return false; + } + if (!blueprint.valid) { + error_message + = "OCIO preview source requested without a valid blueprint"; + return false; + } + if (blueprint.shader_text.empty()) { + error_message = "OCIO shader text is empty"; + return false; + } + + shader_source = OIIO::Strutil::fmt::format( + R"glsl(#version 460 core +layout(location = 0) in vec2 uv_in; +layout(location = 0) out vec4 out_color; + +layout(set = 0, binding = 0) uniform sampler2D source_image; + +layout(push_constant) uniform PreviewPushConstants {{ + float exposure; + float gamma; + float offset; + int color_mode; + int channel; + int use_ocio; + int orientation; +}} pc; + +vec2 display_to_source_uv(vec2 uv, int orientation) +{{ + if (orientation == 2) + return vec2(1.0 - uv.x, uv.y); + if (orientation == 3) + return vec2(1.0 - uv.x, 1.0 - uv.y); + if (orientation == 4) + return vec2(uv.x, 1.0 - uv.y); + if (orientation == 5) + return vec2(uv.y, uv.x); + if (orientation == 6) + return vec2(uv.y, 1.0 - uv.x); + if (orientation == 7) + return vec2(1.0 - uv.y, 1.0 - uv.x); + if (orientation == 8) + return vec2(1.0 - uv.y, uv.x); + return uv; +}} + +float selected_channel(vec4 c, int channel) +{{ + if (channel == 1) + return c.r; + if (channel == 2) + return c.g; + if (channel == 3) + return c.b; + if (channel == 4) + return c.a; + return c.r; +}} + +vec3 heatmap(float x) +{{ + float t = clamp(x, 0.0, 1.0); + vec3 a = vec3(0.0, 0.0, 0.5); + vec3 b = vec3(0.0, 0.9, 1.0); + vec3 c = vec3(1.0, 1.0, 0.0); + vec3 d = vec3(1.0, 0.0, 0.0); + if (t < 0.33) + return mix(a, b, t / 0.33); + if (t < 0.66) + return mix(b, c, (t - 0.33) / 0.33); + return mix(c, d, (t - 0.66) / 0.34); +}} + +{} + +void main() +{{ + vec2 src_uv = display_to_source_uv(uv_in, pc.orientation); + vec4 c = texture(source_image, src_uv); + c.rgb += vec3(pc.offset); + + if (pc.color_mode == 1) {{ + c.a = 1.0; + }} else if (pc.color_mode == 2) {{ + float v = selected_channel(c, pc.channel); + c = vec4(v, v, v, 1.0); + }} else if (pc.color_mode == 3) {{ + float y = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + c = vec4(y, y, y, 1.0); + }} else if (pc.color_mode == 4) {{ + float v = selected_channel(c, pc.channel); + c = vec4(heatmap(v), 1.0); + }} + + if (pc.channel > 0 && pc.color_mode != 2 && pc.color_mode != 4) {{ + float v = selected_channel(c, pc.channel); + c = vec4(v, v, v, 1.0); + }} + + c = {}(c); + out_color = c; +}} +)glsl", + blueprint.shader_text, blueprint.function_name); + return true; +} + +bool +compile_ocio_preview_fragment_spirv(const OcioShaderBlueprint& blueprint, + std::vector& spirv_words, + std::string& error_message) +{ + std::string shader_source; + if (!build_ocio_preview_fragment_source(blueprint, shader_source, + error_message)) { + spirv_words.clear(); + return false; + } + return compile_glsl_to_spirv(RuntimeShaderStage::Fragment, shader_source, + "imiv.ocio.preview.frag", spirv_words, + error_message); +} + +bool +preflight_ocio_runtime_shader(const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message) +{ + OcioShaderBlueprint blueprint; + std::vector spirv_words; + if (!build_ocio_shader_blueprint(ui_state, image, blueprint, error_message)) + return false; + if (!blueprint.enabled) + return true; + return compile_ocio_preview_fragment_spirv(blueprint, spirv_words, + error_message); +} + +bool +preflight_ocio_runtime_shader_glsl(const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message) +{ + OcioShaderRuntime* runtime = nullptr; + const bool ok = ensure_ocio_shader_runtime_glsl(ui_state, image, runtime, + error_message); + destroy_ocio_shader_runtime(runtime); + return ok; +} + +bool +preflight_ocio_runtime_shader_metal(const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message) +{ + OcioShaderRuntime* runtime = nullptr; + const bool ok = ensure_ocio_shader_runtime_metal(ui_state, image, runtime, + error_message); + destroy_ocio_shader_runtime(runtime); + return ok; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_ocio.h b/src/imiv/imiv_ocio.h new file mode 100644 index 0000000000..66517f07c7 --- /dev/null +++ b/src/imiv/imiv_ocio.h @@ -0,0 +1,172 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_viewer.h" + +#include +#include + +#include +#include +#include +#include + +namespace OCIO = OCIO_NAMESPACE; + +namespace Imiv { + +enum class OcioShaderTarget : uint8_t { Vulkan = 0, OpenGL, Metal }; + +enum class OcioUniformType : uint8_t { + Unknown = 0, + Double, + Bool, + Float3, + VectorFloat, + VectorInt +}; + +enum class OcioTextureChannel : uint8_t { Red = 0, RGB }; + +enum class OcioTextureDimensions : uint8_t { Tex1D = 0, Tex2D, Tex3D }; + +enum class OcioInterpolation : uint8_t { Unknown = 0, Nearest, Linear }; + +struct OcioUniformBlueprint { + std::string name; + OcioUniformType type = OcioUniformType::Unknown; + size_t buffer_offset = 0; +}; + +struct OcioTextureBlueprint { + std::string texture_name; + std::string sampler_name; + unsigned shader_binding = 0; + unsigned width = 0; + unsigned height = 0; + unsigned depth = 0; + OcioTextureChannel channel = OcioTextureChannel::RGB; + OcioTextureDimensions dimensions = OcioTextureDimensions::Tex2D; + OcioInterpolation interpolation = OcioInterpolation::Unknown; + std::vector values; +}; + +struct OcioConfigSelection { + OcioConfigSource requested_source = OcioConfigSource::Global; + OcioConfigSource resolved_source = OcioConfigSource::Global; + bool fallback_applied = false; + std::string env_value; + std::string resolved_path; + std::string selection_key; +}; + +struct OcioShaderBlueprint { + bool enabled = false; + bool valid = false; + bool has_dynamic_exposure = false; + bool has_dynamic_gamma = false; + float wrapper_offset = 0.0f; + unsigned descriptor_set_index = 1; + unsigned texture_binding_start = 1; + size_t uniform_buffer_size = 0; + std::string input_color_space; + std::string display; + std::string view; + std::string config_selection_key; + std::string processor_cache_id; + std::string shader_cache_id; + std::string function_name = "imivOcioDisplay"; + std::string resource_prefix = "imiv_ocio_"; + std::string shader_text; + std::vector uniforms; + std::vector textures; +}; + +struct OcioShaderRuntime { + bool enabled = false; + OcioShaderTarget target = OcioShaderTarget::Vulkan; + OcioShaderBlueprint blueprint; + OCIO::ConstConfigRcPtr config; + OCIO::ConstProcessorRcPtr processor; + OCIO::ConstGPUProcessorRcPtr gpu_processor; + OCIO::GpuShaderDescRcPtr shader_desc; + OCIO::DynamicPropertyDoubleRcPtr exposure_property; + OCIO::DynamicPropertyDoubleRcPtr gamma_property; +}; + +void +reset_ocio_shader_blueprint(OcioShaderBlueprint& blueprint); +const char* +ocio_config_source_name(OcioConfigSource source); +void +resolve_ocio_config_selection(const PlaceholderUiState& ui_state, + OcioConfigSelection& selection); + +void +destroy_ocio_shader_runtime(OcioShaderRuntime*& runtime); + +bool +build_ocio_shader_blueprint(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderBlueprint& blueprint, + std::string& error_message); +bool +ensure_ocio_shader_runtime(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderRuntime*& runtime, + std::string& error_message); +bool +ensure_ocio_shader_runtime_glsl(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderRuntime*& runtime, + std::string& error_message); +bool +ensure_ocio_shader_runtime_metal(const PlaceholderUiState& ui_state, + const LoadedImage* image, + OcioShaderRuntime*& runtime, + std::string& error_message); +bool +build_ocio_uniform_buffer(OcioShaderRuntime& runtime, + const PreviewControls& controls, + std::vector& uniform_bytes, + std::string& error_message); +bool +query_ocio_menu_data(const PlaceholderUiState& ui_state, + std::vector& image_color_spaces, + std::vector& displays, + std::vector& views, + std::string& resolved_display, std::string& resolved_view, + std::string& error_message); +bool +build_ocio_cpu_display_processor(const PlaceholderUiState& ui_state, + const LoadedImage* image, double exposure, + double gamma, + OCIO::ConstProcessorRcPtr& processor, + std::string& resolved_display, + std::string& resolved_view, + std::string& error_message); +bool +build_ocio_preview_fragment_source(const OcioShaderBlueprint& blueprint, + std::string& shader_source, + std::string& error_message); +bool +compile_ocio_preview_fragment_spirv(const OcioShaderBlueprint& blueprint, + std::vector& spirv_words, + std::string& error_message); +bool +preflight_ocio_runtime_shader(const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message); +bool +preflight_ocio_runtime_shader_glsl(const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message); +bool +preflight_ocio_runtime_shader_metal(const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message); + +} // namespace Imiv diff --git a/src/imiv/imiv_overlays.cpp b/src/imiv/imiv_overlays.cpp new file mode 100644 index 0000000000..09dc530371 --- /dev/null +++ b/src/imiv/imiv_overlays.cpp @@ -0,0 +1,325 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_ui.h" +#include "imiv_ui_metrics.h" + +#include "imiv_actions.h" +#include "imiv_test_engine.h" + +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + bool image_selection_rect_to_screen(const ViewerState& viewer, + const ImageCoordinateMap& map, + ImVec2& out_min, ImVec2& out_max) + { + if (!has_image_selection(viewer) || !map.valid + || viewer.image.width <= 0 || viewer.image.height <= 0) { + return false; + } + + const float x0 = static_cast(viewer.selection_xbegin) + / static_cast(viewer.image.width); + const float x1 = static_cast(viewer.selection_xend) + / static_cast(viewer.image.width); + const float y0 = static_cast(viewer.selection_ybegin) + / static_cast(viewer.image.height); + const float y1 = static_cast(viewer.selection_yend) + / static_cast(viewer.image.height); + + const ImVec2 corners_uv[] = { ImVec2(x0, y0), ImVec2(x1, y0), + ImVec2(x1, y1), ImVec2(x0, y1) }; + ImVec2 screen_corners[4]; + for (int i = 0; i < 4; ++i) { + if (!source_uv_to_screen(map, corners_uv[i], screen_corners[i])) + return false; + } + + out_min = screen_corners[0]; + out_max = screen_corners[0]; + for (int i = 1; i < 4; ++i) { + out_min.x = std::min(out_min.x, screen_corners[i].x); + out_min.y = std::min(out_min.y, screen_corners[i].y); + out_max.x = std::max(out_max.x, screen_corners[i].x); + out_max.y = std::max(out_max.y, screen_corners[i].y); + } + return true; + } + + const char* channel_view_name(int mode) + { + switch (mode) { + case 0: return "Full Color"; + case 1: return "Red"; + case 2: return "Green"; + case 3: return "Blue"; + case 4: return "Alpha"; + default: break; + } + return "Unknown"; + } + + const char* color_mode_name(int mode) + { + switch (mode) { + case 0: return "RGBA"; + case 1: return "RGB"; + case 2: return "Single channel"; + case 3: return "Luminance"; + case 4: return "Heatmap"; + default: break; + } + return "Unknown"; + } + + const char* mouse_mode_name(int mode) + { + switch (mode) { + case 0: return "Navigate"; + case 1: return "Pan"; + case 2: return "Wipe"; + case 3: return "Area Sample"; + case 4: return "Annotate"; + default: break; + } + return "Navigate"; + } + + std::string upper_ascii_copy(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return std::toupper(c); }); + return value; + } + + std::string status_channel_layout_text(const LoadedImage& image) + { + switch (image.nchannels) { + case 1: return "GRAY"; + case 2: return "RG"; + case 3: return "RGB"; + case 4: return "RGBA"; + default: break; + } + return Strutil::fmt::format("{}CH", std::max(0, image.nchannels)); + } + + std::string status_image_text(const ViewerState& viewer) + { + if (viewer.image.path.empty()) + return "No image loaded"; + const std::filesystem::path path(viewer.image.path); + const std::string filename = path.filename().empty() + ? viewer.image.path + : path.filename().string(); + return filename; + } + + std::string status_specs_text(const ViewerState& viewer) + { + if (viewer.image.path.empty()) + return ""; + + std::string type_name = viewer.image.data_format_name; + if (type_name.empty()) { + type_name = std::string( + upload_data_type_to_typedesc(viewer.image.type).c_str()); + } + if (type_name.empty() || type_name == "unknown") + type_name = std::string(upload_data_type_name(viewer.image.type)); + type_name = upper_ascii_copy(type_name); + + return Strutil::fmt::format("{}x{} {} {}", viewer.image.width, + viewer.image.height, + status_channel_layout_text(viewer.image), + type_name); + } + + std::string status_preview_text(const ViewerState& viewer, + const PlaceholderUiState& ui) + { + if (viewer.image.path.empty()) + return ""; + + const float zoom = std::max(viewer.zoom, 0.00001f); + const float z_num = zoom >= 1.0f ? zoom : 1.0f; + const float z_den = zoom >= 1.0f ? 1.0f : (1.0f / zoom); + std::string text = Strutil::fmt::format( + "zoom {:.2f}:{:.2f} exp {:+.1f} gam {:.2f} shift {:+.2f}", z_num, + z_den, ui.exposure, ui.gamma, ui.offset); + if (ui.color_mode != 0 || ui.current_channel != 0) { + std::string mode = color_mode_name(ui.color_mode); + if (ui.color_mode == 2 || ui.color_mode == 4) { + mode += Strutil::fmt::format(" {}", ui.current_channel); + } else { + mode += Strutil::fmt::format(" ({})", channel_view_name( + ui.current_channel)); + } + text += Strutil::fmt::format(" view {}", mode); + } + if (viewer.image.nsubimages > 1) { + if (viewer.auto_subimage) { + text += Strutil::fmt::format(" subimg AUTO ({}/{})", + viewer.image.subimage + 1, + viewer.image.nsubimages); + } else { + text += Strutil::fmt::format(" subimg {}/{}", + viewer.image.subimage + 1, + viewer.image.nsubimages); + } + } + if (viewer.image.nmiplevels > 1) { + text += Strutil::fmt::format(" MIP {}/{}", + viewer.image.miplevel + 1, + viewer.image.nmiplevels); + } + if (viewer.image.orientation != 1) { + text += Strutil::fmt::format(" orient {}", + viewer.image.orientation); + } + if (ui.show_mouse_mode_selector) { + text += Strutil::fmt::format(" mouse {}", + mouse_mode_name(ui.mouse_mode)); + } + return text; + } + +} // namespace + +void +draw_image_selection_overlay(const ViewerState& viewer, + const ImageCoordinateMap& map) +{ + if (!map.valid) + return; + + ImVec2 rect_min(0.0f, 0.0f); + ImVec2 rect_max(0.0f, 0.0f); + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + draw_list->PushClipRect(map.viewport_rect_min, map.viewport_rect_max, true); + if (viewer.selection_drag_active) { + ImVec2 start_screen(0.0f, 0.0f); + ImVec2 end_screen(0.0f, 0.0f); + if (source_uv_to_screen(map, viewer.selection_drag_start_uv, + start_screen) + && source_uv_to_screen(map, viewer.selection_drag_end_uv, + end_screen)) { + rect_min = ImVec2(std::min(start_screen.x, end_screen.x), + std::min(start_screen.y, end_screen.y)); + rect_max = ImVec2(std::max(start_screen.x, end_screen.x), + std::max(start_screen.y, end_screen.y)); + draw_list->AddRectFilled(rect_min, rect_max, + IM_COL32(72, 196, 255, 42), 0.0f); + draw_list->AddRect(rect_min, rect_max, IM_COL32(72, 196, 255, 255), + 0.0f, 0, 1.2f); + } + } else if (image_selection_rect_to_screen(viewer, map, rect_min, rect_max)) { + draw_list->AddRect(rect_min, rect_max, IM_COL32(72, 196, 255, 255), + 0.0f, 0, 1.2f); + } else { + draw_list->PopClipRect(); + return; + } + draw_list->PopClipRect(); + register_layout_dump_synthetic_rect("rect", "Image selection overlay", + rect_min, rect_max); +} + +void +draw_embedded_status_bar(ViewerState& viewer, PlaceholderUiState& ui) +{ + const std::string filename_text = status_image_text(viewer); + const std::string specs_text = status_specs_text(viewer); + const std::string preview_text = status_preview_text(viewer, ui); + const bool show_progress = false; + + int columns = 3; + if (show_progress) + ++columns; + if (ui.show_mouse_mode_selector) + ++columns; + ImGuiTableFlags table_flags = ImGuiTableFlags_BordersInnerV + | ImGuiTableFlags_PadOuterX + | ImGuiTableFlags_SizingStretchProp + | ImGuiTableFlags_NoSavedSettings; + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, + UiMetrics::kStatusBarCellPadding); + if (ImGui::BeginTable("##imiv_status_bar", columns, table_flags)) { + ImGui::TableSetupColumn("File", ImGuiTableColumnFlags_WidthStretch, + 2.2f); + ImGui::TableSetupColumn("Specs", ImGuiTableColumnFlags_WidthStretch, + 1.8f); + ImGui::TableSetupColumn("Preview", ImGuiTableColumnFlags_WidthStretch, + 2.2f); + if (show_progress) { + ImGui::TableSetupColumn("Load", ImGuiTableColumnFlags_WidthFixed, + UiMetrics::StatusBar::kLoadColumnWidth); + } + if (ui.show_mouse_mode_selector) { + ImGui::TableSetupColumn("Mouse", ImGuiTableColumnFlags_WidthFixed, + UiMetrics::StatusBar::kMouseColumnWidth); + } + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(filename_text.c_str()); + register_layout_dump_synthetic_item("text", filename_text.c_str()); + + ImGui::TableNextColumn(); + ImGui::TextUnformatted(specs_text.c_str()); + register_layout_dump_synthetic_item("text", specs_text.c_str()); + + ImGui::TableNextColumn(); + ImGui::TextUnformatted(preview_text.c_str()); + register_layout_dump_synthetic_item("text", preview_text.c_str()); + + if (show_progress) { + ImGui::TableNextColumn(); + ImGui::ProgressBar(0.0f, ImVec2(-1.0f, 0.0f), "idle"); + } + + if (ui.show_mouse_mode_selector) { + ImGui::TableNextColumn(); + if (ui.show_area_probe_window) { + ImGui::TextUnformatted("Area Sample"); + register_layout_dump_synthetic_item("text", "Area Sample"); + } else { + static const char* mouse_modes[] = { "Navigate", "Pan", "Wipe", + "Annotate" }; + static const int mouse_mode_values[] = { 0, 1, 2, 4 }; + int combo_index = 0; + for (int i = 0; i < IM_ARRAYSIZE(mouse_mode_values); ++i) { + if (ui.mouse_mode == mouse_mode_values[i]) { + combo_index = i; + break; + } + } + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::Combo("##mouse_mode", &combo_index, mouse_modes, + IM_ARRAYSIZE(mouse_modes))) { + set_mouse_mode_action(viewer, ui, + mouse_mode_values[combo_index]); + } + } + } + + ImGui::EndTable(); + } + ImGui::PopStyleVar(); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_parse.cpp b/src/imiv/imiv_parse.cpp new file mode 100644 index 0000000000..ae2be220af --- /dev/null +++ b/src/imiv/imiv_parse.cpp @@ -0,0 +1,155 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_parse.h" + +#include +#include +#include + +#include + +namespace Imiv { + +bool +read_env_value(const char* name, std::string& out_value) +{ + out_value.clear(); + if (name == nullptr || name[0] == '\0') + return false; +#if defined(_WIN32) + char* value = nullptr; + size_t value_size = 0; + errno_t err = _dupenv_s(&value, &value_size, name); + if (err != 0 || value == nullptr || value[0] == '\0') { + if (value != nullptr) + std::free(value); + return false; + } + out_value.assign(value); + std::free(value); +#else + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + return false; + out_value.assign(value); +#endif + return true; +} + +bool +parse_bool_string(std::string_view value, bool& out_value) +{ + const std::string_view trimmed = OIIO::Strutil::strip(value); + if (trimmed.empty()) + return false; + if (trimmed == "1" || OIIO::Strutil::iequals(trimmed, "true") + || OIIO::Strutil::iequals(trimmed, "yes") + || OIIO::Strutil::iequals(trimmed, "on")) { + out_value = true; + return true; + } + if (trimmed == "0" || OIIO::Strutil::iequals(trimmed, "false") + || OIIO::Strutil::iequals(trimmed, "no") + || OIIO::Strutil::iequals(trimmed, "off")) { + out_value = false; + return true; + } + return false; +} + +bool +parse_int_string(std::string_view value, int& out_value) +{ + const std::string trimmed = std::string(OIIO::Strutil::strip(value)); + if (trimmed.empty()) + return false; + char* end = nullptr; + long parsed = std::strtol(trimmed.c_str(), &end, 10); + if (end == trimmed.c_str() || *end != '\0') + return false; + if (parsed < static_cast(std::numeric_limits::min()) + || parsed > static_cast(std::numeric_limits::max())) { + return false; + } + out_value = static_cast(parsed); + return true; +} + +bool +parse_float_string(std::string_view value, float& out_value) +{ + const std::string trimmed = std::string(OIIO::Strutil::strip(value)); + if (trimmed.empty()) + return false; + char* end = nullptr; + float parsed = std::strtof(trimmed.c_str(), &end); + if (end == trimmed.c_str() || *end != '\0') + return false; + out_value = parsed; + return true; +} + +bool +env_flag_is_truthy(const char* name) +{ + bool out_value = false; + return env_read_bool_value(name, out_value) && out_value; +} + +bool +env_read_bool_value(const char* name, bool& out_value) +{ + std::string env_raw; + return read_env_value(name, env_raw) + && parse_bool_string(env_raw, out_value); +} + +bool +env_read_int_value(const char* name, int& out_value) +{ + std::string env_raw; + return read_env_value(name, env_raw) + && parse_int_string(env_raw, out_value); +} + +bool +env_read_float_value(const char* name, float& out_value) +{ + std::string env_raw; + return read_env_value(name, env_raw) + && parse_float_string(env_raw, out_value); +} + +int +env_int_value(const char* name, int fallback) +{ + int out_value = 0; + return env_read_int_value(name, out_value) ? out_value : fallback; +} + +int +env_int_value_clamped(const char* name, int fallback, int min_value, + int max_value) +{ + const int value = env_int_value(name, fallback); + if (min_value > max_value) + return value; + return std::clamp(value, min_value, max_value); +} + +float +env_float_value(const char* name, float fallback, bool* found) +{ + if (found != nullptr) + *found = false; + float out_value = 0.0f; + if (!env_read_float_value(name, out_value)) + return fallback; + if (found != nullptr) + *found = true; + return out_value; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_parse.h b/src/imiv/imiv_parse.h new file mode 100644 index 0000000000..81e2c964c6 --- /dev/null +++ b/src/imiv/imiv_parse.h @@ -0,0 +1,36 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include + +namespace Imiv { + +bool +read_env_value(const char* name, std::string& out_value); +bool +parse_bool_string(std::string_view value, bool& out_value); +bool +parse_int_string(std::string_view value, int& out_value); +bool +parse_float_string(std::string_view value, float& out_value); +bool +env_flag_is_truthy(const char* name); +bool +env_read_bool_value(const char* name, bool& out_value); +bool +env_read_int_value(const char* name, int& out_value); +bool +env_read_float_value(const char* name, float& out_value); +int +env_int_value(const char* name, int fallback); +int +env_int_value_clamped(const char* name, int fallback, int min_value, + int max_value); +float +env_float_value(const char* name, float fallback, bool* found = nullptr); + +} // namespace Imiv diff --git a/src/imiv/imiv_persistence.cpp b/src/imiv/imiv_persistence.cpp new file mode 100644 index 0000000000..36a3189dad --- /dev/null +++ b/src/imiv/imiv_persistence.cpp @@ -0,0 +1,412 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_persistence.h" + +#include "imiv_image_library.h" +#include "imiv_parse.h" + +#include +#include +#include +#include +#include + +#include +#include + +using namespace OIIO; + +namespace Imiv { +namespace { + + constexpr const char* k_imiv_settings_filename = "imiv.inf"; + constexpr const char* k_imiv_legacy_prefs_filename = "imiv_prefs.ini"; + constexpr const char* k_imiv_legacy_imgui_filename = "imiv.ini"; + constexpr const char* k_imiv_app_section_name = "[ImivApp][State]"; + + std::filesystem::path prefs_directory_path() + { + const std::string_view override_dir = Sysutil::getenv( + "IMIV_CONFIG_HOME"); + if (!override_dir.empty()) { + return std::filesystem::path(std::string(override_dir)) + / "OpenImageIO" / "imiv"; + } + +#if defined(_WIN32) + std::string_view base_dir = Sysutil::getenv("APPDATA"); + if (base_dir.empty()) + base_dir = Sysutil::getenv("LOCALAPPDATA"); + if (!base_dir.empty()) { + return std::filesystem::path(std::string(base_dir)) / "OpenImageIO" + / "imiv"; + } +#elif defined(__APPLE__) + const std::string_view home = Sysutil::getenv("HOME"); + if (!home.empty()) { + return std::filesystem::path(std::string(home)) / "Library" + / "Application Support" / "OpenImageIO" / "imiv"; + } +#else + const std::string_view xdg_config_home = Sysutil::getenv( + "XDG_CONFIG_HOME"); + if (!xdg_config_home.empty()) { + return std::filesystem::path(std::string(xdg_config_home)) + / "OpenImageIO" / "imiv"; + } + const std::string_view home = Sysutil::getenv("HOME"); + if (!home.empty()) { + return std::filesystem::path(std::string(home)) / ".config" + / "OpenImageIO" / "imiv"; + } +#endif + + return std::filesystem::path(); + } + + std::filesystem::path config_dir_legacy_prefs_file_path() + { + const std::filesystem::path prefs_dir = prefs_directory_path(); + if (prefs_dir.empty()) + return std::filesystem::path(k_imiv_legacy_prefs_filename); + return prefs_dir / k_imiv_legacy_prefs_filename; + } + + std::filesystem::path persistent_state_file_path() + { + const std::filesystem::path prefs_dir = prefs_directory_path(); + if (prefs_dir.empty()) + return std::filesystem::path(k_imiv_settings_filename); + return prefs_dir / k_imiv_settings_filename; + } + + std::filesystem::path persistent_state_file_path_for_load() + { + const std::filesystem::path prefs_path = persistent_state_file_path(); + std::error_code ec; + if (std::filesystem::exists(prefs_path, ec)) + return prefs_path; + const std::filesystem::path legacy_config_path + = config_dir_legacy_prefs_file_path(); + ec.clear(); + if (std::filesystem::exists(legacy_config_path, ec)) + return legacy_config_path; + return std::filesystem::path(k_imiv_legacy_prefs_filename); + } + + std::string strip_to_string(std::string_view value) + { + return std::string(Strutil::strip(value)); + } + + void apply_bool_pref(std::string_view value, bool& out_value) + { + bool parsed = false; + if (parse_bool_string(value, parsed)) + out_value = parsed; + } + + void apply_int_pref(std::string_view value, int& out_value) + { + int parsed = 0; + if (parse_int_string(value, parsed)) + out_value = parsed; + } + + void apply_float_pref(std::string_view value, float& out_value) + { + float parsed = 0.0f; + if (parse_float_string(value, parsed)) + out_value = parsed; + } + + void apply_persistent_state_entry(const std::string& key, + const std::string& value, + PlaceholderUiState& ui_state, + ViewerState& viewer, + ImageLibraryState& library) + { + if (key == "pixelview_follows_mouse") { + apply_bool_pref(value, ui_state.pixelview_follows_mouse); + } else if (key == "pixelview_left_corner") { + apply_bool_pref(value, ui_state.pixelview_left_corner); + } else if (key == "linear_interpolation") { + apply_bool_pref(value, viewer.recipe.linear_interpolation); + } else if (key == "dark_palette") { + bool legacy_dark_palette = false; + if (parse_bool_string(value, legacy_dark_palette)) { + ui_state.style_preset = static_cast( + legacy_dark_palette ? AppStylePreset::ImGuiDark + : AppStylePreset::ImGuiLight); + } + } else if (key == "style_preset") { + apply_int_pref(value, ui_state.style_preset); + } else if (key == "renderer_backend") { + BackendKind backend_kind = BackendKind::Auto; + if (parse_backend_kind(value, backend_kind)) { + ui_state.renderer_backend = static_cast(backend_kind); + } else { + apply_int_pref(value, ui_state.renderer_backend); + } + } else if (key == "auto_mipmap") { + apply_bool_pref(value, ui_state.auto_mipmap); + } else if (key == "fit_image_to_window") { + apply_bool_pref(value, ui_state.fit_image_to_window); + } else if (key == "show_mouse_mode_selector") { + apply_bool_pref(value, ui_state.show_mouse_mode_selector); + } else if (key == "full_screen_mode") { + apply_bool_pref(value, ui_state.full_screen_mode); + } else if (key == "window_always_on_top") { + apply_bool_pref(value, ui_state.window_always_on_top); + } else if (key == "slide_show_running") { + apply_bool_pref(value, ui_state.slide_show_running); + } else if (key == "slide_loop") { + apply_bool_pref(value, ui_state.slide_loop); + } else if (key == "use_ocio") { + apply_bool_pref(value, viewer.recipe.use_ocio); + } else if (key == "ocio_config_source") { + apply_int_pref(value, ui_state.ocio_config_source); + } else if (key == "max_memory_ic_mb") { + apply_int_pref(value, ui_state.max_memory_ic_mb); + } else if (key == "slide_duration_seconds") { + apply_int_pref(value, ui_state.slide_duration_seconds); + } else if (key == "closeup_pixels") { + apply_int_pref(value, ui_state.closeup_pixels); + } else if (key == "closeup_avg_pixels") { + apply_int_pref(value, ui_state.closeup_avg_pixels); + } else if (key == "current_channel") { + apply_int_pref(value, viewer.recipe.current_channel); + } else if (key == "color_mode") { + apply_int_pref(value, viewer.recipe.color_mode); + } else if (key == "subimage_index") { + apply_int_pref(value, ui_state.subimage_index); + } else if (key == "miplevel_index") { + apply_int_pref(value, ui_state.miplevel_index); + } else if (key == "mouse_mode") { + apply_int_pref(value, ui_state.mouse_mode); + } else if (key == "exposure") { + apply_float_pref(value, viewer.recipe.exposure); + } else if (key == "gamma") { + apply_float_pref(value, viewer.recipe.gamma); + } else if (key == "offset") { + apply_float_pref(value, viewer.recipe.offset); + } else if (key == "ocio_display") { + viewer.recipe.ocio_display = strip_to_string(value); + } else if (key == "ocio_view") { + viewer.recipe.ocio_view = strip_to_string(value); + } else if (key == "ocio_image_color_space") { + viewer.recipe.ocio_image_color_space = strip_to_string(value); + } else if (key == "ocio_user_config_path") { + ui_state.ocio_user_config_path = strip_to_string(value); + } else if (key == "sort_mode") { + int parsed_sort_mode = static_cast(library.sort_mode); + if (parse_int_string(value, parsed_sort_mode)) { + parsed_sort_mode = std::clamp(parsed_sort_mode, 0, 3); + library.sort_mode = static_cast( + parsed_sort_mode); + } + } else if (key == "sort_reverse") { + apply_bool_pref(value, library.sort_reverse); + } else if (key == "recent_image") { + add_recent_image_path(library, strip_to_string(value)); + } + } + + void write_persistent_state_entries(std::ofstream& output, + const PlaceholderUiState& ui_state, + const ViewerState& viewer, + const ImageLibraryState& library) + { + output << k_imiv_app_section_name << "\n"; + output << "pixelview_follows_mouse=" + << (ui_state.pixelview_follows_mouse ? 1 : 0) << "\n"; + output << "pixelview_left_corner=" + << (ui_state.pixelview_left_corner ? 1 : 0) << "\n"; + output << "linear_interpolation=" + << (viewer.recipe.linear_interpolation ? 1 : 0) << "\n"; + output << "style_preset=" << ui_state.style_preset << "\n"; + output << "renderer_backend=" + << backend_cli_name( + sanitize_backend_kind(ui_state.renderer_backend)) + << "\n"; + output << "auto_mipmap=" << (ui_state.auto_mipmap ? 1 : 0) << "\n"; + output << "fit_image_to_window=" + << (ui_state.fit_image_to_window ? 1 : 0) << "\n"; + output << "show_mouse_mode_selector=" + << (ui_state.show_mouse_mode_selector ? 1 : 0) << "\n"; + output << "full_screen_mode=" << (ui_state.full_screen_mode ? 1 : 0) + << "\n"; + output << "window_always_on_top=" + << (ui_state.window_always_on_top ? 1 : 0) << "\n"; + output << "slide_show_running=" << (ui_state.slide_show_running ? 1 : 0) + << "\n"; + output << "slide_loop=" << (ui_state.slide_loop ? 1 : 0) << "\n"; + output << "use_ocio=" << (viewer.recipe.use_ocio ? 1 : 0) << "\n"; + output << "ocio_config_source=" << ui_state.ocio_config_source << "\n"; + output << "max_memory_ic_mb=" << ui_state.max_memory_ic_mb << "\n"; + output << "slide_duration_seconds=" << ui_state.slide_duration_seconds + << "\n"; + output << "closeup_pixels=" << ui_state.closeup_pixels << "\n"; + output << "closeup_avg_pixels=" << ui_state.closeup_avg_pixels << "\n"; + output << "current_channel=" << viewer.recipe.current_channel << "\n"; + output << "color_mode=" << viewer.recipe.color_mode << "\n"; + output << "subimage_index=" << ui_state.subimage_index << "\n"; + output << "miplevel_index=" << ui_state.miplevel_index << "\n"; + output << "mouse_mode=" << ui_state.mouse_mode << "\n"; + output << "exposure=" << viewer.recipe.exposure << "\n"; + output << "gamma=" << viewer.recipe.gamma << "\n"; + output << "offset=" << viewer.recipe.offset << "\n"; + output << "ocio_display=" << viewer.recipe.ocio_display << "\n"; + output << "ocio_view=" << viewer.recipe.ocio_view << "\n"; + output << "ocio_image_color_space=" + << viewer.recipe.ocio_image_color_space << "\n"; + output << "ocio_user_config_path=" << ui_state.ocio_user_config_path + << "\n"; + output << "sort_mode=" << static_cast(library.sort_mode) << "\n"; + output << "sort_reverse=" << (library.sort_reverse ? 1 : 0) << "\n"; + for (const std::string& recent : library.recent_images) + output << "recent_image=" << recent << "\n"; + } + +} // namespace + +std::filesystem::path +imgui_ini_load_path() +{ + const std::filesystem::path settings_load_path + = persistent_state_file_path_for_load(); + std::error_code ec; + if (settings_load_path.filename() == k_imiv_settings_filename + && std::filesystem::exists(settings_load_path, ec) && !ec) { + return settings_load_path; + } + + const std::filesystem::path legacy_imgui_path = std::filesystem::path( + k_imiv_legacy_imgui_filename); + ec.clear(); + if (std::filesystem::exists(legacy_imgui_path, ec) && !ec) + return legacy_imgui_path; + return std::filesystem::path(); +} + +bool +load_persistent_state(PlaceholderUiState& ui_state, ViewerState& viewer, + ImageLibraryState& library, std::string& error_message) +{ + error_message.clear(); + const std::filesystem::path path = persistent_state_file_path_for_load(); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) + return true; + + std::ifstream input(path); + if (!input) { + error_message = Strutil::fmt::format("failed to open '{}'", + path.string()); + return false; + } + + std::string line; + bool in_app_section = false; + bool saw_sections = false; + while (std::getline(input, line)) { + const std::string trimmed = strip_to_string(line); + if (trimmed.empty() || trimmed[0] == '#') + continue; + if (trimmed.front() == '[') { + saw_sections = true; + in_app_section = (trimmed == k_imiv_app_section_name); + continue; + } + if (saw_sections && !in_app_section) + continue; + + const size_t eq = trimmed.find('='); + if (eq == std::string::npos) + continue; + + const std::string key = strip_to_string(trimmed.substr(0, eq)); + const std::string value = trimmed.substr(eq + 1); + apply_persistent_state_entry(key, value, ui_state, viewer, library); + } + + clamp_placeholder_ui_state(ui_state); + clamp_view_recipe(viewer.recipe); + apply_view_recipe_to_ui_state(viewer.recipe, ui_state); + if (!input.eof()) { + error_message = Strutil::fmt::format("failed while reading '{}'", + path.string()); + return false; + } + return true; +} + +bool +save_persistent_state(const PlaceholderUiState& ui_state, + const ViewerState& viewer, + const ImageLibraryState& library, + const char* imgui_ini_text, size_t imgui_ini_size, + std::string& error_message) +{ + error_message.clear(); + const std::filesystem::path path = persistent_state_file_path(); + const std::filesystem::path temp_path = path.string() + ".tmp"; + std::error_code ec; + if (!path.parent_path().empty()) { + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + error_message = Strutil::fmt::format("failed to create '{}': {}", + path.parent_path().string(), + ec.message()); + return false; + } + } + + std::ofstream output(temp_path, std::ios::trunc); + if (!output) { + error_message = Strutil::fmt::format("failed to open '{}'", + temp_path.string()); + return false; + } + + if (imgui_ini_text != nullptr && imgui_ini_size > 0) { + output.write(imgui_ini_text, + static_cast(imgui_ini_size)); + if (imgui_ini_text[imgui_ini_size - 1] != '\n') + output << "\n"; + output << "\n"; + } + + write_persistent_state_entries(output, ui_state, viewer, library); + output.flush(); + if (!output) { + error_message = Strutil::fmt::format("failed while writing '{}'", + temp_path.string()); + output.close(); + std::error_code remove_ec; + std::filesystem::remove(temp_path, remove_ec); + return false; + } + output.close(); + + ec.clear(); + std::filesystem::rename(temp_path, path, ec); + if (ec) { + std::error_code remove_ec; + std::filesystem::remove(path, remove_ec); + ec.clear(); + std::filesystem::rename(temp_path, path, ec); + } + if (ec) { + error_message = Strutil::fmt::format("failed to replace '{}': {}", + path.string(), ec.message()); + std::error_code remove_ec; + std::filesystem::remove(temp_path, remove_ec); + return false; + } + return true; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_persistence.h b/src/imiv/imiv_persistence.h new file mode 100644 index 0000000000..d9ab9a4e92 --- /dev/null +++ b/src/imiv/imiv_persistence.h @@ -0,0 +1,29 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include +#include + +namespace Imiv { + +struct PlaceholderUiState; +struct ViewerState; +struct ImageLibraryState; + +std::filesystem::path +imgui_ini_load_path(); +bool +load_persistent_state(PlaceholderUiState& ui_state, ViewerState& viewer, + ImageLibraryState& library, std::string& error_message); +bool +save_persistent_state(const PlaceholderUiState& ui_state, + const ViewerState& viewer, + const ImageLibraryState& library, + const char* imgui_ini_text, size_t imgui_ini_size, + std::string& error_message); + +} // namespace Imiv diff --git a/src/imiv/imiv_platform_glfw.cpp b/src/imiv/imiv_platform_glfw.cpp new file mode 100644 index 0000000000..bf54a5b517 --- /dev/null +++ b/src/imiv/imiv_platform_glfw.cpp @@ -0,0 +1,428 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_platform_glfw.h" +#include "imiv_parse.h" + +#include +#include + +#include +#include + +#define GLFW_INCLUDE_NONE +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + bool g_glfw_initialized = false; + + enum class GlfwPlatformPreference : uint8_t { + Auto = 0, + X11 = 1, + Wayland = 2 + }; + + bool env_var_is_nonempty(const char* name) + { + std::string value; + return read_env_value(name, value) && !value.empty(); + } + + GlfwPlatformPreference glfw_platform_preference() + { + std::string value; + if (!read_env_value("IMIV_GLFW_PLATFORM", value) || value.empty()) + return GlfwPlatformPreference::Auto; + if (Strutil::iequals(value, "x11")) + return GlfwPlatformPreference::X11; + if (Strutil::iequals(value, "wayland")) + return GlfwPlatformPreference::Wayland; + return GlfwPlatformPreference::Auto; + } + + void glfw_error_callback(int error, const char* description) + { + print(stderr, "imiv: GLFW error {}: {}\n", error, description); + } + + void configure_glfw_platform_preference(bool verbose_logging) + { +#if !defined(_WIN32) && !defined(__APPLE__) && defined(GLFW_VERSION_MAJOR) \ + && ((GLFW_VERSION_MAJOR > 3) \ + || (GLFW_VERSION_MAJOR == 3 && GLFW_VERSION_MINOR >= 4)) + const GlfwPlatformPreference preference = glfw_platform_preference(); + if (preference == GlfwPlatformPreference::X11) { +# if defined(GLFW_PLATFORM) && defined(GLFW_PLATFORM_X11) + glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11); + if (verbose_logging) + print("imiv: GLFW platform preference = x11\n"); +# endif + return; + } + if (preference == GlfwPlatformPreference::Wayland) { +# if defined(GLFW_PLATFORM) && defined(GLFW_PLATFORM_WAYLAND) + glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_WAYLAND); + if (verbose_logging) + print("imiv: GLFW platform preference = wayland\n"); +# endif + return; + } + + const bool have_x11_display = env_var_is_nonempty("DISPLAY"); + const bool have_wayland_display = env_var_is_nonempty( + "WAYLAND_DISPLAY"); + if (have_x11_display && have_wayland_display) { +# if defined(GLFW_PLATFORM) && defined(GLFW_PLATFORM_X11) + glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11); + if (verbose_logging) { + print("imiv: GLFW auto-selected x11 for platform windows " + "(DISPLAY and WAYLAND_DISPLAY are both present)\n"); + } +# endif + } +#else + (void)verbose_logging; +#endif + } + + bool primary_monitor_workarea(int& x, int& y, int& w, int& h) + { + GLFWmonitor* monitor = glfwGetPrimaryMonitor(); + if (monitor == nullptr) + return false; +#if defined(GLFW_VERSION_MAJOR) \ + && ((GLFW_VERSION_MAJOR > 3) \ + || (GLFW_VERSION_MAJOR == 3 && GLFW_VERSION_MINOR >= 3)) + glfwGetMonitorWorkarea(monitor, &x, &y, &w, &h); + if (w > 0 && h > 0) + return true; +#endif + const GLFWvidmode* mode = glfwGetVideoMode(monitor); + if (mode == nullptr) + return false; + x = 0; + y = 0; + w = mode->width; + h = mode->height; + return (w > 0 && h > 0); + } + + bool centered_glfw_window_pos(GLFWwindow* window, int& out_pos_x, + int& out_pos_y, int& out_window_w, + int& out_window_h) + { + out_pos_x = 0; + out_pos_y = 0; + out_window_w = 0; + out_window_h = 0; + if (window == nullptr) + return false; + int monitor_x = 0; + int monitor_y = 0; + int monitor_w = 0; + int monitor_h = 0; + if (!primary_monitor_workarea(monitor_x, monitor_y, monitor_w, + monitor_h)) + return false; + int window_w = 0; + int window_h = 0; + glfwGetWindowSize(window, &window_w, &window_h); + if (window_w <= 0 || window_h <= 0) + return false; + int frame_left = 0; + int frame_top = 0; + int frame_right = 0; + int frame_bottom = 0; + glfwGetWindowFrameSize(window, &frame_left, &frame_top, &frame_right, + &frame_bottom); + const int outer_w = window_w + frame_left + frame_right; + const int outer_h = window_h + frame_top + frame_bottom; + out_pos_x = monitor_x + std::max(0, (monitor_w - outer_w) / 2) + + frame_left; + out_pos_y = monitor_y + std::max(0, (monitor_h - outer_h) / 2) + + frame_top; + out_window_w = window_w; + out_window_h = window_h; + return true; + } + +} // namespace + +bool +platform_glfw_init(bool verbose_logging, std::string& error_message) +{ + if (g_glfw_initialized) { + error_message.clear(); + return true; + } + glfwSetErrorCallback(glfw_error_callback); + configure_glfw_platform_preference(verbose_logging); + if (glfwInit()) { + g_glfw_initialized = true; + error_message.clear(); + return true; + } + error_message = "glfwInit failed"; + return false; +} + +bool +platform_glfw_is_initialized() +{ + return g_glfw_initialized; +} + +void +platform_glfw_terminate() +{ + if (!g_glfw_initialized) + return; + g_glfw_initialized = false; + glfwTerminate(); +} + +GLFWwindow* +platform_glfw_create_main_window(BackendKind backend, int width, int height, + const char* title, std::string& error_message) +{ + if (backend == BackendKind::OpenGL) { +#if defined(__APPLE__) + glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_API); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#else + glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_API); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0); +#endif + } else { + glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); + } + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + GLFWwindow* window = glfwCreateWindow(width, height, title, nullptr, + nullptr); + if (window == nullptr) { + error_message = "failed to create GLFW window"; + return nullptr; + } + if (backend == BackendKind::OpenGL) { + glfwMakeContextCurrent(window); + glfwSwapInterval(1); + } + center_glfw_window(window); + error_message.clear(); + return window; +} + +void +platform_glfw_destroy_window(GLFWwindow* window) +{ + if (window != nullptr) + glfwDestroyWindow(window); +} + +bool +platform_glfw_supports_vulkan(std::string& error_message) +{ + if (glfwVulkanSupported()) { + error_message.clear(); + return true; + } + error_message = "GLFW reports Vulkan is not supported"; + return false; +} + +void +platform_glfw_collect_vulkan_instance_extensions( + ImVector& instance_extensions) +{ + uint32_t glfw_extension_count = 0; + const char** glfw_extensions = glfwGetRequiredInstanceExtensions( + &glfw_extension_count); + for (uint32_t i = 0; i < glfw_extension_count; ++i) + instance_extensions.push_back(glfw_extensions[i]); +} + +void +platform_glfw_imgui_init(GLFWwindow* window, BackendKind backend) +{ + switch (backend) { + case BackendKind::Vulkan: ImGui_ImplGlfw_InitForVulkan(window, true); break; + case BackendKind::OpenGL: ImGui_ImplGlfw_InitForOpenGL(window, true); break; + case BackendKind::Metal: + case BackendKind::Auto: ImGui_ImplGlfw_InitForOther(window, true); break; + } +} + +void +platform_glfw_imgui_shutdown() +{ + ImGui_ImplGlfw_Shutdown(); +} + +void +platform_glfw_imgui_new_frame() +{ + ImGui_ImplGlfw_NewFrame(); +} + +void +platform_glfw_sleep(int milliseconds) +{ + ImGui_ImplGlfw_Sleep(static_cast(milliseconds)); +} + +void +platform_glfw_poll_events() +{ + glfwPollEvents(); +} + +GLFWwindow* +platform_glfw_get_current_context() +{ + return glfwGetCurrentContext(); +} + +void +platform_glfw_make_context_current(GLFWwindow* window) +{ + glfwMakeContextCurrent(window); +} + +void* +platform_glfw_get_proc_address(const char* name) +{ + return reinterpret_cast(glfwGetProcAddress(name)); +} + +void +platform_glfw_swap_buffers(GLFWwindow* window) +{ + glfwSwapBuffers(window); +} + +void +platform_glfw_show_window(GLFWwindow* window) +{ + glfwShowWindow(window); +} + +bool +platform_glfw_should_close(GLFWwindow* window) +{ + return glfwWindowShouldClose(window) != 0; +} + +void +platform_glfw_request_close(GLFWwindow* window) +{ + glfwSetWindowShouldClose(window, GLFW_TRUE); +} + +void +platform_glfw_get_framebuffer_size(GLFWwindow* window, int& width, int& height) +{ + glfwGetFramebufferSize(window, &width, &height); +} + +bool +platform_glfw_is_iconified(GLFWwindow* window) +{ + return glfwGetWindowAttrib(window, GLFW_ICONIFIED) != 0; +} + +bool +platform_glfw_is_window_floating(GLFWwindow* window) +{ + return window != nullptr && glfwGetWindowAttrib(window, GLFW_FLOATING) != 0; +} + +void +platform_glfw_set_window_floating(GLFWwindow* window, bool floating) +{ + if (window == nullptr) + return; + glfwSetWindowAttrib(window, GLFW_FLOATING, + floating ? GLFW_TRUE : GLFW_FALSE); +} + +int +platform_glfw_selected_platform() +{ +#if defined(GLFW_VERSION_MAJOR) \ + && ((GLFW_VERSION_MAJOR > 3) \ + || (GLFW_VERSION_MAJOR == 3 && GLFW_VERSION_MINOR >= 4)) + return glfwGetPlatform(); +#else + return 0; +#endif +} + +const char* +platform_glfw_name(int platform) +{ +#if defined(GLFW_VERSION_MAJOR) \ + && ((GLFW_VERSION_MAJOR > 3) \ + || (GLFW_VERSION_MAJOR == 3 && GLFW_VERSION_MINOR >= 4)) + switch (platform) { +# if defined(GLFW_PLATFORM_WIN32) + case GLFW_PLATFORM_WIN32: return "win32"; +# endif +# if defined(GLFW_PLATFORM_COCOA) + case GLFW_PLATFORM_COCOA: return "cocoa"; +# endif +# if defined(GLFW_PLATFORM_WAYLAND) + case GLFW_PLATFORM_WAYLAND: return "wayland"; +# endif +# if defined(GLFW_PLATFORM_X11) + case GLFW_PLATFORM_X11: return "x11"; +# endif +# if defined(GLFW_PLATFORM_NULL) + case GLFW_PLATFORM_NULL: return "null"; +# endif + default: break; + } +#else + (void)platform; +#endif + return "unknown"; +} + +void +center_glfw_window(GLFWwindow* window) +{ + int pos_x = 0; + int pos_y = 0; + int window_w = 0; + int window_h = 0; + if (!centered_glfw_window_pos(window, pos_x, pos_y, window_w, window_h)) + return; + glfwSetWindowPos(window, pos_x, pos_y); +} + +void +force_center_glfw_window(GLFWwindow* window) +{ + int pos_x = 0; + int pos_y = 0; + int window_w = 0; + int window_h = 0; + if (!centered_glfw_window_pos(window, pos_x, pos_y, window_w, window_h)) + return; + glfwSetWindowMonitor(window, nullptr, pos_x, pos_y, window_w, window_h, + GLFW_DONT_CARE); + glfwSetWindowPos(window, pos_x, pos_y); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_platform_glfw.h b/src/imiv/imiv_platform_glfw.h new file mode 100644 index 0000000000..d6879fdfe0 --- /dev/null +++ b/src/imiv/imiv_platform_glfw.h @@ -0,0 +1,79 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_backend.h" + +#include + +#include + +struct GLFWwindow; + +namespace Imiv { + +bool +platform_glfw_init(bool verbose_logging, std::string& error_message); +bool +platform_glfw_is_initialized(); +void +platform_glfw_terminate(); + +GLFWwindow* +platform_glfw_create_main_window(BackendKind backend, int width, int height, + const char* title, std::string& error_message); +void +platform_glfw_destroy_window(GLFWwindow* window); + +bool +platform_glfw_supports_vulkan(std::string& error_message); +void +platform_glfw_collect_vulkan_instance_extensions( + ImVector& instance_extensions); + +void +platform_glfw_imgui_init(GLFWwindow* window, BackendKind backend); +void +platform_glfw_imgui_shutdown(); +void +platform_glfw_imgui_new_frame(); +void +platform_glfw_sleep(int milliseconds); +void +platform_glfw_poll_events(); +GLFWwindow* +platform_glfw_get_current_context(); +void +platform_glfw_make_context_current(GLFWwindow* window); +void* +platform_glfw_get_proc_address(const char* name); +void +platform_glfw_swap_buffers(GLFWwindow* window); +void +platform_glfw_show_window(GLFWwindow* window); +bool +platform_glfw_should_close(GLFWwindow* window); +void +platform_glfw_request_close(GLFWwindow* window); +void +platform_glfw_get_framebuffer_size(GLFWwindow* window, int& width, int& height); +bool +platform_glfw_is_iconified(GLFWwindow* window); +bool +platform_glfw_is_window_floating(GLFWwindow* window); +void +platform_glfw_set_window_floating(GLFWwindow* window, bool floating); + +int +platform_glfw_selected_platform(); +const char* +platform_glfw_name(int platform); + +void +center_glfw_window(GLFWwindow* window); +void +force_center_glfw_window(GLFWwindow* window); + +} // namespace Imiv diff --git a/src/imiv/imiv_preview_shader_text.cpp b/src/imiv/imiv_preview_shader_text.cpp new file mode 100644 index 0000000000..894a52eec5 --- /dev/null +++ b/src/imiv/imiv_preview_shader_text.cpp @@ -0,0 +1,205 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_preview_shader_text.h" + +namespace Imiv { + +const char* const k_glsl_preview_fragment_common_functions = R"glsl( +vec2 display_to_source_uv(vec2 uv, int orientation) +{ + if (orientation == 2) + return vec2(1.0 - uv.x, uv.y); + if (orientation == 3) + return vec2(1.0 - uv.x, 1.0 - uv.y); + if (orientation == 4) + return vec2(uv.x, 1.0 - uv.y); + if (orientation == 5) + return vec2(uv.y, uv.x); + if (orientation == 6) + return vec2(uv.y, 1.0 - uv.x); + if (orientation == 7) + return vec2(1.0 - uv.y, 1.0 - uv.x); + if (orientation == 8) + return vec2(1.0 - uv.y, uv.x); + return uv; +} + +float selected_channel(vec4 c, int channel) +{ + if (channel == 1) + return c.r; + if (channel == 2) + return c.g; + if (channel == 3) + return c.b; + if (channel == 4) + return c.a; + return c.r; +} + +vec3 heatmap(float x) +{ + float t = clamp(x, 0.0, 1.0); + vec3 a = vec3(0.0, 0.0, 0.5); + vec3 b = vec3(0.0, 0.9, 1.0); + vec3 c = vec3(1.0, 1.0, 0.0); + vec3 d = vec3(1.0, 0.0, 0.0); + if (t < 0.33) + return mix(a, b, t / 0.33); + if (t < 0.66) + return mix(b, c, (t - 0.33) / 0.33); + return mix(c, d, (t - 0.66) / 0.34); +} +)glsl"; + +const char* const k_metal_basic_preview_uniform_fields = R"metal( + float exposure; + float gamma; + float offset; + int color_mode; + int channel; + int input_channels; + int orientation; + int _padding; +)metal"; + +const char* const k_metal_ocio_preview_uniform_fields = R"metal( + float offset; + int color_mode; + int channel; + int input_channels; + int orientation; +)metal"; + +const char* const k_metal_preview_common_shader_functions = R"metal( +inline float selected_channel(float4 rgba, int channel) +{ + if (channel == 1) return rgba.r; + if (channel == 2) return rgba.g; + if (channel == 3) return rgba.b; + if (channel == 4) return rgba.a; + return rgba.r; +} + +inline float3 heatmap(float x) +{ + float t = clamp(x, 0.0, 1.0); + float3 a = float3(0.0, 0.0, 0.5); + float3 b = float3(0.0, 0.9, 1.0); + float3 c = float3(1.0, 1.0, 0.0); + float3 d = float3(1.0, 0.0, 0.0); + if (t < 0.33) + return mix(a, b, t / 0.33); + if (t < 0.66) + return mix(b, c, (t - 0.33) / 0.33); + return mix(c, d, (t - 0.66) / 0.34); +} + +inline float2 display_to_source_uv(float2 uv, int orientation) +{ + switch (orientation) { + case 2: return float2(1.0 - uv.x, uv.y); + case 3: return float2(1.0 - uv.x, 1.0 - uv.y); + case 4: return float2(uv.x, 1.0 - uv.y); + case 5: return float2(uv.y, uv.x); + case 6: return float2(uv.y, 1.0 - uv.x); + case 7: return float2(1.0 - uv.y, 1.0 - uv.x); + case 8: return float2(1.0 - uv.y, uv.x); + default: return uv; + } +} +)metal"; + +std::string +glsl_fullscreen_triangle_vertex_shader() +{ + return R"glsl( +out vec2 uv_in; + +void main() +{ + vec2 pos = vec2(-1.0, -1.0); + if (gl_VertexID == 1) + pos = vec2(3.0, -1.0); + else if (gl_VertexID == 2) + pos = vec2(-1.0, 3.0); + + uv_in = pos * 0.5 + 0.5; + gl_Position = vec4(pos, 0.0, 1.0); +} +)glsl"; +} + +std::string +glsl_preview_fragment_preamble(bool include_exposure_gamma) +{ + std::string source = R"glsl( +in vec2 uv_in; +out vec4 out_color; + +uniform sampler2D u_source_image; +uniform int u_input_channels; +)glsl"; + if (include_exposure_gamma) { + source += R"glsl( +uniform float u_exposure; +uniform float u_gamma; +)glsl"; + } + source += R"glsl( +uniform float u_offset; +uniform int u_color_mode; +uniform int u_channel; +uniform int u_orientation; + +)glsl"; + source += k_glsl_preview_fragment_common_functions; + return source; +} + +std::string +metal_preview_shader_preamble(const char* preview_uniform_fields) +{ + std::string source = R"metal( +#include +using namespace metal; + +struct VertexOut { + float4 position [[position]]; + float2 uv; +}; + +struct PreviewUniforms { +)metal"; + if (preview_uniform_fields != nullptr) + source += preview_uniform_fields; + source += R"metal( +}; +)metal"; + return source; +} + +std::string +metal_fullscreen_triangle_vertex_source(const char* vertex_name) +{ + const char* name = (vertex_name != nullptr && vertex_name[0] != '\0') + ? vertex_name + : "imivPreviewVertex"; + std::string source = "\nvertex VertexOut "; + source += name; + source += R"metal((uint vertex_id [[vertex_id]]) +{ + const float2 positions[3] = { float2(-1.0, -1.0), float2(3.0, -1.0), float2(-1.0, 3.0) }; + const float2 uvs[3] = { float2(0.0, 0.0), float2(2.0, 0.0), float2(0.0, 2.0) }; + VertexOut out; + out.position = float4(positions[vertex_id], 0.0, 1.0); + out.uv = uvs[vertex_id]; + return out; +} +)metal"; + return source; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_preview_shader_text.h b/src/imiv/imiv_preview_shader_text.h new file mode 100644 index 0000000000..267044794a --- /dev/null +++ b/src/imiv/imiv_preview_shader_text.h @@ -0,0 +1,25 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include + +namespace Imiv { + +extern const char* const k_glsl_preview_fragment_common_functions; +extern const char* const k_metal_basic_preview_uniform_fields; +extern const char* const k_metal_ocio_preview_uniform_fields; +extern const char* const k_metal_preview_common_shader_functions; + +std::string +glsl_fullscreen_triangle_vertex_shader(); +std::string +glsl_preview_fragment_preamble(bool include_exposure_gamma); +std::string +metal_preview_shader_preamble(const char* preview_uniform_fields); +std::string +metal_fullscreen_triangle_vertex_source(const char* vertex_name); + +} // namespace Imiv diff --git a/src/imiv/imiv_probe_overlay.cpp b/src/imiv/imiv_probe_overlay.cpp new file mode 100644 index 0000000000..b8c4d69e38 --- /dev/null +++ b/src/imiv/imiv_probe_overlay.cpp @@ -0,0 +1,739 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_probe_overlay.h" + +#include "imiv_test_engine.h" +#include "imiv_ui_metrics.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + enum class ProbeStatsSemantics : uint8_t { RawStored = 0, OIIOFloat = 1 }; + + std::string channel_label_for_index(int c) + { + static const char* names[] = { "R", "G", "B", "A" }; + if (c >= 0 && c < 4) + return names[c]; + return Strutil::fmt::format("C{}", c); + } + + std::string pixel_preview_channel_label(const LoadedImage& image, int c) + { + if (c >= 0 && c < static_cast(image.channel_names.size()) + && !image.channel_names[c].empty()) { + return image.channel_names[c]; + } + return channel_label_for_index(c); + } + + double probe_value_to_oiio_float(UploadDataType type, double value) + { + switch (type) { + case UploadDataType::UInt8: return value / 255.0; + case UploadDataType::UInt16: return value / 65535.0; + case UploadDataType::UInt32: return value / 4294967295.0; + case UploadDataType::Half: + case UploadDataType::Float: + case UploadDataType::Double: return value; + default: break; + } + return value; + } + + bool sample_loaded_pixel_with_semantics(const LoadedImage& image, int x, + int y, + ProbeStatsSemantics semantics, + std::vector& out_channels) + { + if (!sample_loaded_pixel(image, x, y, out_channels)) + return false; + if (semantics == ProbeStatsSemantics::OIIOFloat) { + for (double& value : out_channels) + value = probe_value_to_oiio_float(image.type, value); + } + return true; + } + + bool compute_region_stats(const LoadedImage& image, int xmin, int ymin, + int xmax, int ymax, std::vector& out_min, + std::vector& out_max, + std::vector& out_avg, int& out_samples, + ProbeStatsSemantics semantics) + { + out_min.clear(); + out_max.clear(); + out_avg.clear(); + out_samples = 0; + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0) + return false; + if (xmax < xmin || ymax < ymin) + return false; + + const size_t channels = static_cast(image.nchannels); + out_min.assign(channels, std::numeric_limits::infinity()); + out_max.assign(channels, -std::numeric_limits::infinity()); + out_avg.assign(channels, 0.0); + + std::vector sample; + for (int y = ymin; y <= ymax; ++y) { + for (int x = xmin; x <= xmax; ++x) { + if (!sample_loaded_pixel_with_semantics(image, x, y, semantics, + sample)) { + continue; + } + if (sample.size() != channels) + continue; + for (size_t c = 0; c < channels; ++c) { + out_min[c] = std::min(out_min[c], sample[c]); + out_max[c] = std::max(out_max[c], sample[c]); + out_avg[c] += sample[c]; + } + ++out_samples; + } + } + + if (out_samples <= 0) { + out_min.clear(); + out_max.clear(); + out_avg.clear(); + return false; + } + for (double& value : out_avg) + value /= static_cast(out_samples); + return true; + } + + bool compute_area_stats( + const LoadedImage& image, int center_x, int center_y, int window_size, + std::vector& out_min, std::vector& out_max, + std::vector& out_avg, int& out_samples, + ProbeStatsSemantics semantics = ProbeStatsSemantics::RawStored) + { + if (window_size <= 0) + return false; + if ((window_size & 1) == 0) + ++window_size; + if (image.width <= 0 || image.height <= 0) + return false; + + const int half_window = window_size / 2; + const int x0 = std::max(0, center_x - half_window); + const int x1 = std::min(image.width - 1, center_x + half_window); + const int y0 = std::max(0, center_y - half_window); + const int y1 = std::min(image.height - 1, center_y + half_window); + return compute_region_stats(image, x0, y0, x1, y1, out_min, out_max, + out_avg, out_samples, semantics); + } + + bool compute_rect_stats(const LoadedImage& image, int xbegin, int ybegin, + int xend, int yend, std::vector& out_min, + std::vector& out_max, + std::vector& out_avg, int& out_samples, + ProbeStatsSemantics semantics + = ProbeStatsSemantics::OIIOFloat) + { + if (image.width <= 0 || image.height <= 0) + return false; + const int xmin = std::clamp(std::min(xbegin, xend), 0, image.width - 1); + const int xmax = std::clamp(std::max(xbegin, xend), 0, image.width - 1); + const int ymin = std::clamp(std::min(ybegin, yend), 0, + image.height - 1); + const int ymax = std::clamp(std::max(ybegin, yend), 0, + image.height - 1); + return compute_region_stats(image, xmin, ymin, xmax, ymax, out_min, + out_max, out_avg, out_samples, semantics); + } + + void build_area_probe_placeholder_lines(const LoadedImage& image, + std::vector& out_lines) + { + out_lines.clear(); + out_lines.emplace_back("Area Probe:"); + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0) + return; + for (int c = 0; c < image.nchannels; ++c) { + const std::string channel + = pixel_preview_channel_label(image, static_cast(c)); + out_lines.emplace_back(Strutil::fmt::format( + "{:<5}: [min: ----- max: ----- avg: -----]", channel)); + } + } + + void build_area_probe_result_lines(const LoadedImage& image, + const std::vector& min_values, + const std::vector& max_values, + const std::vector& avg_values, + std::vector& out_lines) + { + out_lines.clear(); + out_lines.emplace_back("Area Probe:"); + const int channel_count = std::max(1, image.nchannels); + for (int c = 0; c < channel_count; ++c) { + const std::string channel + = pixel_preview_channel_label(image, static_cast(c)); + if (static_cast(c) < min_values.size() + && static_cast(c) < max_values.size() + && static_cast(c) < avg_values.size()) { + out_lines.emplace_back(Strutil::fmt::format( + "{:<5}: [min: {:>6.3f} max: {:>6.3f} avg: {:>6.3f}]", + channel, min_values[c], max_values[c], avg_values[c])); + } else { + out_lines.emplace_back(Strutil::fmt::format( + "{:<5}: [min: ----- max: ----- avg: -----]", channel)); + } + } + } + + std::string format_probe_display_value(double value) + { + if (!std::isfinite(value)) + return Strutil::fmt::format("{:>8}", value); + + const double abs_value = std::abs(value); + if ((abs_value > 99999.0) || (abs_value > 0.0 && abs_value < 0.00001)) + return Strutil::fmt::format("{: .1e}", value); + + int digits_before_decimal = 1; + if (abs_value >= 1.0) { + digits_before_decimal + = static_cast(std::floor(std::log10(abs_value))) + 1; + } + digits_before_decimal = std::clamp(digits_before_decimal, 1, 5); + const int decimals = std::clamp(6 - digits_before_decimal, 1, 5); + + const std::string format = Strutil::fmt::format("{{: .{}f}}", decimals); + return Strutil::fmt::format(format, value); + } + + ImU32 probe_channel_color(const std::string& label) + { + if (!label.empty()) { + if (label[0] == 'R') + return IM_COL32(250, 94, 143, 255); + if (label[0] == 'G') + return IM_COL32(135, 203, 124, 255); + if (label[0] == 'B') + return IM_COL32(107, 188, 255, 255); + } + return IM_COL32(220, 220, 220, 255); + } + + OverlayPanelRect + draw_overlay_text_panel(const std::vector& lines, + const ImVec2& preferred_pos, const ImVec2& clip_min, + const ImVec2& clip_max, ImFont* font = nullptr) + { + OverlayPanelRect panel; + if (lines.empty()) + return panel; + + ImFont* draw_font = font ? font : ImGui::GetFont(); + const float font_size = draw_font ? draw_font->LegacySize + : ImGui::GetFontSize(); + const float pad_x = UiMetrics::OverlayPanel::kPadX; + const float pad_y = UiMetrics::OverlayPanel::kPadY; + const float line_gap = UiMetrics::OverlayPanel::kLineGap; + const float line_h = draw_font ? draw_font->LegacySize + : ImGui::GetTextLineHeight(); + + float text_w = 0.0f; + for (const std::string& line : lines) { + const ImVec2 size + = draw_font->CalcTextSizeA(font_size, + std::numeric_limits::max(), + 0.0f, line.c_str()); + if (size.x > text_w) + text_w = size.x; + } + const float panel_w = text_w + pad_x * 2.0f; + const float panel_h = pad_y * 2.0f + + static_cast(lines.size()) * line_h + + static_cast(lines.size() - 1) * line_gap; + + const float min_x = std::min(clip_min.x, clip_max.x); + const float min_y = std::min(clip_min.y, clip_max.y); + const float max_x = std::max(clip_min.x, clip_max.x); + const float max_y = std::max(clip_min.y, clip_max.y); + if ((max_x - min_x) < panel_w || (max_y - min_y) < panel_h) + return panel; + + ImVec2 pos = preferred_pos; + pos.x = std::clamp(pos.x, min_x, std::max(min_x, max_x - panel_w)); + pos.y = std::clamp(pos.y, min_y, std::max(min_y, max_y - panel_h)); + + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + draw_list->PushClipRect(clip_min, clip_max, true); + draw_list->AddRectFilled(pos, ImVec2(pos.x + panel_w, pos.y + panel_h), + IM_COL32(20, 24, 30, 224), + UiMetrics::OverlayPanel::kCornerRounding); + draw_list->AddRect(pos, ImVec2(pos.x + panel_w, pos.y + panel_h), + IM_COL32(175, 185, 205, 255), + UiMetrics::OverlayPanel::kCornerRounding, 0, + UiMetrics::OverlayPanel::kBorderThickness); + + ImVec2 text_pos(pos.x + pad_x, pos.y + pad_y); + for (const std::string& line : lines) { + draw_list->AddText(draw_font, font_size, text_pos, + IM_COL32(240, 242, 245, 255), line.c_str()); + text_pos.y += line_h + line_gap; + } + draw_list->PopClipRect(); + + panel.valid = true; + panel.min = pos; + panel.max = ImVec2(pos.x + panel_w, pos.y + panel_h); + return panel; + } + + void draw_corner_marker(ImDrawList* draw_list, const ImVec2& p0, + const ImVec2& p1, ImU32 color) + { + const float corner_size = UiMetrics::OverlayPanel::kCornerMarkerSize; + draw_list->AddLine(p0, ImVec2(p0.x + corner_size, p0.y), color, 1.0f); + draw_list->AddLine(p0, ImVec2(p0.x, p0.y + corner_size), color, 1.0f); + draw_list->AddLine(ImVec2(p1.x - corner_size, p0.y), ImVec2(p1.x, p0.y), + color, 1.0f); + draw_list->AddLine(ImVec2(p1.x, p0.y), ImVec2(p1.x, p0.y + corner_size), + color, 1.0f); + draw_list->AddLine(ImVec2(p0.x, p1.y - corner_size), ImVec2(p0.x, p1.y), + color, 1.0f); + draw_list->AddLine(ImVec2(p0.x, p1.y), ImVec2(p0.x + corner_size, p1.y), + color, 1.0f); + draw_list->AddLine(ImVec2(p1.x - corner_size, p1.y), p1, color, 1.0f); + draw_list->AddLine(ImVec2(p1.x, p1.y - corner_size), p1, color, 1.0f); + } + +} // namespace + +void +reset_area_probe_overlay(ViewerState& viewer) +{ + viewer.area_probe_drag_active = false; + viewer.area_probe_drag_start_uv = ImVec2(0.0f, 0.0f); + viewer.area_probe_drag_end_uv = ImVec2(0.0f, 0.0f); + build_area_probe_placeholder_lines(viewer.image, viewer.area_probe_lines); +} + +void +update_area_probe_overlay(ViewerState& viewer, int xbegin, int ybegin, int xend, + int yend) +{ + if (viewer.image.path.empty()) { + viewer.area_probe_lines.clear(); + return; + } + + std::vector min_values; + std::vector max_values; + std::vector avg_values; + int sample_count = 0; + if (!compute_rect_stats(viewer.image, xbegin, ybegin, xend, yend, + min_values, max_values, avg_values, sample_count, + ProbeStatsSemantics::OIIOFloat)) { + build_area_probe_placeholder_lines(viewer.image, + viewer.area_probe_lines); + return; + } + + build_area_probe_result_lines(viewer.image, min_values, max_values, + avg_values, viewer.area_probe_lines); +} + +void +sync_area_probe_to_selection(ViewerState& viewer, + const PlaceholderUiState& ui_state) +{ + if (!ui_state.show_area_probe_window || !has_image_selection(viewer)) { + reset_area_probe_overlay(viewer); + return; + } + + update_area_probe_overlay(viewer, viewer.selection_xbegin, + viewer.selection_ybegin, + viewer.selection_xend - 1, + viewer.selection_yend - 1); +} + +void +build_area_probe_overlay_lines(const ViewerState& viewer, + std::vector& out_lines) +{ + out_lines = viewer.area_probe_lines; + if (!out_lines.empty()) + return; + + if (viewer.image.path.empty()) { + out_lines.emplace_back("Area Probe:"); + out_lines.emplace_back("No image loaded."); + return; + } + + build_area_probe_placeholder_lines(viewer.image, out_lines); +} + +void +build_pixel_closeup_overlay_text(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ProbeOverlayText& out_text) +{ + out_text.lines.clear(); + out_text.colors.clear(); + + if (viewer.image.path.empty()) { + out_text.lines.emplace_back("No image loaded."); + out_text.colors.emplace_back(IM_COL32(240, 242, 245, 255)); + return; + } + if (!viewer.probe_valid) { + out_text.lines.emplace_back("Hover over image to inspect."); + out_text.colors.emplace_back(IM_COL32(240, 242, 245, 255)); + return; + } + + out_text.lines.reserve(viewer.probe_channels.size() + 3); + out_text.colors.reserve(viewer.probe_channels.size() + 3); + + out_text.lines.emplace_back( + Strutil::fmt::format("X: {:>7} Y: {:>7}", viewer.probe_x, + viewer.probe_y)); + out_text.colors.emplace_back(IM_COL32(0, 255, 255, 220)); + + std::vector min_values; + std::vector max_values; + std::vector avg_values; + int sample_count = 0; + const bool have_stats + = compute_area_stats(viewer.image, viewer.probe_x, viewer.probe_y, + ui_state.closeup_avg_pixels, min_values, + max_values, avg_values, sample_count, + ProbeStatsSemantics::OIIOFloat); + + out_text.lines.emplace_back(""); + out_text.colors.emplace_back(IM_COL32(240, 242, 245, 255)); + out_text.lines.emplace_back( + Strutil::fmt::format("{:<2} {:>8} {:>8} {:>8} {:>8}", "", "Val", "Min", + "Max", "Avg")); + out_text.colors.emplace_back(IM_COL32(255, 255, 160, 220)); + for (size_t c = 0; c < viewer.probe_channels.size(); ++c) { + const std::string label + = pixel_preview_channel_label(viewer.image, static_cast(c)); + const double semantic_value + = probe_value_to_oiio_float(viewer.image.type, + viewer.probe_channels[c]); + const std::string value = format_probe_display_value(semantic_value); + if (have_stats && c < min_values.size() && c < max_values.size() + && c < avg_values.size()) { + out_text.lines.emplace_back(Strutil::fmt::format( + "{:<2} {:>8} {:>8} {:>8} {:>8}", label, value, + format_probe_display_value(min_values[c]), + format_probe_display_value(max_values[c]), + format_probe_display_value(avg_values[c]))); + } else { + out_text.lines.emplace_back( + Strutil::fmt::format("{:<2} {:>8} {:>8} {:>8} {:>8}", label, + value, "-----", "-----", "-----")); + } + out_text.colors.emplace_back(probe_channel_color(label)); + } + (void)sample_count; +} + +OverlayPanelRect +draw_pixel_closeup_overlay(const ViewerState& viewer, + PlaceholderUiState& ui_state, + const ImageCoordinateMap& map, + ImTextureRef closeup_texture, + bool has_closeup_texture, const AppFonts& fonts) +{ + OverlayPanelRect panel; + if (!ui_state.show_pixelview_window || !map.valid) + return panel; + if (ui_state.show_area_probe_window && viewer.area_probe_drag_active) + return panel; + + ProbeOverlayText text; + build_pixel_closeup_overlay_text(viewer, ui_state, text); + const std::vector& lines = text.lines; + const std::vector& line_colors = text.colors; + + const float closeup_window_size = UiMetrics::PixelCloseup::kWindowSize; + const float follow_mouse_offset + = UiMetrics::PixelCloseup::kFollowMouseOffset; + const float corner_padding = UiMetrics::PixelCloseup::kCornerPadding; + const float text_pad_x = UiMetrics::PixelCloseup::kTextPadX; + const float text_pad_y = UiMetrics::PixelCloseup::kTextPadY; + const float text_line_gap = UiMetrics::PixelCloseup::kTextLineGap; + const float text_to_window_gap = UiMetrics::PixelCloseup::kTextToWindowGap; + const float text_wrap_w = std::max(8.0f, + closeup_window_size - text_pad_x * 2.0f); + ImFont* text_font = fonts.mono ? fonts.mono : ImGui::GetFont(); + const float text_font_size = UiMetrics::PixelCloseup::kFontSize; + + float text_panel_h = text_pad_y * 2.0f; + for (const std::string& line : lines) { + const ImVec2 line_size + = text_font->CalcTextSizeA(text_font_size, + std::numeric_limits::max(), + text_wrap_w, line.c_str()); + text_panel_h += line_size.y; + text_panel_h += text_line_gap; + } + if (!lines.empty()) + text_panel_h -= text_line_gap; + const float text_panel_w = closeup_window_size; + const float total_h = closeup_window_size + text_to_window_gap + + text_panel_h; + + const float clip_min_x = std::min(map.viewport_rect_min.x, + map.viewport_rect_max.x); + const float clip_min_y = std::min(map.viewport_rect_min.y, + map.viewport_rect_max.y); + const float clip_max_x = std::max(map.viewport_rect_min.x, + map.viewport_rect_max.x); + const float clip_max_y = std::max(map.viewport_rect_min.y, + map.viewport_rect_max.y); + if ((clip_max_x - clip_min_x) < closeup_window_size + || (clip_max_y - clip_min_y) < total_h) { + return panel; + } + + ImVec2 closeup_min(clip_min_x + corner_padding, + clip_min_y + corner_padding); + const ImVec2 mouse_pos = ImGui::GetIO().MousePos; + + if (ui_state.pixelview_follows_mouse) { + const bool should_show_on_left = (mouse_pos.x + closeup_window_size + + follow_mouse_offset) + > clip_max_x; + const bool should_show_above = (mouse_pos.y + closeup_window_size + + follow_mouse_offset + text_panel_h) + > clip_max_y; + + closeup_min.x = mouse_pos.x + follow_mouse_offset; + closeup_min.y = mouse_pos.y + follow_mouse_offset; + if (should_show_on_left) { + closeup_min.x = mouse_pos.x - follow_mouse_offset + - closeup_window_size; + } + if (should_show_above) { + closeup_min.y = mouse_pos.y - follow_mouse_offset + - closeup_window_size - text_to_window_gap + - text_panel_h; + } + } else { + closeup_min.x = ui_state.pixelview_left_corner + ? (clip_min_x + corner_padding) + : (clip_max_x - closeup_window_size + - corner_padding); + closeup_min.y = clip_min_y + corner_padding; + + const ImVec2 panel_max(closeup_min.x + text_panel_w, + closeup_min.y + total_h); + const bool mouse_over_panel = mouse_pos.x >= closeup_min.x + && mouse_pos.x <= panel_max.x + && mouse_pos.y >= closeup_min.y + && mouse_pos.y <= panel_max.y; + if (mouse_over_panel) { + ui_state.pixelview_left_corner = !ui_state.pixelview_left_corner; + closeup_min.x = ui_state.pixelview_left_corner + ? (clip_min_x + corner_padding) + : (clip_max_x - closeup_window_size + - corner_padding); + } + } + + closeup_min.x = std::clamp(closeup_min.x, clip_min_x, + std::max(clip_min_x, clip_max_x - text_panel_w)); + closeup_min.y = std::clamp(closeup_min.y, clip_min_y, + std::max(clip_min_y, clip_max_y - total_h)); + const ImVec2 closeup_max(closeup_min.x + closeup_window_size, + closeup_min.y + closeup_window_size); + const ImVec2 text_min(closeup_min.x, closeup_max.y + text_to_window_gap); + const ImVec2 text_max(text_min.x + text_panel_w, text_min.y + text_panel_h); + + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + draw_list->PushClipRect(map.viewport_rect_min, map.viewport_rect_max, true); + draw_list->AddRectFilled(closeup_min, closeup_max, + IM_COL32(20, 24, 30, 224), + UiMetrics::OverlayPanel::kCornerRounding); + draw_list->AddRect(closeup_min, closeup_max, IM_COL32(175, 185, 205, 255), + UiMetrics::OverlayPanel::kCornerRounding, 0, + UiMetrics::OverlayPanel::kBorderThickness); + + const bool render_zoom_patch = has_closeup_texture + && !viewer.image.path.empty() + && viewer.probe_valid && map.source_width > 0 + && map.source_height > 0; + if (render_zoom_patch) { + int display_w = viewer.image.width; + int display_h = viewer.image.height; + oriented_image_dimensions(viewer.image, display_w, display_h); + display_w = std::max(1, display_w); + display_h = std::max(1, display_h); + + int closeup_px = std::clamp(ui_state.closeup_pixels, 1, + std::min(display_w, display_h)); + if (closeup_px <= 0) + closeup_px = 1; + int patch_w = std::min(closeup_px, display_w); + int patch_h = std::min(closeup_px, display_h); + patch_w = std::max(1, patch_w); + patch_h = std::max(1, patch_h); + + const ImVec2 source_uv((static_cast(viewer.probe_x) + 0.5f) + / static_cast(map.source_width), + (static_cast(viewer.probe_y) + 0.5f) + / static_cast(map.source_height)); + const ImVec2 display_uv = source_uv_to_display_uv(source_uv, + map.orientation); + const int center_x + = std::clamp(static_cast(std::floor(display_uv.x * display_w)), + 0, display_w - 1); + const int center_y + = std::clamp(static_cast(std::floor(display_uv.y * display_h)), + 0, display_h - 1); + + const int xbegin = std::clamp(center_x - patch_w / 2, 0, + std::max(0, display_w - patch_w)); + const int ybegin = std::clamp(center_y - patch_h / 2, 0, + std::max(0, display_h - patch_h)); + const int xend = xbegin + patch_w; + const int yend = ybegin + patch_h; + + const ImVec2 uv_min(static_cast(xbegin) / display_w, + static_cast(ybegin) / display_h); + const ImVec2 uv_max(static_cast(xend) / display_w, + static_cast(yend) / display_h); + draw_list->AddImage(closeup_texture, closeup_min, closeup_max, uv_min, + uv_max, IM_COL32_WHITE); + + const float cell_w = closeup_window_size / patch_w; + const float cell_h = closeup_window_size / patch_h; + for (int i = 1; i < patch_w; ++i) { + const float x = closeup_min.x + i * cell_w; + draw_list->AddLine(ImVec2(x, closeup_min.y), + ImVec2(x, closeup_max.y), + IM_COL32(8, 10, 12, 140), 1.0f); + } + for (int i = 1; i < patch_h; ++i) { + const float y = closeup_min.y + i * cell_h; + draw_list->AddLine(ImVec2(closeup_min.x, y), + ImVec2(closeup_max.x, y), + IM_COL32(8, 10, 12, 140), 1.0f); + } + + const int center_ix = center_x - xbegin; + const int center_iy = center_y - ybegin; + const ImVec2 center_min(closeup_min.x + center_ix * cell_w, + closeup_min.y + center_iy * cell_h); + const ImVec2 center_max(center_min.x + cell_w, center_min.y + cell_h); + draw_corner_marker(draw_list, center_min, center_max, + IM_COL32(0, 255, 255, 180)); + + int avg_px = std::clamp(ui_state.closeup_avg_pixels, 1, + std::min(patch_w, patch_h)); + if ((avg_px & 1) == 0) + avg_px = std::max(1, avg_px - 1); + if (avg_px > 1) { + int avg_start_x = center_ix - avg_px / 2; + int avg_start_y = center_iy - avg_px / 2; + int avg_end_x = avg_start_x + avg_px; + int avg_end_y = avg_start_y + avg_px; + avg_start_x = std::clamp(avg_start_x, 0, patch_w - avg_px); + avg_start_y = std::clamp(avg_start_y, 0, patch_h - avg_px); + avg_end_x = avg_start_x + avg_px; + avg_end_y = avg_start_y + avg_px; + const ImVec2 avg_min(closeup_min.x + avg_start_x * cell_w, + closeup_min.y + avg_start_y * cell_h); + const ImVec2 avg_max(closeup_min.x + avg_end_x * cell_w, + closeup_min.y + avg_end_y * cell_h); + draw_corner_marker(draw_list, avg_min, avg_max, + IM_COL32(255, 255, 0, 170)); + } + } + + draw_list->AddRectFilled(text_min, text_max, IM_COL32(20, 24, 30, 224), + UiMetrics::OverlayPanel::kCornerRounding); + draw_list->AddRect(text_min, text_max, IM_COL32(175, 185, 205, 255), + UiMetrics::OverlayPanel::kCornerRounding, 0, + UiMetrics::OverlayPanel::kBorderThickness); + ImVec2 text_pos(text_min.x + text_pad_x, text_min.y + text_pad_y); + for (size_t i = 0; i < lines.size(); ++i) { + const std::string& line = lines[i]; + const ImU32 color = i < line_colors.size() + ? line_colors[i] + : IM_COL32(240, 242, 245, 255); + draw_list->AddText(text_font, text_font_size, text_pos, color, + line.c_str(), nullptr, text_wrap_w); + const ImVec2 line_size + = text_font->CalcTextSizeA(text_font_size, + std::numeric_limits::max(), + text_wrap_w, line.c_str()); + text_pos.y += line_size.y + text_line_gap; + } + draw_list->PopClipRect(); + + panel.valid = true; + panel.min = closeup_min; + panel.max = ImVec2(closeup_min.x + text_panel_w, closeup_min.y + total_h); + register_layout_dump_synthetic_rect("text", "Pixel Closeup overlay", + panel.min, panel.max); + return panel; +} + +void +draw_area_probe_overlay(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + const ImageCoordinateMap& map, + const OverlayPanelRect& pixel_overlay_panel, + const AppFonts& fonts) +{ + (void)pixel_overlay_panel; + if (!ui_state.show_area_probe_window || !map.valid) + return; + + std::vector lines; + build_area_probe_overlay_lines(viewer, lines); + + const float clip_min_x = std::min(map.viewport_rect_min.x, + map.viewport_rect_max.x); + const float clip_max_y = std::max(map.viewport_rect_min.y, + map.viewport_rect_max.y); + const float pad_y = UiMetrics::AreaProbe::kPadY; + const float line_gap = UiMetrics::AreaProbe::kLineGap; + const ImFont* mono_font = fonts.mono ? fonts.mono : ImGui::GetFont(); + const float line_h = mono_font ? mono_font->LegacySize + : ImGui::GetTextLineHeight(); + float panel_h = pad_y * 2.0f + static_cast(lines.size()) * line_h + + static_cast(std::max(0, lines.size() - 1)) + * line_gap; + const float border_margin = UiMetrics::AreaProbe::kBorderMargin; + ImVec2 preferred(clip_min_x + border_margin, + clip_max_y - panel_h - border_margin); + const OverlayPanelRect panel + = draw_overlay_text_panel(lines, preferred, map.viewport_rect_min, + map.viewport_rect_max, fonts.mono); + if (panel.valid) { + register_layout_dump_synthetic_rect("text", "Area Probe overlay", + panel.min, panel.max); + } +} + +} // namespace Imiv diff --git a/src/imiv/imiv_probe_overlay.h b/src/imiv/imiv_probe_overlay.h new file mode 100644 index 0000000000..eb66eaa4ee --- /dev/null +++ b/src/imiv/imiv_probe_overlay.h @@ -0,0 +1,47 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_ui.h" + +#include +#include + +namespace Imiv { + +struct ProbeOverlayText { + std::vector lines; + std::vector colors; +}; + +void +reset_area_probe_overlay(ViewerState& viewer); +void +update_area_probe_overlay(ViewerState& viewer, int xbegin, int ybegin, int xend, + int yend); +void +sync_area_probe_to_selection(ViewerState& viewer, + const PlaceholderUiState& ui_state); +void +build_area_probe_overlay_lines(const ViewerState& viewer, + std::vector& out_lines); +void +build_pixel_closeup_overlay_text(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ProbeOverlayText& out_text); +OverlayPanelRect +draw_pixel_closeup_overlay(const ViewerState& viewer, + PlaceholderUiState& ui_state, + const ImageCoordinateMap& map, + ImTextureRef closeup_texture, + bool has_closeup_texture, const AppFonts& fonts); +void +draw_area_probe_overlay(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + const ImageCoordinateMap& map, + const OverlayPanelRect& pixel_overlay_panel, + const AppFonts& fonts); + +} // namespace Imiv diff --git a/src/imiv/imiv_renderer.cpp b/src/imiv/imiv_renderer.cpp new file mode 100644 index 0000000000..cc05e82dd5 --- /dev/null +++ b/src/imiv/imiv_renderer.cpp @@ -0,0 +1,698 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_renderer.h" + +#include "imiv_build_config.h" +#include "imiv_platform_glfw.h" +#include "imiv_renderer_backend.h" +#include "imiv_viewer.h" + +#include +#include + +#include + +namespace Imiv { + +namespace { + + const RendererBackendVTable* renderer_backend_vtable(BackendKind kind) + { + switch (kind) { +#if IMIV_WITH_VULKAN + case BackendKind::Vulkan: return renderer_backend_vulkan_vtable(); +#else + case BackendKind::Vulkan: break; +#endif +#if IMIV_WITH_METAL + case BackendKind::Metal: return renderer_backend_metal_vtable(); +#else + case BackendKind::Metal: break; +#endif +#if IMIV_WITH_OPENGL + case BackendKind::OpenGL: return renderer_backend_opengl_vtable(); +#else + case BackendKind::OpenGL: break; +#endif + case BackendKind::Auto: break; + } + return nullptr; + } + + const RendererBackendVTable* + renderer_dispatch_vtable(const RendererState& renderer_state) + { + if (renderer_state.vtable != nullptr) + return renderer_state.vtable; + return renderer_backend_vtable(renderer_state.active_backend); + } + + const RendererBackendVTable* + texture_dispatch_vtable(const RendererTexture& texture) + { + if (texture.vtable != nullptr) + return texture.vtable; + return renderer_backend_vtable(texture.backend_kind); + } + + BackendInfo make_backend_info(BackendKind kind, const char* cli_name, + const char* display_name) + { + BackendInfo info; + info.kind = kind; + info.cli_name = cli_name; + info.display_name = display_name; + switch (kind) { + case BackendKind::Vulkan: info.compiled = IMIV_WITH_VULKAN != 0; break; + case BackendKind::Metal: info.compiled = IMIV_WITH_METAL != 0; break; + case BackendKind::OpenGL: info.compiled = IMIV_WITH_OPENGL != 0; break; + case BackendKind::Auto: info.compiled = false; break; + } + info.active_build = (kind == active_build_backend_kind()); + info.platform_default = (kind == platform_default_backend_kind()); + return info; + } + + size_t backend_info_index(BackendKind kind) + { + switch (kind) { + case BackendKind::Vulkan: return 0; + case BackendKind::Metal: return 1; + case BackendKind::OpenGL: return 2; + case BackendKind::Auto: break; + } + return compiled_backend_info().size(); + } + + template + BackendKind resolve_backend_with_predicate(BackendKind requested_kind, + Predicate predicate) + { + if (requested_kind != BackendKind::Auto && predicate(requested_kind)) + return requested_kind; + + const BackendKind build_default = active_build_backend_kind(); + if (build_default != BackendKind::Auto && predicate(build_default)) + return build_default; + + const BackendKind platform_default = platform_default_backend_kind(); + if (platform_default != BackendKind::Auto + && predicate(platform_default)) + return platform_default; + + if (predicate(BackendKind::Vulkan)) + return BackendKind::Vulkan; + if (predicate(BackendKind::Metal)) + return BackendKind::Metal; + if (predicate(BackendKind::OpenGL)) + return BackendKind::OpenGL; + + return BackendKind::Auto; + } + + std::array& mutable_runtime_backend_info() + { + static std::array info; + return info; + } + + bool& runtime_backend_info_is_valid_flag() + { + static bool valid = false; + return valid; + } + + void reset_runtime_backend_info() + { + std::array& info = mutable_runtime_backend_info(); + const std::array& compiled = compiled_backend_info(); + for (size_t i = 0; i < info.size(); ++i) { + info[i].build_info = compiled[i]; + info[i].available = compiled[i].compiled; + info[i].probed = false; + info[i].unavailable_reason.clear(); + } + } + +} // namespace + +BackendKind +sanitize_backend_kind(int value) +{ + switch (static_cast(value)) { + case BackendKind::Auto: + case BackendKind::Vulkan: + case BackendKind::Metal: + case BackendKind::OpenGL: return static_cast(value); + } + return BackendKind::Auto; +} + +bool +parse_backend_kind(std::string_view value, BackendKind& out_kind) +{ + const std::string normalized = OIIO::Strutil::lower( + std::string(OIIO::Strutil::strip(value))); + if (normalized.empty() || normalized == "auto") { + out_kind = BackendKind::Auto; + return true; + } + if (normalized == "vulkan") { + out_kind = BackendKind::Vulkan; + return true; + } + if (normalized == "metal") { + out_kind = BackendKind::Metal; + return true; + } + if (normalized == "opengl" || normalized == "gl") { + out_kind = BackendKind::OpenGL; + return true; + } + return false; +} + +const char* +backend_cli_name(BackendKind kind) +{ + switch (kind) { + case BackendKind::Auto: return "auto"; + case BackendKind::Vulkan: return "vulkan"; + case BackendKind::Metal: return "metal"; + case BackendKind::OpenGL: return "opengl"; + } + return "auto"; +} + +const char* +backend_display_name(BackendKind kind) +{ + switch (kind) { + case BackendKind::Auto: return "Auto"; + case BackendKind::Vulkan: return "Vulkan"; + case BackendKind::Metal: return "Metal"; + case BackendKind::OpenGL: return "OpenGL"; + } + return "Auto"; +} + +const char* +backend_runtime_name(BackendKind kind) +{ + switch (kind) { + case BackendKind::Vulkan: return "glfw+vulkan"; + case BackendKind::Metal: return "glfw+metal"; + case BackendKind::OpenGL: return "glfw+opengl"; + case BackendKind::Auto: return "auto"; + } + return "auto"; +} + +BackendKind +active_build_backend_kind() +{ + return sanitize_backend_kind(IMIV_BUILD_DEFAULT_BACKEND_KIND); +} + +BackendKind +platform_default_backend_kind() +{ +#if defined(__APPLE__) + if (IMIV_WITH_METAL) + return BackendKind::Metal; + if (IMIV_WITH_VULKAN) + return BackendKind::Vulkan; + if (IMIV_WITH_OPENGL) + return BackendKind::OpenGL; +#else + if (IMIV_WITH_VULKAN) + return BackendKind::Vulkan; + if (IMIV_WITH_OPENGL) + return BackendKind::OpenGL; + if (IMIV_WITH_METAL) + return BackendKind::Metal; +#endif + return BackendKind::Auto; +} + +BackendKind +resolve_backend_request(BackendKind requested_kind) +{ + return resolve_backend_with_predicate(requested_kind, + backend_kind_is_available); +} + +bool +refresh_runtime_backend_info(bool verbose_logging, std::string& error_message) +{ + error_message.clear(); + reset_runtime_backend_info(); + + bool initialized_here = false; + if (!platform_glfw_is_initialized()) { + if (!platform_glfw_init(verbose_logging, error_message)) { + std::array& info + = mutable_runtime_backend_info(); + for (BackendRuntimeInfo& runtime_info : info) { + if (!runtime_info.build_info.compiled) + continue; + runtime_info.available = false; + runtime_info.probed = true; + runtime_info.unavailable_reason = error_message; + } + runtime_backend_info_is_valid_flag() = true; + return false; + } + initialized_here = true; + } + + std::array& info = mutable_runtime_backend_info(); + for (BackendRuntimeInfo& runtime_info : info) { + if (!runtime_info.build_info.compiled) + continue; + std::string probe_error; + runtime_info.available = renderer_probe_backend_runtime_support( + runtime_info.build_info.kind, probe_error); + runtime_info.probed = true; + if (!runtime_info.available) { + runtime_info.unavailable_reason = probe_error.empty() + ? "backend is unavailable" + : probe_error; + } + } + + runtime_backend_info_is_valid_flag() = true; + if (initialized_here) + platform_glfw_terminate(); + return true; +} + +void +clear_runtime_backend_info() +{ + reset_runtime_backend_info(); + runtime_backend_info_is_valid_flag() = false; +} + +bool +runtime_backend_info_valid() +{ + return runtime_backend_info_is_valid_flag(); +} + +bool +backend_kind_is_available(BackendKind kind) +{ + if (!backend_kind_is_compiled(kind)) + return false; + if (!runtime_backend_info_valid()) + return true; + const size_t index = backend_info_index(kind); + if (index >= mutable_runtime_backend_info().size()) + return false; + return mutable_runtime_backend_info()[index].available; +} + +std::string_view +backend_unavailable_reason(BackendKind kind) +{ + if (!runtime_backend_info_valid()) + return {}; + const size_t index = backend_info_index(kind); + if (index >= mutable_runtime_backend_info().size()) + return {}; + return mutable_runtime_backend_info()[index].unavailable_reason; +} + +bool +backend_kind_is_compiled(BackendKind kind) +{ + switch (kind) { + case BackendKind::Auto: return false; + case BackendKind::Vulkan: return IMIV_WITH_VULKAN != 0; + case BackendKind::Metal: return IMIV_WITH_METAL != 0; + case BackendKind::OpenGL: return IMIV_WITH_OPENGL != 0; + } + return false; +} + +const std::array& +compiled_backend_info() +{ + static const std::array info = { + make_backend_info(BackendKind::Vulkan, "vulkan", "Vulkan"), + make_backend_info(BackendKind::Metal, "metal", "Metal"), + make_backend_info(BackendKind::OpenGL, "opengl", "OpenGL"), + }; + return info; +} + +const std::array& +runtime_backend_info() +{ + if (!runtime_backend_info_valid()) + reset_runtime_backend_info(); + return mutable_runtime_backend_info(); +} + +size_t +compiled_backend_count() +{ + size_t count = 0; + for (const BackendInfo& info : compiled_backend_info()) { + if (info.compiled) + ++count; + } + return count; +} + +void +renderer_select_backend(RendererState& renderer_state, BackendKind backend) +{ + renderer_state.active_backend = backend; + renderer_state.vtable = renderer_backend_vtable(backend); +} + +BackendKind +renderer_active_backend(const RendererState& renderer_state) +{ + return renderer_state.active_backend; +} + +bool +renderer_probe_backend_runtime_support(BackendKind backend, + std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_backend_vtable(backend); + if (vtable == nullptr || vtable->probe_runtime_support == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->probe_runtime_support(error_message); +} + +bool +renderer_texture_is_loading(const RendererTexture& texture) +{ + const RendererBackendVTable* vtable = texture_dispatch_vtable(texture); + if (vtable == nullptr || vtable->texture_is_loading == nullptr) + return false; + return vtable->texture_is_loading(texture); +} + +void +renderer_get_viewer_texture_refs(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ImTextureRef& main_texture_ref, + bool& has_main_texture, + ImTextureRef& closeup_texture_ref, + bool& has_closeup_texture) +{ + main_texture_ref = ImTextureRef(); + closeup_texture_ref = ImTextureRef(); + has_main_texture = false; + has_closeup_texture = false; + const RendererBackendVTable* vtable = texture_dispatch_vtable( + viewer.texture); + if (vtable == nullptr || vtable->get_viewer_texture_refs == nullptr) + return; + vtable->get_viewer_texture_refs(viewer, ui_state, main_texture_ref, + has_main_texture, closeup_texture_ref, + has_closeup_texture); +} + +bool +renderer_create_texture(RendererState& renderer_state, const LoadedImage& image, + RendererTexture& texture, std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->create_texture == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + const bool ok = vtable->create_texture(renderer_state, image, texture, + error_message); + if (ok) { + texture.backend_kind = renderer_state.active_backend; + texture.vtable = vtable; + } + return ok; +} + +void +renderer_destroy_texture(RendererState& renderer_state, + RendererTexture& texture) +{ + const RendererBackendVTable* vtable = texture_dispatch_vtable(texture); + if (vtable != nullptr && vtable->destroy_texture != nullptr) + vtable->destroy_texture(renderer_state, texture); + texture.vtable = nullptr; + texture.backend_kind = BackendKind::Auto; + texture.backend = nullptr; + texture.preview_initialized = false; +} + +bool +renderer_update_preview_texture(RendererState& renderer_state, + RendererTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) +{ + const RendererBackendVTable* vtable = texture_dispatch_vtable(texture); + if (vtable == nullptr || vtable->update_preview_texture == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->update_preview_texture(renderer_state, texture, image, + ui_state, controls, error_message); +} + +bool +renderer_quiesce_texture_preview_submission(RendererState& renderer_state, + RendererTexture& texture, + std::string& error_message) +{ + const RendererBackendVTable* vtable = texture_dispatch_vtable(texture); + if (vtable == nullptr + || vtable->quiesce_texture_preview_submission == nullptr) { + error_message.clear(); + return true; + } + return vtable->quiesce_texture_preview_submission(renderer_state, texture, + error_message); +} + +bool +renderer_setup_instance(RendererState& renderer_state, + ImVector& instance_extensions, + std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->setup_instance == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->setup_instance(renderer_state, instance_extensions, + error_message); +} + +bool +renderer_setup_device(RendererState& renderer_state, std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->setup_device == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->setup_device(renderer_state, error_message); +} + +bool +renderer_setup_window(RendererState& renderer_state, int width, int height, + std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->setup_window == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->setup_window(renderer_state, width, height, error_message); +} + +bool +renderer_create_surface(RendererState& renderer_state, GLFWwindow* window, + std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->create_surface == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->create_surface(renderer_state, window, error_message); +} + +void +renderer_destroy_surface(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->destroy_surface != nullptr) + vtable->destroy_surface(renderer_state); +} + +void +renderer_cleanup_window(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->cleanup_window != nullptr) + vtable->cleanup_window(renderer_state); +} + +void +renderer_cleanup(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->cleanup != nullptr) + vtable->cleanup(renderer_state); +} + +bool +renderer_wait_idle(RendererState& renderer_state, std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->wait_idle == nullptr) { + error_message.clear(); + return true; + } + return vtable->wait_idle(renderer_state, error_message); +} + +bool +renderer_imgui_init(RendererState& renderer_state, std::string& error_message) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->imgui_init == nullptr) { + error_message = "renderer backend is unavailable"; + return false; + } + return vtable->imgui_init(renderer_state, error_message); +} + +void +renderer_imgui_shutdown(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->imgui_shutdown != nullptr) + vtable->imgui_shutdown(); +} + +void +renderer_imgui_new_frame(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->imgui_new_frame != nullptr) + vtable->imgui_new_frame(renderer_state); +} + +bool +renderer_needs_main_window_resize(RendererState& renderer_state, int width, + int height) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->needs_main_window_resize == nullptr) + return false; + return vtable->needs_main_window_resize(renderer_state, width, height); +} + +void +renderer_resize_main_window(RendererState& renderer_state, int width, + int height) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->resize_main_window != nullptr) + vtable->resize_main_window(renderer_state, width, height); +} + +void +renderer_set_main_clear_color(RendererState& renderer_state, float r, float g, + float b, float a) +{ + renderer_state.clear_color[0] = r; + renderer_state.clear_color[1] = g; + renderer_state.clear_color[2] = b; + renderer_state.clear_color[3] = a; + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->set_main_clear_color != nullptr) + vtable->set_main_clear_color(renderer_state, r, g, b, a); +} + +void +renderer_prepare_platform_windows(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->prepare_platform_windows != nullptr) + vtable->prepare_platform_windows(renderer_state); +} + +void +renderer_finish_platform_windows(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->finish_platform_windows != nullptr) + vtable->finish_platform_windows(renderer_state); +} + +void +renderer_frame_render(RendererState& renderer_state, ImDrawData* draw_data) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->frame_render != nullptr) + vtable->frame_render(renderer_state, draw_data); +} + +void +renderer_frame_present(RendererState& renderer_state) +{ + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable != nullptr && vtable->frame_present != nullptr) + vtable->frame_present(renderer_state); +} + +bool +renderer_screen_capture(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data) +{ + if (user_data == nullptr) + return false; + const RendererState& renderer_state = *static_cast( + user_data); + const RendererBackendVTable* vtable = renderer_dispatch_vtable( + renderer_state); + if (vtable == nullptr || vtable->screen_capture == nullptr) + return false; + return vtable->screen_capture(viewport_id, x, y, w, h, pixels, user_data); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_renderer.h b/src/imiv/imiv_renderer.h new file mode 100644 index 0000000000..f43b1dd19a --- /dev/null +++ b/src/imiv/imiv_renderer.h @@ -0,0 +1,241 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_backend.h" +#include "imiv_types.h" + +#include + +#include + +struct GLFWwindow; + +namespace Imiv { + +struct ViewerState; +struct PlaceholderUiState; +struct RendererBackendVTable; + +struct RendererBackendState; +struct RendererTextureBackendState; + +struct RendererState { + const RendererBackendVTable* vtable = nullptr; + RendererBackendState* backend = nullptr; + BackendKind active_backend = BackendKind::Auto; + bool verbose_logging = false; + bool verbose_validation_output = false; + bool log_imgui_texture_updates = false; + float clear_color[4] = { 0.08f, 0.08f, 0.08f, 1.0f }; + int framebuffer_width = 0; + int framebuffer_height = 0; +}; + +struct RendererTexture { + const RendererBackendVTable* vtable = nullptr; + BackendKind backend_kind = BackendKind::Auto; + RendererTextureBackendState* backend = nullptr; + bool preview_initialized = false; +}; + +template +inline BackendStateT* +backend_state(RendererState& renderer_state) +{ + return reinterpret_cast(renderer_state.backend); +} + +template +inline const BackendStateT* +backend_state(const RendererState& renderer_state) +{ + return reinterpret_cast(renderer_state.backend); +} + +template +inline TextureStateT* +texture_backend_state(RendererTexture& texture) +{ + return reinterpret_cast(texture.backend); +} + +template +inline const TextureStateT* +texture_backend_state(const RendererTexture& texture) +{ + return reinterpret_cast(texture.backend); +} + +template +inline bool +ensure_default_backend_state(RendererState& renderer_state) +{ + if (renderer_state.backend != nullptr) + return true; + renderer_state.backend = reinterpret_cast( + new BackendStateT()); + return renderer_state.backend != nullptr; +} + +inline bool +renderer_texture_preview_pending(const RendererTexture& texture) +{ + return texture.backend != nullptr && !texture.preview_initialized; +} + +inline bool +renderer_framebuffer_size_changed(RendererState& renderer_state, int width, + int height) +{ + return renderer_state.framebuffer_width != width + || renderer_state.framebuffer_height != height; +} + +inline void +renderer_set_framebuffer_size(RendererState& renderer_state, int width, + int height) +{ + renderer_state.framebuffer_width = width; + renderer_state.framebuffer_height = height; +} + +inline void +renderer_set_clear_color(RendererState& renderer_state, float r, float g, + float b, float a) +{ + renderer_state.clear_color[0] = r; + renderer_state.clear_color[1] = g; + renderer_state.clear_color[2] = b; + renderer_state.clear_color[3] = a; +} + +template +inline void +renderer_clear_backend_window(RendererState& renderer_state) +{ + if (BackendStateT* state = backend_state(renderer_state)) + state->window = nullptr; +} + +inline bool +renderer_noop_quiesce_texture_preview_submission(RendererState& renderer_state, + RendererTexture& texture, + std::string& error_message) +{ + (void)renderer_state; + (void)texture; + error_message.clear(); + return true; +} + +inline bool +renderer_noop_wait_idle(RendererState& renderer_state, + std::string& error_message) +{ + (void)renderer_state; + error_message.clear(); + return true; +} + +inline void +renderer_noop_platform_windows(RendererState& renderer_state) +{ + (void)renderer_state; +} + +template +inline void +renderer_call_backend_new_frame(RendererState& renderer_state) +{ + (void)renderer_state; + Fn(); +} + +void +renderer_select_backend(RendererState& renderer_state, BackendKind backend); +BackendKind +renderer_active_backend(const RendererState& renderer_state); +bool +renderer_probe_backend_runtime_support(BackendKind backend, + std::string& error_message); +bool +renderer_texture_is_loading(const RendererTexture& texture); + +void +renderer_get_viewer_texture_refs(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ImTextureRef& main_texture_ref, + bool& has_main_texture, + ImTextureRef& closeup_texture_ref, + bool& has_closeup_texture); + +bool +renderer_create_texture(RendererState& renderer_state, const LoadedImage& image, + RendererTexture& texture, std::string& error_message); +void +renderer_destroy_texture(RendererState& renderer_state, + RendererTexture& texture); +bool +renderer_update_preview_texture(RendererState& renderer_state, + RendererTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message); +bool +renderer_quiesce_texture_preview_submission(RendererState& renderer_state, + RendererTexture& texture, + std::string& error_message); + +bool +renderer_setup_instance(RendererState& renderer_state, + ImVector& instance_extensions, + std::string& error_message); +bool +renderer_setup_device(RendererState& renderer_state, + std::string& error_message); +bool +renderer_setup_window(RendererState& renderer_state, int width, int height, + std::string& error_message); +bool +renderer_create_surface(RendererState& renderer_state, GLFWwindow* window, + std::string& error_message); +void +renderer_destroy_surface(RendererState& renderer_state); +void +renderer_cleanup_window(RendererState& renderer_state); +void +renderer_cleanup(RendererState& renderer_state); +bool +renderer_wait_idle(RendererState& renderer_state, std::string& error_message); +bool +renderer_imgui_init(RendererState& renderer_state, std::string& error_message); +void +renderer_imgui_shutdown(RendererState& renderer_state); +void +renderer_imgui_new_frame(RendererState& renderer_state); +bool +renderer_needs_main_window_resize(RendererState& renderer_state, int width, + int height); +void +renderer_resize_main_window(RendererState& renderer_state, int width, + int height); +void +renderer_set_main_clear_color(RendererState& renderer_state, float r, float g, + float b, float a); +void +renderer_prepare_platform_windows(RendererState& renderer_state); +void +renderer_finish_platform_windows(RendererState& renderer_state); +void +renderer_frame_render(RendererState& renderer_state, ImDrawData* draw_data); +void +renderer_frame_present(RendererState& renderer_state); +bool +renderer_screen_capture(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data); + +} // namespace Imiv diff --git a/src/imiv/imiv_renderer_backend.h b/src/imiv/imiv_renderer_backend.h new file mode 100644 index 0000000000..47d7d9c59d --- /dev/null +++ b/src/imiv/imiv_renderer_backend.h @@ -0,0 +1,97 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_renderer.h" + +namespace Imiv { + +struct RendererBackendVTable { + BackendKind kind = BackendKind::Auto; + bool (*probe_runtime_support)(std::string& error_message) = nullptr; + bool (*get_viewer_texture_refs)(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ImTextureRef& main_texture_ref, + bool& has_main_texture, + ImTextureRef& closeup_texture_ref, + bool& has_closeup_texture) + = nullptr; + bool (*texture_is_loading)(const RendererTexture& texture) = nullptr; + bool (*create_texture)(RendererState& renderer_state, + const LoadedImage& image, RendererTexture& texture, + std::string& error_message) + = nullptr; + void (*destroy_texture)(RendererState& renderer_state, + RendererTexture& texture) + = nullptr; + bool (*update_preview_texture)(RendererState& renderer_state, + RendererTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) + = nullptr; + bool (*quiesce_texture_preview_submission)(RendererState& renderer_state, + RendererTexture& texture, + std::string& error_message) + = nullptr; + bool (*setup_instance)(RendererState& renderer_state, + ImVector& instance_extensions, + std::string& error_message) + = nullptr; + bool (*setup_device)(RendererState& renderer_state, + std::string& error_message) + = nullptr; + bool (*setup_window)(RendererState& renderer_state, int width, int height, + std::string& error_message) + = nullptr; + bool (*create_surface)(RendererState& renderer_state, GLFWwindow* window, + std::string& error_message) + = nullptr; + void (*destroy_surface)(RendererState& renderer_state) = nullptr; + void (*cleanup_window)(RendererState& renderer_state) = nullptr; + void (*cleanup)(RendererState& renderer_state) = nullptr; + bool (*wait_idle)(RendererState& renderer_state, std::string& error_message) + = nullptr; + bool (*imgui_init)(RendererState& renderer_state, + std::string& error_message) + = nullptr; + void (*imgui_shutdown)() = nullptr; + void (*imgui_new_frame)(RendererState& renderer_state) = nullptr; + bool (*needs_main_window_resize)(RendererState& renderer_state, int width, + int height) + = nullptr; + void (*resize_main_window)(RendererState& renderer_state, int width, + int height) + = nullptr; + void (*set_main_clear_color)(RendererState& renderer_state, float r, + float g, float b, float a) + = nullptr; + void (*prepare_platform_windows)(RendererState& renderer_state) = nullptr; + void (*finish_platform_windows)(RendererState& renderer_state) = nullptr; + void (*frame_render)(RendererState& renderer_state, ImDrawData* draw_data) + = nullptr; + void (*frame_present)(RendererState& renderer_state) = nullptr; + bool (*screen_capture)(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data) + = nullptr; +}; + +#if IMIV_WITH_VULKAN +const RendererBackendVTable* +renderer_backend_vulkan_vtable(); +#endif + +#if IMIV_WITH_METAL +const RendererBackendVTable* +renderer_backend_metal_vtable(); +#endif + +#if IMIV_WITH_OPENGL +const RendererBackendVTable* +renderer_backend_opengl_vtable(); +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_renderer_metal.mm b/src/imiv/imiv_renderer_metal.mm new file mode 100644 index 0000000000..1243761507 --- /dev/null +++ b/src/imiv/imiv_renderer_metal.mm @@ -0,0 +1,2298 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_renderer_backend.h" + +#include "imiv_loaded_image.h" +#include "imiv_ocio.h" +#include "imiv_platform_glfw.h" +#include "imiv_preview_shader_text.h" +#include "imiv_tiling.h" +#include "imiv_viewer.h" + +#include "imiv_imgui_metal_extras.h" + +#define GLFW_INCLUDE_NONE +#define GLFW_EXPOSE_NATIVE_COCOA +#include +#include + +#import +#import + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Imiv { + +namespace { + + struct RendererTextureBackendState { + __strong id source_texture = nil; + __strong id preview_linear_texture = nil; + __strong id preview_nearest_texture = nil; + ImTextureID preview_linear_tex_id = ImTextureID_Invalid; + ImTextureID preview_nearest_tex_id = ImTextureID_Invalid; + int width = 0; + int height = 0; + int input_channels = 0; + bool preview_dirty = true; + bool preview_params_valid = false; + PreviewControls last_preview_controls = {}; + }; + + struct MetalOcioTextureBinding { + std::string texture_name; + std::string sampler_name; + __strong id texture = nil; + __strong id sampler = nil; + NSUInteger texture_index = 0; + NSUInteger sampler_index = 0; + }; + + struct MetalOcioVectorUniformBinding { + std::string name; + OcioUniformType type = OcioUniformType::Unknown; + std::vector bytes; + NSUInteger buffer_index = 0; + }; + + struct MetalOcioPreviewState { + OcioShaderRuntime* runtime = nullptr; + __strong id library = nil; + __strong id pipeline = nil; + std::vector textures; + std::vector vector_uniforms; + std::string shader_cache_id; + bool ready = false; + }; + + struct RendererBackendState { + GLFWwindow* window = nullptr; + id device = nil; + id command_queue = nil; + CAMetalLayer* layer = nil; + MTLRenderPassDescriptor* render_pass = nil; + id current_drawable = nil; + id current_command_buffer = nil; + id current_encoder = nil; + __strong id preview_library = nil; + __strong id preview_pipeline = nil; + __strong id upload_pipeline = nil; + __strong id linear_sampler = nil; + __strong id nearest_sampler = nil; + MetalOcioPreviewState ocio_preview; + bool imgui_initialized = false; + }; + + struct MetalPreviewUniforms { + float exposure = 0.0f; + float gamma = 1.0f; + float offset = 0.0f; + int color_mode = 0; + int channel = 0; + int input_channels = 0; + int orientation = 1; + int _padding = 0; + }; + + struct MetalUploadUniforms { + uint32_t width = 0; + uint32_t height = 0; + uint32_t dst_y_offset = 0; + uint32_t row_pitch_bytes = 0; + uint32_t pixel_stride_bytes = 0; + uint32_t channel_count = 0; + uint32_t data_type = 0; + }; + + constexpr size_t kDefaultMetalUploadChunkBytes = 64u * 1024u * 1024u; + + NSUInteger align_up(NSUInteger value, NSUInteger alignment) + { + if (alignment == 0) + return value; + const NSUInteger remainder = value % alignment; + if (remainder == 0) + return value; + return value + (alignment - remainder); + } + + bool read_metal_limit_override(const char* name, size_t& out_value) + { + out_value = 0; + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + return false; + char* end = nullptr; + unsigned long long raw = std::strtoull(value, &end, 10); + if (end == value || *end != '\0' || raw == 0 + || raw > static_cast( + std::numeric_limits::max())) { + return false; + } + out_value = static_cast(raw); + return true; + } + + size_t metal_max_upload_chunk_bytes() + { + size_t override_value = 0; + if (read_metal_limit_override( + "IMIV_METAL_MAX_UPLOAD_CHUNK_BYTES_OVERRIDE", override_value)) { + return override_value; + } + return kDefaultMetalUploadChunkBytes; + } + + void update_drawable_size(RendererState& renderer_state) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->window == nullptr || state->layer == nil) + return; + int width = 0; + int height = 0; + platform_glfw_get_framebuffer_size(state->window, width, height); + renderer_state.framebuffer_width = width; + renderer_state.framebuffer_height = height; + state->layer.drawableSize = CGSizeMake(width, height); + } + + bool prepare_source_upload(const LoadedImage& image, + const unsigned char*& upload_ptr, + size_t& upload_bytes, + UploadDataType& upload_type, + size_t& channel_bytes, size_t& row_pitch_bytes, + std::vector& converted_pixels, + std::string& error_message) + { + error_message.clear(); + converted_pixels.clear(); + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0 + || image.pixels.empty()) { + error_message = "invalid source image dimensions"; + return false; + } + + upload_type = image.type; + channel_bytes = image.channel_bytes; + row_pitch_bytes = image.row_pitch_bytes; + upload_ptr = image.pixels.data(); + upload_bytes = image.pixels.size(); + + if (upload_type == UploadDataType::Unknown) { + error_message = "unsupported source pixel type"; + return false; + } + + const size_t channel_count = static_cast(image.nchannels); + if (upload_type == UploadDataType::Double) { + const size_t value_count = image.pixels.size() / sizeof(double); + converted_pixels.resize(value_count * sizeof(float)); + const double* src = reinterpret_cast( + image.pixels.data()); + float* dst = reinterpret_cast(converted_pixels.data()); + for (size_t i = 0; i < value_count; ++i) + dst[i] = static_cast(src[i]); + upload_type = UploadDataType::Float; + channel_bytes = sizeof(float); + row_pitch_bytes = static_cast(image.width) * channel_count + * channel_bytes; + upload_ptr = converted_pixels.data(); + upload_bytes = converted_pixels.size(); + } + + const size_t pixel_stride_bytes = channel_bytes * channel_count; + if (converted_pixels.empty()) { + LoadedImageLayout layout; + if (!describe_loaded_image_layout(image, layout, error_message)) { + if (error_message == "invalid source row pitch") + error_message = "invalid source row pitch"; + return false; + } + row_pitch_bytes = image.row_pitch_bytes; + upload_bytes = layout.required_bytes; + } else { + if (pixel_stride_bytes == 0 || row_pitch_bytes == 0 + || row_pitch_bytes < static_cast(image.width) + * pixel_stride_bytes) { + error_message = "invalid source row pitch"; + return false; + } + const size_t required_bytes = row_pitch_bytes + * static_cast(image.height); + if (upload_bytes < required_bytes) { + error_message + = "source pixel buffer is smaller than declared stride"; + return false; + } + upload_bytes = required_bytes; + } + + if (row_pitch_bytes > std::numeric_limits::max() + || pixel_stride_bytes > std::numeric_limits::max()) { + error_message = "source image stride exceeds Metal upload limits"; + return false; + } + + return true; + } + + void rgb_to_rgba(const float* rgb_values, size_t value_count, + std::vector& rgba_values) + { + rgba_values.clear(); + if (rgb_values == nullptr || value_count == 0) + return; + rgba_values.reserve((value_count / 3u) * 4u); + for (size_t i = 0; i + 2 < value_count; i += 3) { + rgba_values.push_back(rgb_values[i + 0]); + rgba_values.push_back(rgb_values[i + 1]); + rgba_values.push_back(rgb_values[i + 2]); + rgba_values.push_back(1.0f); + } + } + + id create_sampler_state(id device, + OcioInterpolation interpolation) + { + if (device == nil) + return nil; + MTLSamplerDescriptor* descriptor = [[MTLSamplerDescriptor alloc] init]; + if (interpolation == OcioInterpolation::Nearest) { + descriptor.minFilter = MTLSamplerMinMagFilterNearest; + descriptor.magFilter = MTLSamplerMinMagFilterNearest; + } else { + descriptor.minFilter = MTLSamplerMinMagFilterLinear; + descriptor.magFilter = MTLSamplerMinMagFilterLinear; + } + descriptor.sAddressMode = MTLSamplerAddressModeClampToEdge; + descriptor.tAddressMode = MTLSamplerAddressModeClampToEdge; + descriptor.rAddressMode = MTLSamplerAddressModeClampToEdge; + return [device newSamplerStateWithDescriptor:descriptor]; + } + + std::string metal_error_string(NSError* error, const char* fallback_message) + { + if (error != nil && error.localizedDescription != nil) + return std::string(error.localizedDescription.UTF8String); + return std::string(fallback_message); + } + + bool create_shader_library(id device, NSString* source, + MTLCompileOptions* options, + const char* device_error, + const char* compile_error, + id& library, + std::string& error_message) + { + library = nil; + if (device == nil) { + error_message = device_error; + return false; + } + + NSError* error = nil; + library = [device newLibraryWithSource:source + options:options + error:&error]; + if (library == nil) { + error_message = metal_error_string(error, compile_error); + return false; + } + + error_message.clear(); + return true; + } + + MTLCompileOptions* create_ocio_compile_options() + { + MTLCompileOptions* options = [[MTLCompileOptions alloc] init]; + if (@available(macOS 10.13, *)) + [options setLanguageVersion:MTLLanguageVersion2_0]; +#if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) \ + && __MAC_OS_X_VERSION_MAX_ALLOWED >= 150000 + if (@available(macOS 15.0, *)) { + [options setMathMode:MTLMathModeSafe]; + } else { +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wdeprecated-declarations" + [options setFastMathEnabled:NO]; +# pragma clang diagnostic pop + } +#else + [options setFastMathEnabled:NO]; +#endif + return options; + } + + bool create_compute_pipeline_state( + id device, id library, const char* function_name, + const char* function_error, const char* pipeline_error, + id& pipeline, std::string& error_message) + { + pipeline = nil; + if (device == nil) { + error_message = "Metal device is not initialized"; + return false; + } + if (library == nil) { + error_message = function_error; + return false; + } + + NSString* ns_function_name = [NSString + stringWithUTF8String:function_name]; + id function = [library + newFunctionWithName:ns_function_name]; + if (function == nil) { + error_message = function_error; + return false; + } + + NSError* error = nil; + pipeline = [device newComputePipelineStateWithFunction:function + error:&error]; + if (pipeline == nil) { + error_message = metal_error_string(error, pipeline_error); + return false; + } + + error_message.clear(); + return true; + } + + bool create_render_pipeline_state( + id device, id library, const char* vertex_name, + const char* fragment_name, const char* function_error, + const char* pipeline_error, id& pipeline, + std::string& error_message) + { + pipeline = nil; + if (device == nil) { + error_message = "Metal device is not initialized"; + return false; + } + if (library == nil) { + error_message = function_error; + return false; + } + + NSString* ns_vertex_name = [NSString stringWithUTF8String:vertex_name]; + NSString* ns_fragment_name = [NSString + stringWithUTF8String:fragment_name]; + id vertex_function = [library + newFunctionWithName:ns_vertex_name]; + id fragment_function = [library + newFunctionWithName:ns_fragment_name]; + if (vertex_function == nil || fragment_function == nil) { + error_message = function_error; + return false; + } + + MTLRenderPipelineDescriptor* descriptor = + [[MTLRenderPipelineDescriptor alloc] init]; + descriptor.vertexFunction = vertex_function; + descriptor.fragmentFunction = fragment_function; + descriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm; + + NSError* error = nil; + pipeline = [device newRenderPipelineStateWithDescriptor:descriptor + error:&error]; + if (pipeline == nil) { + error_message = metal_error_string(error, pipeline_error); + return false; + } + + error_message.clear(); + return true; + } + + bool begin_offscreen_render_pass(id command_queue, + id target_texture, + const char* command_buffer_error, + const char* encoder_error, + id& command_buffer, + id& encoder, + std::string& error_message) + { + command_buffer = [command_queue commandBuffer]; + if (command_buffer == nil) { + error_message = command_buffer_error; + return false; + } + + MTLRenderPassDescriptor* pass = + [MTLRenderPassDescriptor renderPassDescriptor]; + pass.colorAttachments[0].texture = target_texture; + pass.colorAttachments[0].loadAction = MTLLoadActionDontCare; + pass.colorAttachments[0].storeAction = MTLStoreActionStore; + + encoder = [command_buffer renderCommandEncoderWithDescriptor:pass]; + if (encoder == nil) { + error_message = encoder_error; + return false; + } + + error_message.clear(); + return true; + } + + bool end_offscreen_render_pass(id command_buffer, + id encoder, + const char* completion_error, + std::string& error_message) + { + [encoder endEncoding]; + [command_buffer commit]; + [command_buffer waitUntilCompleted]; + if (command_buffer.status != MTLCommandBufferStatusCompleted) { + error_message = completion_error; + return false; + } + + error_message.clear(); + return true; + } + + bool create_ocio_texture(id device, + const OcioTextureBlueprint& blueprint, + id& texture, + std::string& error_message) + { + texture = nil; + if (device == nil) { + error_message = "Metal OCIO device is not initialized"; + return false; + } + if (blueprint.values.empty()) { + error_message = "missing Metal OCIO LUT values"; + return false; + } + + std::vector adapted_values; + const float* values = nullptr; + MTLTextureDescriptor* descriptor = [[MTLTextureDescriptor alloc] init]; + descriptor.mipmapLevelCount = 1; + + if (blueprint.dimensions == OcioTextureDimensions::Tex3D) { + if (blueprint.width == 0 || blueprint.height == 0 + || blueprint.depth == 0) { + error_message = "invalid Metal OCIO 3D LUT dimensions"; + return false; + } + rgb_to_rgba(blueprint.values.data(), blueprint.values.size(), + adapted_values); + values = adapted_values.data(); + descriptor.textureType = MTLTextureType3D; + descriptor.pixelFormat = MTLPixelFormatRGBA32Float; + descriptor.width = blueprint.width; + descriptor.height = blueprint.height; + descriptor.depth = blueprint.depth; + texture = [device newTextureWithDescriptor:descriptor]; + if (texture == nil) { + error_message = "failed to create Metal OCIO 3D LUT texture"; + return false; + } + [texture + replaceRegion:MTLRegionMake3D(0, 0, 0, blueprint.width, + blueprint.height, blueprint.depth) + mipmapLevel:0 + slice:0 + withBytes:values + bytesPerRow:static_cast(blueprint.width) * 4u + * sizeof(float) + bytesPerImage:static_cast(blueprint.width) + * static_cast(blueprint.height) * 4u + * sizeof(float)]; + error_message.clear(); + return true; + } + + if (blueprint.width == 0 || blueprint.height == 0) { + error_message = "invalid Metal OCIO LUT dimensions"; + return false; + } + + const bool red_channel = blueprint.channel == OcioTextureChannel::Red; + if (red_channel) { + adapted_values = blueprint.values; + } else { + rgb_to_rgba(blueprint.values.data(), blueprint.values.size(), + adapted_values); + } + values = adapted_values.data(); + + descriptor.textureType = blueprint.dimensions + == OcioTextureDimensions::Tex1D + ? MTLTextureType1D + : MTLTextureType2D; + descriptor.pixelFormat = red_channel ? MTLPixelFormatR32Float + : MTLPixelFormatRGBA32Float; + descriptor.width = blueprint.width; + descriptor.height = blueprint.dimensions == OcioTextureDimensions::Tex1D + ? 1u + : blueprint.height; + descriptor.depth = 1; + texture = [device newTextureWithDescriptor:descriptor]; + if (texture == nil) { + error_message = "failed to create Metal OCIO LUT texture"; + return false; + } + [texture replaceRegion:MTLRegionMake3D(0, 0, 0, descriptor.width, + descriptor.height, 1) + mipmapLevel:0 + withBytes:values + bytesPerRow:static_cast(descriptor.width) + * static_cast(red_channel ? 1u : 4u) + * sizeof(float)]; + error_message.clear(); + return true; + } + + void destroy_ocio_preview_resources(MetalOcioPreviewState& state) + { + for (MetalOcioTextureBinding& texture : state.textures) { + texture.texture = nil; + texture.sampler = nil; + } + state.textures.clear(); + state.vector_uniforms.clear(); + state.library = nil; + state.pipeline = nil; + state.shader_cache_id.clear(); + state.ready = false; + } + + void destroy_ocio_preview_program(MetalOcioPreviewState& state) + { + destroy_ocio_preview_resources(state); + destroy_ocio_shader_runtime(state.runtime); + } + + void align_uniform_bytes(std::vector& bytes, + size_t alignment) + { + if (alignment <= 1) + return; + const size_t remainder = bytes.size() % alignment; + if (remainder == 0) + return; + bytes.resize(bytes.size() + (alignment - remainder), 0); + } + + struct MetalOcioShaderSourceParts { + std::ostringstream uniforms_struct; + std::ostringstream uniform_bindings; + std::ostringstream texture_bindings; + std::ostringstream texture_call_params; + std::ostringstream uniform_call_params; + bool has_uniform_struct = false; + bool uniform_need_separator = false; + bool texture_need_separator = false; + NSUInteger vector_buffer_index = 2; + NSUInteger texture_index = 1; + }; + + void append_shader_call_param(std::ostringstream& params, + bool& need_separator, const std::string& text) + { + if (need_separator) + params << ", "; + params << text; + need_separator = true; + } + + void append_texture_binding_source(MetalOcioShaderSourceParts& parts, + const char* texture_decl, + const char* texture_name, + const char* sampler_name) + { + parts.texture_bindings + << ", " << texture_decl << texture_name << " [[texture(" + << parts.texture_index << ")]]\n" + << ", sampler " << sampler_name << " [[sampler(" + << parts.texture_index << ")]]\n"; + append_shader_call_param(parts.texture_call_params, + parts.texture_need_separator, + std::string(texture_name) + ", " + + sampler_name); + ++parts.texture_index; + } + + OcioUniformType metal_ocio_uniform_type(OCIO::UniformDataType type) + { + switch (type) { + case OCIO::UNIFORM_DOUBLE: return OcioUniformType::Double; + case OCIO::UNIFORM_BOOL: return OcioUniformType::Bool; + case OCIO::UNIFORM_FLOAT3: return OcioUniformType::Float3; + case OCIO::UNIFORM_VECTOR_FLOAT: return OcioUniformType::VectorFloat; + case OCIO::UNIFORM_VECTOR_INT: return OcioUniformType::VectorInt; + case OCIO::UNIFORM_UNKNOWN: + default: return OcioUniformType::Unknown; + } + } + + bool build_ocio_scalar_uniform_bytes(OcioShaderRuntime& runtime, + std::vector& bytes, + std::string& error_message) + { + bytes.clear(); + error_message.clear(); + if (runtime.shader_desc == nullptr) + return true; + + const unsigned num_uniforms = runtime.shader_desc->getNumUniforms(); + size_t max_alignment = 4; + for (unsigned idx = 0; idx < num_uniforms; ++idx) { + OCIO::GpuShaderDesc::UniformData data; + runtime.shader_desc->getUniform(idx, data); + switch (data.m_type) { + case OCIO::UNIFORM_DOUBLE: { + align_uniform_bytes(bytes, 4); + const float value = data.m_getDouble + ? static_cast(data.m_getDouble()) + : 0.0f; + const unsigned char* src + = reinterpret_cast(&value); + bytes.insert(bytes.end(), src, src + sizeof(value)); + break; + } + case OCIO::UNIFORM_BOOL: { + align_uniform_bytes(bytes, 4); + const int32_t value = (data.m_getBool && data.m_getBool()) ? 1 + : 0; + const unsigned char* src + = reinterpret_cast(&value); + bytes.insert(bytes.end(), src, src + sizeof(value)); + break; + } + case OCIO::UNIFORM_FLOAT3: { + align_uniform_bytes(bytes, 16); + max_alignment = std::max(max_alignment, size_t(16)); + float packed[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; + if (data.m_getFloat3) { + const OCIO::Float3& value = data.m_getFloat3(); + packed[0] = value[0]; + packed[1] = value[1]; + packed[2] = value[2]; + } + const unsigned char* src + = reinterpret_cast(packed); + bytes.insert(bytes.end(), src, src + sizeof(packed)); + break; + } + case OCIO::UNIFORM_VECTOR_FLOAT: { + align_uniform_bytes(bytes, 4); + const int32_t count = data.m_vectorFloat.m_getSize + ? data.m_vectorFloat.m_getSize() + : 0; + const unsigned char* src + = reinterpret_cast(&count); + bytes.insert(bytes.end(), src, src + sizeof(count)); + break; + } + case OCIO::UNIFORM_VECTOR_INT: { + align_uniform_bytes(bytes, 4); + const int32_t count = data.m_vectorInt.m_getSize + ? data.m_vectorInt.m_getSize() + : 0; + const unsigned char* src + = reinterpret_cast(&count); + bytes.insert(bytes.end(), src, src + sizeof(count)); + break; + } + case OCIO::UNIFORM_UNKNOWN: + default: + error_message = "unsupported Metal OCIO uniform type"; + return false; + } + } + + align_uniform_bytes(bytes, max_alignment); + return true; + } + + bool build_ocio_vector_uniform_bindings( + OcioShaderRuntime& runtime, + std::vector& bindings, + std::string& error_message) + { + bindings.clear(); + error_message.clear(); + if (runtime.shader_desc == nullptr) + return true; + + NSUInteger buffer_index = 2; + const unsigned num_uniforms = runtime.shader_desc->getNumUniforms(); + for (unsigned idx = 0; idx < num_uniforms; ++idx) { + OCIO::GpuShaderDesc::UniformData data; + const char* name = runtime.shader_desc->getUniform(idx, data); + if (data.m_type != OCIO::UNIFORM_VECTOR_FLOAT + && data.m_type != OCIO::UNIFORM_VECTOR_INT) { + continue; + } + + MetalOcioVectorUniformBinding binding; + if (name != nullptr) + binding.name = name; + binding.type = metal_ocio_uniform_type(data.m_type); + binding.buffer_index = buffer_index++; + + if (data.m_type == OCIO::UNIFORM_VECTOR_FLOAT) { + const int count = data.m_vectorFloat.m_getSize + ? data.m_vectorFloat.m_getSize() + : 0; + if (count > 0 && data.m_vectorFloat.m_getVector) { + const float* src = data.m_vectorFloat.m_getVector(); + const unsigned char* begin + = reinterpret_cast(src); + binding.bytes.assign(begin, begin + + static_cast(count) + * sizeof(float)); + } else { + const float dummy = 0.0f; + const unsigned char* begin + = reinterpret_cast(&dummy); + binding.bytes.assign(begin, begin + sizeof(dummy)); + } + } else { + const int count = data.m_vectorInt.m_getSize + ? data.m_vectorInt.m_getSize() + : 0; + if (count > 0 && data.m_vectorInt.m_getVector) { + const int* src = data.m_vectorInt.m_getVector(); + const unsigned char* begin + = reinterpret_cast(src); + binding.bytes.assign(begin, begin + + static_cast(count) + * sizeof(int)); + } else { + const int dummy = 0; + const unsigned char* begin + = reinterpret_cast(&dummy); + binding.bytes.assign(begin, begin + sizeof(dummy)); + } + } + + bindings.push_back(std::move(binding)); + } + + return true; + } + + bool append_ocio_uniform_shader_source(const OcioShaderRuntime& runtime, + MetalOcioShaderSourceParts& parts, + std::string& error_message) + { + const unsigned num_uniforms = runtime.shader_desc->getNumUniforms(); + for (unsigned idx = 0; idx < num_uniforms; ++idx) { + OCIO::GpuShaderDesc::UniformData data; + const char* uniform_name = runtime.shader_desc->getUniform(idx, + data); + if (uniform_name == nullptr || uniform_name[0] == '\0') { + error_message = "Metal OCIO uniform name is missing"; + return false; + } + + switch (data.m_type) { + case OCIO::UNIFORM_DOUBLE: + parts.uniforms_struct << " float " << uniform_name << ";\n"; + append_shader_call_param(parts.uniform_call_params, + parts.uniform_need_separator, + std::string("ocioUniformData.") + + uniform_name); + parts.has_uniform_struct = true; + break; + case OCIO::UNIFORM_BOOL: + parts.uniforms_struct << " int " << uniform_name << ";\n"; + append_shader_call_param(parts.uniform_call_params, + parts.uniform_need_separator, + std::string("(ocioUniformData.") + + uniform_name + " != 0)"); + parts.has_uniform_struct = true; + break; + case OCIO::UNIFORM_FLOAT3: + parts.uniforms_struct << " float4 " << uniform_name << ";\n"; + append_shader_call_param(parts.uniform_call_params, + parts.uniform_need_separator, + std::string("ocioUniformData.") + + uniform_name + ".xyz"); + parts.has_uniform_struct = true; + break; + case OCIO::UNIFORM_VECTOR_FLOAT: { + const std::string count_name = uniform_name + "_count"; + parts.uniforms_struct << " int " << count_name << ";\n"; + parts.uniform_bindings + << ", constant float* " << uniform_name << " [[buffer(" + << parts.vector_buffer_index++ << ")]]\n"; + append_shader_call_param(parts.uniform_call_params, + parts.uniform_need_separator, + std::string(uniform_name) + + ", ocioUniformData." + + count_name); + parts.has_uniform_struct = true; + break; + } + case OCIO::UNIFORM_VECTOR_INT: { + const std::string count_name = uniform_name + "_count"; + parts.uniforms_struct << " int " << count_name << ";\n"; + parts.uniform_bindings + << ", constant int* " << uniform_name << " [[buffer(" + << parts.vector_buffer_index++ << ")]]\n"; + append_shader_call_param(parts.uniform_call_params, + parts.uniform_need_separator, + std::string(uniform_name) + + ", ocioUniformData." + + count_name); + parts.has_uniform_struct = true; + break; + } + case OCIO::UNIFORM_UNKNOWN: + default: + error_message = "unsupported Metal OCIO uniform type"; + return false; + } + } + + return true; + } + + bool append_ocio_texture_shader_source(const OcioShaderRuntime& runtime, + MetalOcioShaderSourceParts& parts, + std::string& error_message) + { + for (unsigned idx = 0; idx < runtime.shader_desc->getNum3DTextures(); + ++idx) { + const char* texture_name = nullptr; + const char* sampler_name = nullptr; + unsigned edge_len = 0; + OCIO::Interpolation interpolation = OCIO::INTERP_DEFAULT; + runtime.shader_desc->get3DTexture(idx, texture_name, sampler_name, + edge_len, interpolation); + if (texture_name == nullptr || sampler_name == nullptr) { + error_message = "Metal OCIO 3D LUT binding is missing"; + return false; + } + + append_texture_binding_source(parts, "texture3d ", + texture_name, sampler_name); + } + + for (unsigned idx = 0; idx < runtime.shader_desc->getNumTextures(); + ++idx) { + const char* texture_name = nullptr; + const char* sampler_name = nullptr; + unsigned width = 0; + unsigned height = 0; + OCIO::GpuShaderDesc::TextureType channel + = OCIO::GpuShaderDesc::TEXTURE_RGB_CHANNEL; + OCIO::GpuShaderCreator::TextureDimensions dimensions + = OCIO::GpuShaderCreator::TextureDimensions::TEXTURE_2D; + OCIO::Interpolation interpolation = OCIO::INTERP_DEFAULT; + runtime.shader_desc->getTexture(idx, texture_name, sampler_name, + width, height, channel, dimensions, + interpolation); + if (texture_name == nullptr || sampler_name == nullptr) { + error_message = "Metal OCIO LUT binding is missing"; + return false; + } + + append_texture_binding_source( + parts, + dimensions + == OCIO::GpuShaderCreator::TextureDimensions::TEXTURE_2D + ? "texture2d " + : "texture1d ", + texture_name, sampler_name); + } + + return true; + } + + std::string + build_ocio_shader_call_params(const MetalOcioShaderSourceParts& parts) + { + std::ostringstream params; + const std::string texture_call_params = parts.texture_call_params.str(); + const std::string uniform_call_params = parts.uniform_call_params.str(); + bool need_separator = false; + if (!texture_call_params.empty()) { + params << texture_call_params; + need_separator = true; + } + if (!uniform_call_params.empty()) { + if (need_separator) + params << ", "; + params << uniform_call_params; + need_separator = true; + } + if (need_separator) + params << ", "; + params << "rgba"; + return params.str(); + } + + bool create_source_texture(id device, int width, int height, + id& texture, + std::string& error_message) + { + texture = nil; + if (device == nil || width <= 0 || height <= 0) { + error_message = "invalid Metal source texture parameters"; + return false; + } + MTLTextureDescriptor* descriptor = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA32Float + width:static_cast(width) + height:static_cast(height) + mipmapped:NO]; + descriptor.usage = MTLTextureUsageShaderRead + | MTLTextureUsageShaderWrite; + descriptor.storageMode = MTLStorageModePrivate; + texture = [device newTextureWithDescriptor:descriptor]; + if (texture == nil) { + error_message = "failed to create Metal source texture"; + return false; + } + error_message.clear(); + return true; + } + + NSString* upload_shader_source() + { + static const char* source = R"metal( +#include +using namespace metal; + +struct UploadUniforms { + uint width; + uint height; + uint dst_y_offset; + uint row_pitch_bytes; + uint pixel_stride_bytes; + uint channel_count; + uint data_type; +}; + +constant uint IMIV_DATA_U8 = 0u; +constant uint IMIV_DATA_U16 = 1u; +constant uint IMIV_DATA_U32 = 2u; +constant uint IMIV_DATA_F16 = 3u; +constant uint IMIV_DATA_F32 = 4u; +constant uint IMIV_DATA_F64 = 5u; + +inline uint read_byte(const device uchar* src_bytes, uint byte_offset) +{ + return uint(src_bytes[byte_offset]); +} + +inline uint read_u16(const device uchar* src_bytes, uint byte_offset) +{ + return read_byte(src_bytes, byte_offset) + | (read_byte(src_bytes, byte_offset + 1u) << 8u); +} + +inline uint read_u32(const device uchar* src_bytes, uint byte_offset) +{ + return read_byte(src_bytes, byte_offset) + | (read_byte(src_bytes, byte_offset + 1u) << 8u) + | (read_byte(src_bytes, byte_offset + 2u) << 16u) + | (read_byte(src_bytes, byte_offset + 3u) << 24u); +} + +inline float decode_channel(const device uchar* src_bytes, + constant UploadUniforms& upload, + uint pixel_offset, uint channel_index) +{ + uint channel_bytes = 1u; + if (upload.data_type == IMIV_DATA_U16 || upload.data_type == IMIV_DATA_F16) + channel_bytes = 2u; + else if (upload.data_type == IMIV_DATA_U32 + || upload.data_type == IMIV_DATA_F32) + channel_bytes = 4u; + else if (upload.data_type == IMIV_DATA_F64) + channel_bytes = 8u; + + const uint byte_offset = pixel_offset + channel_index * channel_bytes; + if (upload.data_type == IMIV_DATA_U8) + return float(read_byte(src_bytes, byte_offset)) * (1.0f / 255.0f); + if (upload.data_type == IMIV_DATA_U16) + return float(read_u16(src_bytes, byte_offset)) * (1.0f / 65535.0f); + if (upload.data_type == IMIV_DATA_U32) + return float(read_u32(src_bytes, byte_offset)) + * (1.0f / 4294967295.0f); + if (upload.data_type == IMIV_DATA_F16) + return float(as_type(ushort(read_u16(src_bytes, byte_offset)))); + if (upload.data_type == IMIV_DATA_F32) + return as_type(read_u32(src_bytes, byte_offset)); + return 0.0f; +} + +inline float4 decode_pixel(const device uchar* src_bytes, + constant UploadUniforms& upload, uint pixel_offset) +{ + if (upload.channel_count == 0u) + return float4(0.0f, 0.0f, 0.0f, 1.0f); + if (upload.channel_count == 1u) { + const float g = decode_channel(src_bytes, upload, pixel_offset, 0u); + return float4(g, g, g, 1.0f); + } + if (upload.channel_count == 2u) { + const float g = decode_channel(src_bytes, upload, pixel_offset, 0u); + const float a = decode_channel(src_bytes, upload, pixel_offset, 1u); + return float4(g, g, g, a); + } + const float r = decode_channel(src_bytes, upload, pixel_offset, 0u); + const float g = decode_channel(src_bytes, upload, pixel_offset, 1u); + const float b = decode_channel(src_bytes, upload, pixel_offset, 2u); + float a = 1.0f; + if (upload.channel_count >= 4u) + a = decode_channel(src_bytes, upload, pixel_offset, 3u); + return float4(r, g, b, a); +} + +kernel void imivUploadToSourceTexture(const device uchar* src_bytes [[buffer(0)]], + constant UploadUniforms& upload [[buffer(1)]], + texture2d dst_texture [[texture(0)]], + uint2 gid [[thread_position_in_grid]]) +{ + if (gid.x >= upload.width || gid.y >= upload.height) + return; + const uint pixel_offset = gid.y * upload.row_pitch_bytes + + gid.x * upload.pixel_stride_bytes; + dst_texture.write(decode_pixel(src_bytes, upload, pixel_offset), + uint2(gid.x, gid.y + upload.dst_y_offset)); +} +)metal"; + return [NSString stringWithUTF8String:source]; + } + + bool create_upload_pipeline(RendererBackendState& state, + std::string& error_message) + { + MTLCompileOptions* options = [[MTLCompileOptions alloc] init]; + id library = nil; + if (!create_shader_library(state.device, upload_shader_source(), + options, "Metal device is not initialized", + "failed to compile Metal upload shader", + library, error_message)) { + return false; + } + return create_compute_pipeline_state( + state.device, library, "imivUploadToSourceTexture", + "failed to create Metal upload shader function", + "failed to create Metal upload pipeline", state.upload_pipeline, + error_message); + } + + bool upload_source_texture(RendererBackendState& state, + const LoadedImage& image, id texture, + std::string& error_message) + { + if (state.device == nil || state.command_queue == nil || texture == nil + || state.upload_pipeline == nil) { + error_message = "Metal upload pipeline is not initialized"; + return false; + } + + std::vector converted_pixels; + const unsigned char* upload_ptr = nullptr; + size_t upload_bytes = 0; + UploadDataType upload_type = UploadDataType::Unknown; + size_t channel_bytes = 0; + size_t row_pitch_bytes = 0; + if (!prepare_source_upload(image, upload_ptr, upload_bytes, upload_type, + channel_bytes, row_pitch_bytes, + converted_pixels, error_message)) { + return false; + } + + const size_t pixel_stride_bytes + = channel_bytes * static_cast(image.nchannels); + RowStripeUploadPlan stripe_plan; + if (!build_row_stripe_upload_plan(row_pitch_bytes, pixel_stride_bytes, + image.height, + metal_max_upload_chunk_bytes(), 1, + stripe_plan, error_message)) { + return false; + } + + id command_buffer = + [state.command_queue commandBuffer]; + if (command_buffer == nil) { + error_message = "failed to create Metal upload command buffer"; + return false; + } + + id encoder = + [command_buffer computeCommandEncoder]; + if (encoder == nil) { + error_message = "failed to create Metal upload command encoder"; + return false; + } + + NSMutableArray>* stripe_buffers = [NSMutableArray array]; + [encoder setComputePipelineState:state.upload_pipeline]; + [encoder setTexture:texture atIndex:0]; + + const MTLSize threads_per_group = MTLSizeMake(16, 16, 1); + for (uint32_t stripe_index = 0; stripe_index < stripe_plan.stripe_count; + ++stripe_index) { + const uint32_t stripe_y = stripe_index * stripe_plan.stripe_rows; + const uint32_t stripe_height + = std::min(stripe_plan.stripe_rows, + static_cast(image.height) - stripe_y); + const size_t stripe_offset = static_cast(stripe_y) + * row_pitch_bytes; + const size_t stripe_bytes = static_cast(stripe_height) + * row_pitch_bytes; + + id source_buffer = [state.device + newBufferWithBytes:upload_ptr + stripe_offset + length:static_cast(stripe_bytes) + options:MTLResourceStorageModeShared]; + if (source_buffer == nil) { + error_message + = stripe_plan.uses_multiple_stripes + ? "failed to create Metal striped upload buffer" + : "failed to create Metal source upload buffer"; + [encoder endEncoding]; + return false; + } + [stripe_buffers addObject:source_buffer]; + + MetalUploadUniforms uniforms = {}; + uniforms.width = static_cast(image.width); + uniforms.height = stripe_height; + uniforms.dst_y_offset = stripe_y; + uniforms.row_pitch_bytes = static_cast(row_pitch_bytes); + uniforms.pixel_stride_bytes = static_cast( + pixel_stride_bytes); + uniforms.channel_count = static_cast( + std::max(0, image.nchannels)); + uniforms.data_type = static_cast(upload_type); + + [encoder setBuffer:source_buffer offset:0 atIndex:0]; + [encoder setBytes:&uniforms length:sizeof(uniforms) atIndex:1]; + + const MTLSize threadgroups + = MTLSizeMake((static_cast(image.width) + + threads_per_group.width - 1) + / threads_per_group.width, + (static_cast(stripe_height) + + threads_per_group.height - 1) + / threads_per_group.height, + 1); + [encoder dispatchThreadgroups:threadgroups + threadsPerThreadgroup:threads_per_group]; + } + [encoder endEncoding]; + [command_buffer commit]; + [command_buffer waitUntilCompleted]; + if (command_buffer.status != MTLCommandBufferStatusCompleted) { + error_message = "Metal source upload compute dispatch failed"; + return false; + } + + error_message.clear(); + return true; + } + + bool create_preview_texture(id device, int width, int height, + id& texture, + std::string& error_message) + { + texture = nil; + if (device == nil || width <= 0 || height <= 0) { + error_message = "invalid Metal preview texture parameters"; + return false; + } + MTLTextureDescriptor* descriptor = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm + width:static_cast(width) + height:static_cast(height) + mipmapped:NO]; + descriptor.usage = MTLTextureUsageShaderRead + | MTLTextureUsageRenderTarget; + texture = [device newTextureWithDescriptor:descriptor]; + if (texture == nil) { + error_message = "failed to create Metal preview texture"; + return false; + } + error_message.clear(); + return true; + } + + NSString* preview_shader_source() + { + std::string source = metal_preview_shader_preamble( + k_metal_basic_preview_uniform_fields); + source += metal_fullscreen_triangle_vertex_source("imivPreviewVertex"); + source += k_metal_preview_common_shader_functions; + source += R"metal( + +fragment float4 imivPreviewFragment(VertexOut in [[stage_in]], + texture2d source_texture [[texture(0)]], + sampler source_sampler [[sampler(0)]], + constant PreviewUniforms& uniforms [[buffer(0)]]) +{ + // Metal's texture sampling origin differs from the existing preview UV + // convention here only on the vertical axis. Normalize Y before applying + // the shared orientation transform. + float2 display_uv = float2(in.uv.x, 1.0 - in.uv.y); + float2 src_uv = display_to_source_uv(display_uv, uniforms.orientation); + float4 rgba = source_texture.sample(source_sampler, src_uv); + rgba.r += uniforms.offset; + rgba.g += uniforms.offset; + rgba.b += uniforms.offset; + + if (uniforms.color_mode == 1) { + rgba.a = 1.0; + } else if (uniforms.color_mode == 2) { + float value = selected_channel(rgba, uniforms.channel); + rgba = float4(value, value, value, 1.0); + } else if (uniforms.color_mode == 3) { + float value = dot(rgba.rgb, float3(0.2126, 0.7152, 0.0722)); + rgba = float4(value, value, value, 1.0); + } else if (uniforms.color_mode == 4) { + float value = selected_channel(rgba, uniforms.channel); + rgba = float4(heatmap(value), 1.0); + } + + if (uniforms.channel > 0 && uniforms.color_mode != 2 + && uniforms.color_mode != 4) { + float value = selected_channel(rgba, uniforms.channel); + rgba = float4(value, value, value, 1.0); + } + + if (uniforms.input_channels == 1 && uniforms.color_mode <= 1) { + rgba.g = rgba.r; + rgba.b = rgba.r; + rgba.a = 1.0; + } else if (uniforms.input_channels == 2 && uniforms.color_mode == 0) { + rgba.g = rgba.r; + rgba.b = rgba.r; + } else if (uniforms.input_channels == 2 && uniforms.color_mode == 1) { + rgba.g = rgba.r; + rgba.b = rgba.r; + rgba.a = 1.0; + } + + float exposure_scale = exp2(uniforms.exposure); + float inv_gamma = 1.0 / max(uniforms.gamma, 0.01f); + rgba.rgb = pow(max(rgba.rgb * exposure_scale, float3(0.0)), float3(inv_gamma)); + rgba.rgb = clamp(rgba.rgb, 0.0, 1.0); + rgba.a = clamp(rgba.a, 0.0, 1.0); + return rgba; +} +)metal"; + return [NSString stringWithUTF8String:source.c_str()]; + } + + bool create_preview_pipeline(RendererBackendState& state, + std::string& error_message) + { + MTLCompileOptions* options = [[MTLCompileOptions alloc] init]; + if (!create_shader_library(state.device, preview_shader_source(), + options, "Metal device is not initialized", + "failed to compile Metal preview shader", + state.preview_library, error_message)) { + return false; + } + if (!create_render_pipeline_state( + state.device, state.preview_library, "imivPreviewVertex", + "imivPreviewFragment", + "failed to create Metal preview shader functions", + "failed to create Metal preview pipeline", + state.preview_pipeline, error_message)) { + return false; + } + state.linear_sampler = create_sampler_state(state.device, + OcioInterpolation::Linear); + state.nearest_sampler + = create_sampler_state(state.device, OcioInterpolation::Nearest); + + if (state.linear_sampler == nil || state.nearest_sampler == nil) { + error_message = "failed to create Metal preview samplers"; + return false; + } + + error_message.clear(); + return true; + } + + bool build_ocio_preview_shader_source(const OcioShaderRuntime& runtime, + std::string& shader_source, + std::string& error_message) + { + shader_source.clear(); + error_message.clear(); + if (runtime.shader_desc == nullptr || !runtime.blueprint.enabled + || runtime.blueprint.shader_text.empty()) { + error_message = "Metal OCIO shader blueprint is invalid"; + return false; + } + + MetalOcioShaderSourceParts parts; + if (!append_ocio_uniform_shader_source(runtime, parts, error_message)) + return false; + if (!append_ocio_texture_shader_source(runtime, parts, error_message)) + return false; + + const std::string call_params = build_ocio_shader_call_params(parts); + + std::ostringstream source; + source << metal_preview_shader_preamble( + k_metal_ocio_preview_uniform_fields); + + if (parts.has_uniform_struct) { + source << "\nstruct OcioUniformData {\n" + << parts.uniforms_struct.str() << "};\n"; + } + + source << metal_fullscreen_triangle_vertex_source( + "imivOcioPreviewVertex"); + source << k_metal_preview_common_shader_functions; + + source << runtime.blueprint.shader_text << "\n"; + source << "fragment float4 imivOcioPreviewFragment(\n" + << " VertexOut in [[stage_in]],\n" + << " texture2d source_texture [[texture(0)]],\n" + << " sampler source_sampler [[sampler(0)]],\n" + << " constant PreviewUniforms& preview [[buffer(0)]]\n"; + if (parts.has_uniform_struct) { + source + << ", constant OcioUniformData& ocioUniformData [[buffer(1)]]\n"; + } + source << parts.uniform_bindings.str(); + source << parts.texture_bindings.str(); + source + << ")\n{\n" + << " float2 display_uv = float2(in.uv.x, 1.0 - in.uv.y);\n" + << " float2 src_uv = display_to_source_uv(display_uv, preview.orientation);\n" + << " float4 rgba = source_texture.sample(source_sampler, src_uv);\n" + << " rgba.rgb += float3(preview.offset);\n" + << " if (preview.color_mode == 1) {\n" + << " rgba.a = 1.0;\n" + << " } else if (preview.color_mode == 2) {\n" + << " float value = selected_channel(rgba, preview.channel);\n" + << " rgba = float4(value, value, value, 1.0);\n" + << " } else if (preview.color_mode == 3) {\n" + << " float value = dot(rgba.rgb, float3(0.2126, 0.7152, 0.0722));\n" + << " rgba = float4(value, value, value, 1.0);\n" + << " } else if (preview.color_mode == 4) {\n" + << " float value = selected_channel(rgba, preview.channel);\n" + << " rgba = float4(heatmap(value), 1.0);\n" + << " }\n" + << " if (preview.channel > 0 && preview.color_mode != 2 && preview.color_mode != 4) {\n" + << " float value = selected_channel(rgba, preview.channel);\n" + << " rgba = float4(value, value, value, 1.0);\n" + << " }\n" + << " if (preview.input_channels == 1 && preview.color_mode <= 1) {\n" + << " rgba = float4(rgba.rrr, 1.0);\n" + << " } else if (preview.input_channels == 2 && preview.color_mode == 0) {\n" + << " rgba = float4(rgba.rrr, rgba.a);\n" + << " } else if (preview.input_channels == 2 && preview.color_mode == 1) {\n" + << " rgba = float4(rgba.rrr, 1.0);\n" + << " }\n" + << " return " << runtime.blueprint.function_name << "(" + << call_params << ");\n" + << "}\n"; + + shader_source = source.str(); + return true; + } + + bool upload_ocio_texture(id device, + const OcioTextureBlueprint& blueprint, + NSUInteger texture_index, + MetalOcioTextureBinding& binding, + std::string& error_message) + { + binding = {}; + binding.texture_name = blueprint.texture_name; + binding.sampler_name = blueprint.sampler_name; + binding.texture_index = texture_index; + binding.sampler_index = texture_index; + if (!create_ocio_texture(device, blueprint, binding.texture, + error_message)) + return false; + binding.sampler = create_sampler_state(device, blueprint.interpolation); + if (binding.sampler == nil) { + binding.texture = nil; + error_message = "failed to create Metal OCIO sampler state"; + return false; + } + return true; + } + + bool upload_ocio_textures_for_dimension( + id device, const std::vector& textures, + OcioTextureDimensions dimensions_filter, NSUInteger& texture_index, + std::vector& bindings, + std::string& error_message) + { + for (const OcioTextureBlueprint& texture : textures) { + if (texture.dimensions != dimensions_filter) + continue; + + MetalOcioTextureBinding binding; + if (!upload_ocio_texture(device, texture, texture_index, binding, + error_message)) { + return false; + } + + bindings.push_back(std::move(binding)); + ++texture_index; + } + + return true; + } + + bool upload_ocio_non_3d_textures( + id device, const std::vector& textures, + NSUInteger& texture_index, + std::vector& bindings, + std::string& error_message) + { + for (const OcioTextureBlueprint& texture : textures) { + if (texture.dimensions == OcioTextureDimensions::Tex3D) + continue; + + MetalOcioTextureBinding binding; + if (!upload_ocio_texture(device, texture, texture_index, binding, + error_message)) { + return false; + } + + bindings.push_back(std::move(binding)); + ++texture_index; + } + + return true; + } + + void bind_ocio_fragment_resources( + id encoder, + const MetalPreviewUniforms& preview_uniforms, + const std::vector& scalar_uniform_bytes, + const std::vector& vector_uniforms, + const std::vector& textures) + { + [encoder setFragmentBytes:&preview_uniforms + length:sizeof(preview_uniforms) + atIndex:0]; + if (!scalar_uniform_bytes.empty()) { + [encoder setFragmentBytes:scalar_uniform_bytes.data() + length:scalar_uniform_bytes.size() + atIndex:1]; + } + + for (const MetalOcioVectorUniformBinding& binding : vector_uniforms) { + [encoder setFragmentBytes:binding.bytes.data() + length:binding.bytes.size() + atIndex:binding.buffer_index]; + } + for (const MetalOcioTextureBinding& binding : textures) { + [encoder setFragmentTexture:binding.texture + atIndex:binding.texture_index]; + [encoder setFragmentSamplerState:binding.sampler + atIndex:binding.sampler_index]; + } + } + + bool ensure_ocio_preview_program(RendererBackendState& state, + const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message) + { + if (!ensure_ocio_shader_runtime_metal(ui_state, image, + state.ocio_preview.runtime, + error_message)) { + return false; + } + if (state.ocio_preview.runtime == nullptr + || state.ocio_preview.runtime->shader_desc == nullptr) { + error_message = "Metal OCIO runtime is not initialized"; + return false; + } + + const OcioShaderBlueprint& blueprint + = state.ocio_preview.runtime->blueprint; + if (state.ocio_preview.ready + && state.ocio_preview.shader_cache_id + == blueprint.shader_cache_id) { + return true; + } + + destroy_ocio_preview_resources(state.ocio_preview); + + std::string shader_source; + if (!build_ocio_preview_shader_source(*state.ocio_preview.runtime, + shader_source, error_message)) { + return false; + } + + MTLCompileOptions* options = create_ocio_compile_options(); + NSString* shader_source_ns = [NSString + stringWithUTF8String:shader_source.c_str()]; + if (!create_shader_library(state.device, shader_source_ns, options, + "Metal device is not initialized", + "failed to compile Metal OCIO shader", + state.ocio_preview.library, error_message)) { + return false; + } + if (!create_render_pipeline_state( + state.device, state.ocio_preview.library, + "imivOcioPreviewVertex", "imivOcioPreviewFragment", + "failed to create Metal OCIO shader functions", + "failed to create Metal OCIO pipeline", + state.ocio_preview.pipeline, error_message)) { + return false; + } + + state.ocio_preview.textures.clear(); + NSUInteger texture_index = 1; + if (!upload_ocio_textures_for_dimension( + state.device, blueprint.textures, OcioTextureDimensions::Tex3D, + texture_index, state.ocio_preview.textures, error_message) + || !upload_ocio_non_3d_textures(state.device, blueprint.textures, + texture_index, + state.ocio_preview.textures, + error_message)) { + destroy_ocio_preview_resources(state.ocio_preview); + return false; + } + + if (!build_ocio_vector_uniform_bindings( + *state.ocio_preview.runtime, state.ocio_preview.vector_uniforms, + error_message)) { + destroy_ocio_preview_resources(state.ocio_preview); + return false; + } + + state.ocio_preview.shader_cache_id = blueprint.shader_cache_id; + state.ocio_preview.ready = true; + error_message.clear(); + return true; + } + + bool render_ocio_preview_texture(RendererBackendState& state, + RendererTextureBackendState& texture_state, + id target_texture, + id source_sampler, + const PreviewControls& controls, + std::string& error_message) + { + if (state.command_queue == nil || state.ocio_preview.pipeline == nil + || source_sampler == nil || texture_state.source_texture == nil + || target_texture == nil || state.ocio_preview.runtime == nullptr) { + error_message = "Metal OCIO preview state is not initialized"; + return false; + } + + if (state.ocio_preview.runtime->exposure_property) { + state.ocio_preview.runtime->exposure_property->setValue( + static_cast(controls.exposure)); + } + if (state.ocio_preview.runtime->gamma_property) { + const double gamma + = 1.0 / std::max(1.0e-6, static_cast(controls.gamma)); + state.ocio_preview.runtime->gamma_property->setValue(gamma); + } + + MetalPreviewUniforms preview_uniforms; + preview_uniforms.offset = controls.offset; + preview_uniforms.color_mode = controls.color_mode; + preview_uniforms.channel = controls.channel; + preview_uniforms.input_channels = texture_state.input_channels; + preview_uniforms.orientation = controls.orientation; + + std::vector scalar_uniform_bytes; + if (!build_ocio_scalar_uniform_bytes(*state.ocio_preview.runtime, + scalar_uniform_bytes, + error_message)) { + return false; + } + if (!build_ocio_vector_uniform_bindings( + *state.ocio_preview.runtime, state.ocio_preview.vector_uniforms, + error_message)) { + return false; + } + + id command_buffer = nil; + id encoder = nil; + if (!begin_offscreen_render_pass( + state.command_queue, target_texture, + "failed to create Metal OCIO command buffer", + "failed to create Metal OCIO encoder", command_buffer, encoder, + error_message)) { + return false; + } + + [encoder setRenderPipelineState:state.ocio_preview.pipeline]; + [encoder setFragmentTexture:texture_state.source_texture atIndex:0]; + [encoder setFragmentSamplerState:source_sampler atIndex:0]; + bind_ocio_fragment_resources(encoder, preview_uniforms, + scalar_uniform_bytes, + state.ocio_preview.vector_uniforms, + state.ocio_preview.textures); + [encoder drawPrimitives:MTLPrimitiveTypeTriangle + vertexStart:0 + vertexCount:3]; + return end_offscreen_render_pass(command_buffer, encoder, + "Metal OCIO preview render failed", + error_message); + } + + bool render_preview_texture(RendererBackendState& state, + RendererTextureBackendState& texture_state, + id target_texture, + id sampler_state, + const PreviewControls& controls, + std::string& error_message) + { + if (state.command_queue == nil || state.preview_pipeline == nil + || sampler_state == nil || texture_state.source_texture == nil + || target_texture == nil) { + error_message = "Metal preview pipeline state is not initialized"; + return false; + } + + MetalPreviewUniforms uniforms; + uniforms.exposure = controls.exposure; + uniforms.gamma = std::max(controls.gamma, 0.01f); + uniforms.offset = controls.offset; + uniforms.color_mode = controls.color_mode; + uniforms.channel = controls.channel; + uniforms.input_channels = texture_state.input_channels; + uniforms.orientation = controls.orientation; + + id command_buffer = nil; + id encoder = nil; + if (!begin_offscreen_render_pass( + state.command_queue, target_texture, + "failed to create Metal preview command buffer", + "failed to create Metal preview encoder", command_buffer, + encoder, error_message)) { + return false; + } + + [encoder setRenderPipelineState:state.preview_pipeline]; + [encoder setFragmentTexture:texture_state.source_texture atIndex:0]; + [encoder setFragmentSamplerState:sampler_state atIndex:0]; + [encoder setFragmentBytes:&uniforms length:sizeof(uniforms) atIndex:0]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangle + vertexStart:0 + vertexCount:3]; + return end_offscreen_render_pass(command_buffer, encoder, + "Metal preview render failed", + error_message); + } + + bool metal_get_viewer_texture_refs(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ImTextureRef& main_texture_ref, + bool& has_main_texture, + ImTextureRef& closeup_texture_ref, + bool& has_closeup_texture) + { + const RendererTextureBackendState* state + = texture_backend_state( + viewer.texture); + if (state == nullptr || !viewer.texture.preview_initialized) + return false; + + ImTextureID main_texture_id = ui_state.linear_interpolation != 0 + ? state->preview_linear_tex_id + : state->preview_nearest_tex_id; + if (main_texture_id == ImTextureID_Invalid) + main_texture_id = state->preview_linear_tex_id; + if (main_texture_id != ImTextureID_Invalid) { + main_texture_ref = ImTextureRef(main_texture_id); + has_main_texture = true; + } + + ImTextureID closeup_texture_id = state->preview_nearest_tex_id; + if (closeup_texture_id == ImTextureID_Invalid) + closeup_texture_id = state->preview_linear_tex_id; + if (closeup_texture_id != ImTextureID_Invalid) { + closeup_texture_ref = ImTextureRef(closeup_texture_id); + has_closeup_texture = true; + } + return has_main_texture || has_closeup_texture; + } + + bool metal_create_texture(RendererState& renderer_state, + const LoadedImage& image, + RendererTexture& texture, + std::string& error_message) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->device == nil) { + error_message = "Metal window/device is not initialized"; + return false; + } + + auto* texture_state = new RendererTextureBackendState(); + if (texture_state == nullptr) { + error_message = "failed to allocate Metal texture state"; + return false; + } + + if (!create_source_texture(state->device, image.width, image.height, + texture_state->source_texture, error_message) + || !upload_source_texture(*state, image, + texture_state->source_texture, + error_message) + || !create_preview_texture(state->device, image.width, image.height, + texture_state->preview_linear_texture, + error_message) + || !create_preview_texture(state->device, image.width, image.height, + texture_state->preview_nearest_texture, + error_message)) { + texture_state->source_texture = nil; + texture_state->preview_linear_texture = nil; + texture_state->preview_nearest_texture = nil; + delete texture_state; + return false; + } + + texture_state->preview_linear_tex_id + = ImGui_ImplMetal_CreateUserTextureID( + texture_state->preview_linear_texture, state->linear_sampler); + texture_state->preview_nearest_tex_id + = ImGui_ImplMetal_CreateUserTextureID( + texture_state->preview_nearest_texture, state->nearest_sampler); + if (texture_state->preview_linear_tex_id == ImTextureID_Invalid + || texture_state->preview_nearest_tex_id == ImTextureID_Invalid) { + if (texture_state->preview_linear_tex_id != ImTextureID_Invalid) + ImGui_ImplMetal_DestroyUserTextureID( + texture_state->preview_linear_tex_id); + if (texture_state->preview_nearest_tex_id != ImTextureID_Invalid) + ImGui_ImplMetal_DestroyUserTextureID( + texture_state->preview_nearest_tex_id); + texture_state->preview_linear_tex_id = ImTextureID_Invalid; + texture_state->preview_nearest_tex_id = ImTextureID_Invalid; + texture_state->source_texture = nil; + texture_state->preview_linear_texture = nil; + texture_state->preview_nearest_texture = nil; + error_message = "failed to create Metal ImGui texture bindings"; + delete texture_state; + return false; + } + + texture_state->width = image.width; + texture_state->height = image.height; + texture_state->input_channels = image.nchannels; + texture_state->preview_dirty = true; + + texture.backend = reinterpret_cast<::Imiv::RendererTextureBackendState*>( + texture_state); + texture.preview_initialized = false; + error_message.clear(); + return true; + } + + void metal_destroy_texture(RendererState& renderer_state, + RendererTexture& texture) + { + (void)renderer_state; + RendererTextureBackendState* state + = texture_backend_state(texture); + if (state == nullptr) { + texture.preview_initialized = false; + return; + } + if (state->preview_linear_tex_id != ImTextureID_Invalid) { + ImGui_ImplMetal_DestroyUserTextureID(state->preview_linear_tex_id); + state->preview_linear_tex_id = ImTextureID_Invalid; + } + if (state->preview_nearest_tex_id != ImTextureID_Invalid) { + ImGui_ImplMetal_DestroyUserTextureID(state->preview_nearest_tex_id); + state->preview_nearest_tex_id = ImTextureID_Invalid; + } + state->source_texture = nil; + state->preview_linear_texture = nil; + state->preview_nearest_texture = nil; + delete state; + texture.backend = nullptr; + texture.preview_initialized = false; + } + + bool metal_update_preview_texture(RendererState& renderer_state, + RendererTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) + { + RendererBackendState* renderer_backend + = backend_state(renderer_state); + RendererTextureBackendState* texture_state + = texture_backend_state(texture); + if (renderer_backend == nullptr || texture_state == nullptr) { + error_message = "Metal preview state is not initialized"; + return false; + } + + PreviewControls effective_controls = controls; + if (!texture_state->preview_dirty && texture_state->preview_params_valid + && preview_controls_equal(texture_state->last_preview_controls, + effective_controls) + && effective_controls.use_ocio == 0) { + texture.preview_initialized = true; + error_message.clear(); + return true; + } + + bool used_ocio = false; + if (effective_controls.use_ocio != 0) { + std::string ocio_error; + if (ensure_ocio_preview_program(*renderer_backend, ui_state, image, + ocio_error) + && render_ocio_preview_texture( + *renderer_backend, *texture_state, + texture_state->preview_linear_texture, + renderer_backend->linear_sampler, effective_controls, + ocio_error) + && render_ocio_preview_texture( + *renderer_backend, *texture_state, + texture_state->preview_nearest_texture, + renderer_backend->nearest_sampler, effective_controls, + ocio_error)) { + used_ocio = true; + } else { + if (!ocio_error.empty()) { + std::cerr << "imiv: Metal OCIO fallback: " << ocio_error + << "\n"; + } + effective_controls.use_ocio = 0; + } + } + + if (!used_ocio + && (!render_preview_texture(*renderer_backend, *texture_state, + texture_state->preview_linear_texture, + renderer_backend->linear_sampler, + effective_controls, error_message) + || !render_preview_texture( + *renderer_backend, *texture_state, + texture_state->preview_nearest_texture, + renderer_backend->nearest_sampler, effective_controls, + error_message))) { + texture.preview_initialized = false; + return false; + } + + texture_state->preview_dirty = false; + texture_state->preview_params_valid = true; + texture_state->last_preview_controls = effective_controls; + texture.preview_initialized = true; + error_message.clear(); + return true; + } + + bool metal_setup_instance(RendererState& renderer_state, + ImVector& instance_extensions, + std::string& error_message) + { + (void)instance_extensions; + if (!ensure_default_backend_state( + renderer_state)) { + error_message = "failed to allocate Metal renderer state"; + return false; + } + error_message.clear(); + return true; + } + + bool metal_create_surface(RendererState& renderer_state, GLFWwindow* window, + std::string& error_message) + { + if (!ensure_default_backend_state( + renderer_state)) { + error_message = "failed to allocate Metal renderer state"; + return false; + } + backend_state(renderer_state)->window = window; + error_message.clear(); + return true; + } + + bool metal_setup_device(RendererState& renderer_state, + std::string& error_message) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr) { + error_message = "Metal renderer state is not initialized"; + return false; + } + state->device = MTLCreateSystemDefaultDevice(); + if (state->device == nil) { + error_message = "MTLCreateSystemDefaultDevice failed"; + return false; + } + state->command_queue = [state->device newCommandQueue]; + if (state->command_queue == nil) { + error_message = "failed to create Metal command queue"; + return false; + } + if (!create_preview_pipeline(*state, error_message) + || !create_upload_pipeline(*state, error_message)) + return false; + error_message.clear(); + return true; + } + + bool metal_setup_window(RendererState& renderer_state, int width, + int height, std::string& error_message) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->window == nullptr + || state->device == nil) { + error_message = "Metal window/device is not initialized"; + return false; + } + NSWindow* ns_window = glfwGetCocoaWindow(state->window); + if (ns_window == nil) { + error_message = "failed to get Cocoa window from GLFW"; + return false; + } + state->layer = [CAMetalLayer layer]; + state->layer.device = state->device; + state->layer.pixelFormat = MTLPixelFormatBGRA8Unorm; + state->layer.framebufferOnly = NO; + ns_window.contentView.layer = state->layer; + ns_window.contentView.wantsLayer = YES; + state->render_pass = [MTLRenderPassDescriptor new]; + renderer_set_framebuffer_size(renderer_state, width, height); + update_drawable_size(renderer_state); + error_message.clear(); + return true; + } + + void metal_cleanup_window(RendererState& renderer_state) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr) + return; + state->current_drawable = nil; + state->current_command_buffer = nil; + state->current_encoder = nil; + } + + void metal_cleanup(RendererState& renderer_state) + { + if (RendererBackendState* state = backend_state( + renderer_state)) { + destroy_ocio_preview_program(state->ocio_preview); + delete state; + } + renderer_state.backend = nullptr; + } + + bool metal_wait_idle(RendererState& renderer_state, + std::string& error_message) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state != nullptr && state->current_command_buffer != nil) + [state->current_command_buffer waitUntilCompleted]; + error_message.clear(); + return true; + } + + bool metal_imgui_init(RendererState& renderer_state, + std::string& error_message) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->device == nil) { + error_message = "Metal device is not initialized"; + return false; + } + ImGui_ImplMetal_Init(state->device); + state->imgui_initialized = true; + error_message.clear(); + return true; + } + + void metal_imgui_new_frame(RendererState& renderer_state) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->layer == nil + || state->render_pass == nil) + return; + update_drawable_size(renderer_state); + state->current_drawable = [state->layer nextDrawable]; + if (state->current_drawable == nil) + return; + state->render_pass.colorAttachments[0].texture + = state->current_drawable.texture; + state->render_pass.colorAttachments[0].loadAction = MTLLoadActionClear; + state->render_pass.colorAttachments[0].storeAction = MTLStoreActionStore; + state->render_pass.colorAttachments[0].clearColor = MTLClearColorMake( + renderer_state.clear_color[0] * renderer_state.clear_color[3], + renderer_state.clear_color[1] * renderer_state.clear_color[3], + renderer_state.clear_color[2] * renderer_state.clear_color[3], + renderer_state.clear_color[3]); + ImGui_ImplMetal_NewFrame(state->render_pass); + } + + void metal_resize_main_window(RendererState& renderer_state, int width, + int height) + { + renderer_set_framebuffer_size(renderer_state, width, height); + update_drawable_size(renderer_state); + } + + void metal_frame_render(RendererState& renderer_state, + ImDrawData* draw_data) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->current_drawable == nil + || state->command_queue == nil || state->render_pass == nil) { + return; + } + state->current_command_buffer = [state->command_queue commandBuffer]; + state->current_encoder = [state->current_command_buffer + renderCommandEncoderWithDescriptor:state->render_pass]; + ImGui_ImplMetal_RenderDrawData(draw_data, state->current_command_buffer, + state->current_encoder); + } + + void metal_frame_present(RendererState& renderer_state) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->current_command_buffer == nil + || state->current_drawable == nil) { + return; + } + if (state->current_encoder != nil) + [state->current_encoder endEncoding]; + [state->current_command_buffer presentDrawable:state->current_drawable]; + [state->current_command_buffer commit]; + state->current_encoder = nil; + state->current_command_buffer = nil; + state->current_drawable = nil; + } + + bool metal_screen_capture(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data) + { + (void)viewport_id; + RendererState* renderer_state = reinterpret_cast( + user_data); + if (renderer_state == nullptr || pixels == nullptr || w <= 0 || h <= 0) + return false; + + RendererBackendState* state = backend_state( + *renderer_state); + if (state == nullptr || state->current_drawable == nil + || state->current_command_buffer == nil + || state->current_encoder == nil) { + return false; + } + + id texture = state->current_drawable.texture; + if (texture == nil) + return false; + + const int texture_width = static_cast(texture.width); + const int texture_height = static_cast(texture.height); + int window_width = 0; + int window_height = 0; + if (state->window != nullptr) + glfwGetWindowSize(state->window, &window_width, &window_height); + + const double scale_x = (window_width > 0) + ? static_cast(texture_width) + / static_cast(window_width) + : 1.0; + const double scale_y = (window_height > 0) + ? static_cast(texture_height) + / static_cast(window_height) + : 1.0; + const bool logical_rect = (window_width > 0 && window_height > 0 + && w > 0 && h > 0 && w <= window_width + && h <= window_height + && (std::abs(scale_x - 1.0) > 1.0e-3 + || std::abs(scale_y - 1.0) > 1.0e-3)); + + const int src_x = logical_rect + ? static_cast(std::lround(x * scale_x)) + : x; + const int src_y = logical_rect + ? static_cast(std::lround(y * scale_y)) + : y; + const int src_w + = logical_rect ? std::max(1, static_cast(std::lround( + static_cast(w) * scale_x))) + : w; + const int src_h + = logical_rect ? std::max(1, static_cast(std::lround( + static_cast(h) * scale_y))) + : h; + if (src_x < 0 || src_y < 0 || src_x + src_w > texture_width + || src_y + src_h > texture_height) { + return false; + } + + [state->current_encoder endEncoding]; + state->current_encoder = nil; + + const NSUInteger bytes_per_pixel = 4; + const NSUInteger row_bytes + = align_up(static_cast(src_w) * bytes_per_pixel, 256); + const NSUInteger buffer_size = row_bytes + * static_cast(src_h); + + id readback_buffer = [state->device + newBufferWithLength:buffer_size + options:MTLResourceStorageModeShared]; + if (readback_buffer == nil) + return false; + + id blit = + [state->current_command_buffer blitCommandEncoder]; + if (blit == nil) + return false; + + const MTLOrigin origin = MTLOriginMake(src_x, src_y, 0); + const MTLSize size = MTLSizeMake(src_w, src_h, 1); + [blit copyFromTexture:texture + sourceSlice:0 + sourceLevel:0 + sourceOrigin:origin + sourceSize:size + toBuffer:readback_buffer + destinationOffset:0 + destinationBytesPerRow:row_bytes + destinationBytesPerImage:buffer_size]; + [blit endEncoding]; + + [state->current_command_buffer presentDrawable:state->current_drawable]; + [state->current_command_buffer commit]; + [state->current_command_buffer waitUntilCompleted]; + + if (state->current_command_buffer.status + != MTLCommandBufferStatusCompleted) + return false; + + const unsigned char* src_bytes = static_cast( + [readback_buffer contents]); + if (src_bytes == nullptr) + return false; + + unsigned char* dst_bytes = reinterpret_cast(pixels); + const double sample_scale_x = static_cast(src_w) + / static_cast(w); + const double sample_scale_y = static_cast(src_h) + / static_cast(h); + for (int row = 0; row < h; ++row) { + unsigned char* dst_row = dst_bytes + + static_cast(row) + * static_cast(w) * 4; + const int sample_row = std::clamp( + static_cast(std::floor((static_cast(row) + 0.5) + * sample_scale_y)), + 0, src_h - 1); + const unsigned char* src_row = src_bytes + + static_cast(sample_row) + * static_cast( + row_bytes); + for (int col = 0; col < w; ++col) { + const int sample_col = std::clamp( + static_cast(std::floor((static_cast(col) + 0.5) + * sample_scale_x)), + 0, src_w - 1); + const unsigned char* src + = src_row + static_cast(sample_col) * 4; + unsigned char* dst = dst_row + static_cast(col) * 4; + dst[0] = src[2]; + dst[1] = src[1]; + dst[2] = src[0]; + dst[3] = src[3]; + } + } + + state->current_command_buffer = nil; + state->current_drawable = nil; + return true; + } + + bool metal_probe_runtime_support(std::string& error_message) + { + id device = MTLCreateSystemDefaultDevice(); + if (device == nil) { + error_message = "Metal device is unavailable"; + return false; + } + error_message.clear(); + return true; + } + +} // namespace +} // namespace Imiv + +namespace Imiv { + +const RendererBackendVTable k_metal_vtable = { + BackendKind::Metal, + metal_probe_runtime_support, + metal_get_viewer_texture_refs, + renderer_texture_preview_pending, + metal_create_texture, + metal_destroy_texture, + metal_update_preview_texture, + renderer_noop_quiesce_texture_preview_submission, + metal_setup_instance, + metal_setup_device, + metal_setup_window, + metal_create_surface, + renderer_clear_backend_window, + metal_cleanup_window, + metal_cleanup, + metal_wait_idle, + metal_imgui_init, + ImGui_ImplMetal_Shutdown, + metal_imgui_new_frame, + renderer_framebuffer_size_changed, + metal_resize_main_window, + renderer_set_clear_color, + renderer_noop_platform_windows, + renderer_noop_platform_windows, + metal_frame_render, + metal_frame_present, + metal_screen_capture, +}; + +const RendererBackendVTable* +renderer_backend_metal_vtable() +{ + return &k_metal_vtable; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_renderer_opengl.cpp b/src/imiv/imiv_renderer_opengl.cpp new file mode 100644 index 0000000000..92220f5d56 --- /dev/null +++ b/src/imiv/imiv_renderer_opengl.cpp @@ -0,0 +1,1897 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_renderer_backend.h" + +#include "imiv_loaded_image.h" +#include "imiv_ocio.h" +#include "imiv_platform_glfw.h" +#include "imiv_preview_shader_text.h" +#include "imiv_tiling.h" +#include "imiv_viewer.h" + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifndef GL_TEXTURE_BASE_LEVEL +# define GL_TEXTURE_BASE_LEVEL 0x813C +#endif +#ifndef GL_TEXTURE_MAX_LEVEL +# define GL_TEXTURE_MAX_LEVEL 0x813D +#endif +#ifndef GL_RGBA32F +# define GL_RGBA32F 0x8814 +#endif +#ifndef GL_NO_ERROR +# define GL_NO_ERROR 0 +#endif +#ifndef GL_FRAMEBUFFER +# define GL_FRAMEBUFFER 0x8D40 +#endif +#ifndef GL_COLOR_ATTACHMENT0 +# define GL_COLOR_ATTACHMENT0 0x8CE0 +#endif +#ifndef GL_FRAMEBUFFER_COMPLETE +# define GL_FRAMEBUFFER_COMPLETE 0x8CD5 +#endif +#ifndef GL_VERTEX_SHADER +# define GL_VERTEX_SHADER 0x8B31 +#endif +#ifndef GL_FRAGMENT_SHADER +# define GL_FRAGMENT_SHADER 0x8B30 +#endif +#ifndef GL_COMPILE_STATUS +# define GL_COMPILE_STATUS 0x8B81 +#endif +#ifndef GL_LINK_STATUS +# define GL_LINK_STATUS 0x8B82 +#endif +#ifndef GL_INFO_LOG_LENGTH +# define GL_INFO_LOG_LENGTH 0x8B84 +#endif +#ifndef GL_TEXTURE0 +# define GL_TEXTURE0 0x84C0 +#endif +#ifndef GL_UNPACK_ROW_LENGTH +# define GL_UNPACK_ROW_LENGTH 0x0CF2 +#endif +#ifndef GL_TEXTURE_1D +# define GL_TEXTURE_1D 0x0DE0 +#endif +#ifndef GL_TEXTURE_3D +# define GL_TEXTURE_3D 0x806F +#endif +#ifndef GL_R32F +# define GL_R32F 0x822E +#endif +#ifndef GL_R8 +# define GL_R8 0x8229 +#endif +#ifndef GL_R16 +# define GL_R16 0x822A +#endif +#ifndef GL_R16F +# define GL_R16F 0x822D +#endif +#ifndef GL_RG +# define GL_RG 0x8227 +#endif +#ifndef GL_RG8 +# define GL_RG8 0x822B +#endif +#ifndef GL_RG16 +# define GL_RG16 0x822C +#endif +#ifndef GL_RG16F +# define GL_RG16F 0x822F +#endif +#ifndef GL_RG32F +# define GL_RG32F 0x8230 +#endif +#ifndef GL_RGB32F +# define GL_RGB32F 0x8815 +#endif +#ifndef GL_RGB8 +# define GL_RGB8 0x8051 +#endif +#ifndef GL_RGB16 +# define GL_RGB16 0x8054 +#endif +#ifndef GL_RGB16F +# define GL_RGB16F 0x881B +#endif +#ifndef GL_RGBA8 +# define GL_RGBA8 0x8058 +#endif +#ifndef GL_RGBA16 +# define GL_RGBA16 0x805B +#endif +#ifndef GL_RGBA16F +# define GL_RGBA16F 0x881A +#endif +#ifndef GL_HALF_FLOAT +# define GL_HALF_FLOAT 0x140B +#endif +#ifndef GL_TEXTURE_WRAP_R +# define GL_TEXTURE_WRAP_R 0x8072 +#endif +#ifndef GL_RED +# define GL_RED 0x1903 +#endif +#ifndef GL_RGB +# define GL_RGB 0x1907 +#endif + +namespace Imiv { + +namespace { + + void clear_gl_error_queue() + { + while (glGetError() != GL_NO_ERROR) {} + } + + struct RendererTextureBackendState { + GLuint source_texture = 0; + GLuint preview_linear_texture = 0; + GLuint preview_nearest_texture = 0; + int width = 0; + int height = 0; + int input_channels = 0; + bool preview_dirty = true; + bool preview_params_valid = false; + PreviewControls last_preview_controls = {}; + std::string last_ocio_shader_cache_id; + }; + + struct SourceTextureUploadDesc { + GLint internal_format = GL_RGBA32F; + GLenum format = GL_RGBA; + GLenum type = GL_FLOAT; + const void* pixels = nullptr; + GLint unpack_row_length = 0; + size_t pixel_stride_bytes = 0; + size_t row_pitch_bytes = 0; + std::vector fallback_rgba_data = {}; + }; + + struct BasicPreviewProgram { + GLuint program = 0; + GLuint fullscreen_triangle_vao = 0; + GLint source_sampler_location = -1; + GLint input_channels_location = -1; + GLint exposure_location = -1; + GLint gamma_location = -1; + GLint offset_location = -1; + GLint color_mode_location = -1; + GLint channel_location = -1; + GLint orientation_location = -1; + bool ready = false; + }; + + struct OcioPreviewProgram { + struct TextureDesc { + GLuint texture_id = 0; + GLenum target = 0; + GLint sampler_location = -1; + GLint texture_unit = 0; + }; + + struct UniformDesc { + std::string name; + OCIO::GpuShaderDesc::UniformData data; + GLint location = -1; + }; + + GLuint program = 0; + GLint source_sampler_location = -1; + GLint input_channels_location = -1; + GLint offset_location = -1; + GLint color_mode_location = -1; + GLint channel_location = -1; + GLint orientation_location = -1; + OcioShaderRuntime* runtime = nullptr; + std::string shader_cache_id; + std::vector textures; + std::vector uniforms; + bool ready = false; + }; + + using GlUniform1fProc = void(APIENTRY*)(GLint location, GLfloat v0); + using GlUniform3fProc = void(APIENTRY*)(GLint location, GLfloat v0, + GLfloat v1, GLfloat v2); + using GlUniform1fvProc = void(APIENTRY*)(GLint location, GLsizei count, + const GLfloat* value); + using GlUniform1ivProc = void(APIENTRY*)(GLint location, GLsizei count, + const GLint* value); + using GlDrawArraysProc = void(APIENTRY*)(GLenum mode, GLint first, + GLsizei count); + using GlTexImage1DProc + = void(APIENTRY*)(GLenum target, GLint level, GLint internalformat, + GLsizei width, GLint border, GLenum format, + GLenum type, const void* pixels); + using GlGenFramebuffersProc = void(APIENTRY*)(GLsizei n, + GLuint* framebuffers); + using GlBindFramebufferProc = void(APIENTRY*)(GLenum target, + GLuint framebuffer); + using GlDeleteFramebuffersProc + = void(APIENTRY*)(GLsizei n, const GLuint* framebuffers); + using GlFramebufferTexture2DProc + = void(APIENTRY*)(GLenum target, GLenum attachment, GLenum textarget, + GLuint texture, GLint level); + using GlCheckFramebufferStatusProc = GLenum(APIENTRY*)(GLenum target); + using GlTexImage3DProc + = void(APIENTRY*)(GLenum target, GLint level, GLint internalformat, + GLsizei width, GLsizei height, GLsizei depth, + GLint border, GLenum format, GLenum type, + const void* pixels); + using GlReadBufferProc = void(APIENTRY*)(GLenum src); + + struct OpenGlExtraProcs { + GlUniform1fProc Uniform1f = nullptr; + GlUniform3fProc Uniform3f = nullptr; + GlUniform1fvProc Uniform1fv = nullptr; + GlUniform1ivProc Uniform1iv = nullptr; + GlDrawArraysProc DrawArrays = nullptr; + GlTexImage1DProc TexImage1D = nullptr; + GlGenFramebuffersProc GenFramebuffers = nullptr; + GlBindFramebufferProc BindFramebuffer = nullptr; + GlDeleteFramebuffersProc DeleteFramebuffers = nullptr; + GlFramebufferTexture2DProc FramebufferTexture2D = nullptr; + GlCheckFramebufferStatusProc CheckFramebufferStatus = nullptr; + GlTexImage3DProc TexImage3D = nullptr; + GlReadBufferProc ReadBuffer = nullptr; + bool ready = false; + }; + + constexpr size_t kDefaultOpenGlUploadChunkBytes = 64u * 1024u * 1024u; + + bool read_opengl_limit_override(const char* name, size_t& out_value) + { + out_value = 0; + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + return false; + char* end = nullptr; + unsigned long long raw = std::strtoull(value, &end, 10); + if (end == value || *end != '\0' || raw == 0 + || raw > static_cast( + std::numeric_limits::max())) { + return false; + } + out_value = static_cast(raw); + return true; + } + + size_t opengl_max_upload_chunk_bytes() + { + size_t override_value = 0; + if (read_opengl_limit_override( + "IMIV_OPENGL_MAX_UPLOAD_CHUNK_BYTES_OVERRIDE", override_value)) { + return override_value; + } + return kDefaultOpenGlUploadChunkBytes; + } + + struct RendererBackendState { + GLFWwindow* window = nullptr; + GLFWwindow* backup_context = nullptr; + const char* glsl_version = nullptr; + bool imgui_initialized = false; + OpenGlExtraProcs extra_procs; + GLuint preview_framebuffer = 0; + BasicPreviewProgram basic_preview; + OcioPreviewProgram ocio_preview; + }; + + RendererBackendState* + ensure_opengl_backend_state(RendererState& renderer_state, + std::string& error_message) + { + if (!ensure_default_backend_state( + renderer_state)) { + error_message = "failed to allocate OpenGL renderer state"; + return nullptr; + } + return backend_state(renderer_state); + } + + RendererBackendState* opengl_window_state(RendererState& renderer_state, + std::string& error_message) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->window == nullptr) { + error_message = "OpenGL window is not initialized"; + return nullptr; + } + return state; + } + + bool ensure_basic_preview_program(RendererBackendState& state, + std::string& error_message); + bool ensure_preview_framebuffer(RendererBackendState& state, + std::string& error_message); + + const char* open_gl_glsl_version() + { +#if defined(__APPLE__) + return "#version 150"; +#else + return "#version 130"; +#endif + } + + template + bool load_gl_proc(const char* name, ProcT& proc, std::string& error_message) + { + proc = reinterpret_cast(platform_glfw_get_proc_address(name)); + if (proc != nullptr) + return true; + error_message = std::string("missing OpenGL function: ") + name; + return false; + } + + bool ensure_extra_procs(RendererBackendState& state, + std::string& error_message) + { + if (state.extra_procs.ready) + return true; + return load_gl_proc("glUniform1f", state.extra_procs.Uniform1f, + error_message) + && load_gl_proc("glUniform3f", state.extra_procs.Uniform3f, + error_message) + && load_gl_proc("glUniform1fv", state.extra_procs.Uniform1fv, + error_message) + && load_gl_proc("glUniform1iv", state.extra_procs.Uniform1iv, + error_message) + && load_gl_proc("glDrawArrays", state.extra_procs.DrawArrays, + error_message) + && load_gl_proc("glTexImage1D", state.extra_procs.TexImage1D, + error_message) + && load_gl_proc("glGenFramebuffers", + state.extra_procs.GenFramebuffers, error_message) + && load_gl_proc("glBindFramebuffer", + state.extra_procs.BindFramebuffer, error_message) + && load_gl_proc("glDeleteFramebuffers", + state.extra_procs.DeleteFramebuffers, + error_message) + && load_gl_proc("glFramebufferTexture2D", + state.extra_procs.FramebufferTexture2D, + error_message) + && load_gl_proc("glCheckFramebufferStatus", + state.extra_procs.CheckFramebufferStatus, + error_message) + && load_gl_proc("glTexImage3D", state.extra_procs.TexImage3D, + error_message) + && load_gl_proc("glReadBuffer", state.extra_procs.ReadBuffer, + error_message) + && ((state.extra_procs.ready = true), true); + } + + bool compile_shader(GLuint shader, const char* const* sources, + GLsizei source_count, std::string& error_message) + { + glShaderSource(shader, source_count, sources, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_TRUE) { + error_message.clear(); + return true; + } + + GLint log_length = 0; + glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &log_length); + std::string log(std::max(0, log_length), '\0'); + if (log_length > 0) + glGetShaderInfoLog(shader, log_length, nullptr, log.data()); + error_message = log.empty() ? "OpenGL shader compilation failed" : log; + return false; + } + + bool link_program(GLuint program, std::string& error_message) + { + glLinkProgram(program); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_TRUE) { + error_message.clear(); + return true; + } + + GLint log_length = 0; + glGetProgramiv(program, GL_INFO_LOG_LENGTH, &log_length); + std::string log(std::max(0, log_length), '\0'); + if (log_length > 0) + glGetProgramInfoLog(program, log_length, nullptr, log.data()); + error_message = log.empty() ? "OpenGL program link failed" : log; + return false; + } + + bool create_shader_program(const char* glsl_version, + const std::string& vertex_source, + const std::string& fragment_source, + const char* create_shader_error, + const char* create_program_error, + GLuint& program, std::string& error_message) + { + program = 0; + GLuint vertex_shader = glCreateShader(GL_VERTEX_SHADER); + GLuint fragment_shader = glCreateShader(GL_FRAGMENT_SHADER); + if (vertex_shader == 0 || fragment_shader == 0) { + if (vertex_shader != 0) + glDeleteShader(vertex_shader); + if (fragment_shader != 0) + glDeleteShader(fragment_shader); + error_message = create_shader_error; + return false; + } + + const char* vertex_sources[] = { glsl_version, "\n", + vertex_source.c_str() }; + const char* fragment_sources[] = { glsl_version, "\n", + fragment_source.c_str() }; + bool ok = compile_shader(vertex_shader, vertex_sources, 3, + error_message) + && compile_shader(fragment_shader, fragment_sources, 3, + error_message); + if (!ok) { + glDeleteShader(vertex_shader); + glDeleteShader(fragment_shader); + return false; + } + + program = glCreateProgram(); + if (program == 0) { + glDeleteShader(vertex_shader); + glDeleteShader(fragment_shader); + error_message = create_program_error; + return false; + } + + glAttachShader(program, vertex_shader); + glAttachShader(program, fragment_shader); + ok = link_program(program, error_message); + glDetachShader(program, vertex_shader); + glDetachShader(program, fragment_shader); + glDeleteShader(vertex_shader); + glDeleteShader(fragment_shader); + if (!ok) { + glDeleteProgram(program); + program = 0; + return false; + } + + return true; + } + + bool create_vertex_array_resource(GLuint& vao, const char* create_error, + std::string& error_message) + { + vao = 0; + glGenVertexArrays(1, &vao); + if (vao != 0) { + error_message.clear(); + return true; + } + error_message = create_error; + return false; + } + + void destroy_basic_preview_program(BasicPreviewProgram& program) + { + if (program.fullscreen_triangle_vao != 0) { + glDeleteVertexArrays(1, &program.fullscreen_triangle_vao); + program.fullscreen_triangle_vao = 0; + } + if (program.program != 0) { + glDeleteProgram(program.program); + program.program = 0; + } + program.source_sampler_location = -1; + program.input_channels_location = -1; + program.exposure_location = -1; + program.gamma_location = -1; + program.offset_location = -1; + program.color_mode_location = -1; + program.channel_location = -1; + program.orientation_location = -1; + program.ready = false; + } + + void destroy_ocio_preview_resources(OcioPreviewProgram& program) + { + for (const OcioPreviewProgram::TextureDesc& texture : + program.textures) { + if (texture.texture_id != 0) + glDeleteTextures(1, &texture.texture_id); + } + program.textures.clear(); + program.uniforms.clear(); + if (program.program != 0) { + glDeleteProgram(program.program); + program.program = 0; + } + program.source_sampler_location = -1; + program.input_channels_location = -1; + program.offset_location = -1; + program.color_mode_location = -1; + program.channel_location = -1; + program.orientation_location = -1; + program.shader_cache_id.clear(); + program.ready = false; + } + + void destroy_ocio_preview_program(OcioPreviewProgram& program) + { + destroy_ocio_preview_resources(program); + destroy_ocio_shader_runtime(program.runtime); + } + + GLenum gl_filter_for_ocio(OcioInterpolation interpolation) + { + return interpolation == OcioInterpolation::Nearest ? GL_NEAREST + : GL_LINEAR; + } + + GLenum gl_target_for_ocio(OcioTextureDimensions dimensions) + { + switch (dimensions) { + case OcioTextureDimensions::Tex1D: return GL_TEXTURE_1D; + case OcioTextureDimensions::Tex3D: return GL_TEXTURE_3D; + case OcioTextureDimensions::Tex2D: + default: return GL_TEXTURE_2D; + } + } + + void set_ocio_texture_parameters(GLenum target, GLenum filter) + { + glTexParameteri(target, GL_TEXTURE_MIN_FILTER, filter); + glTexParameteri(target, GL_TEXTURE_MAG_FILTER, filter); + glTexParameteri(target, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + if (target != GL_TEXTURE_1D) + glTexParameteri(target, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + if (target == GL_TEXTURE_3D) + glTexParameteri(target, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + } + + bool upload_ocio_texture(RendererBackendState& state, + const OcioTextureBlueprint& blueprint, + GLint texture_unit, GLuint program, + OcioPreviewProgram::TextureDesc& texture_desc, + std::string& error_message) + { + const GLenum target = gl_target_for_ocio(blueprint.dimensions); + const GLenum filter = gl_filter_for_ocio(blueprint.interpolation); + const GLenum format = blueprint.channel == OcioTextureChannel::Red + ? GL_RED + : GL_RGB; + const GLint internal_format = blueprint.channel + == OcioTextureChannel::Red + ? GL_R32F + : GL_RGB32F; + + GLuint texture_id = 0; + glGenTextures(1, &texture_id); + if (texture_id == 0) { + error_message = "failed to create OpenGL OCIO LUT texture"; + return false; + } + + glActiveTexture(GL_TEXTURE0 + texture_unit); + glBindTexture(target, texture_id); + set_ocio_texture_parameters(target, filter); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + clear_gl_error_queue(); + const float* values = blueprint.values.empty() + ? nullptr + : blueprint.values.data(); + if (values == nullptr) { + glDeleteTextures(1, &texture_id); + error_message = "missing OCIO LUT values"; + return false; + } + + if (target == GL_TEXTURE_3D) { + state.extra_procs.TexImage3D(target, 0, internal_format, + static_cast(blueprint.width), + static_cast(blueprint.height), + static_cast(blueprint.depth), + 0, format, GL_FLOAT, values); + } else if (target == GL_TEXTURE_2D) { + glTexImage2D(target, 0, internal_format, + static_cast(blueprint.width), + static_cast(blueprint.height), 0, format, + GL_FLOAT, values); + } else { + state.extra_procs.TexImage1D(target, 0, internal_format, + static_cast(blueprint.width), + 0, format, GL_FLOAT, values); + } + + const GLenum err = glGetError(); + if (err != GL_NO_ERROR) { + glDeleteTextures(1, &texture_id); + error_message = "OpenGL OCIO texture upload failed"; + return false; + } + + const GLint sampler_location + = glGetUniformLocation(program, blueprint.sampler_name.c_str()); + if (sampler_location < 0) { + glDeleteTextures(1, &texture_id); + error_message = "missing OpenGL OCIO sampler uniform: " + + blueprint.sampler_name; + return false; + } + + texture_desc.texture_id = texture_id; + texture_desc.target = target; + texture_desc.sampler_location = sampler_location; + texture_desc.texture_unit = texture_unit; + return true; + } + + bool set_ocio_uniform(GLint location, + const OCIO::GpuShaderDesc::UniformData& data, + OpenGlExtraProcs& extra_procs, + std::string& error_message) + { + if (location < 0) + return true; + + switch (data.m_type) { + case OCIO::UNIFORM_DOUBLE: + extra_procs.Uniform1f(location, + data.m_getDouble + ? static_cast(data.m_getDouble()) + : 0.0f); + return true; + case OCIO::UNIFORM_BOOL: { + const GLint value = data.m_getBool && data.m_getBool() ? 1 : 0; + glUniform1i(location, value); + return true; + } + case OCIO::UNIFORM_FLOAT3: + if (data.m_getFloat3) { + const OCIO::Float3& value = data.m_getFloat3(); + extra_procs.Uniform3f(location, static_cast(value[0]), + static_cast(value[1]), + static_cast(value[2])); + } else { + extra_procs.Uniform3f(location, 0.0f, 0.0f, 0.0f); + } + return true; + case OCIO::UNIFORM_VECTOR_FLOAT: + if (data.m_vectorFloat.m_getSize + && data.m_vectorFloat.m_getVector) { + extra_procs.Uniform1fv(location, + static_cast( + data.m_vectorFloat.m_getSize()), + data.m_vectorFloat.m_getVector()); + } + return true; + case OCIO::UNIFORM_VECTOR_INT: + if (data.m_vectorInt.m_getSize && data.m_vectorInt.m_getVector) { + extra_procs.Uniform1iv(location, + static_cast( + data.m_vectorInt.m_getSize()), + data.m_vectorInt.m_getVector()); + } + return true; + case OCIO::UNIFORM_UNKNOWN: + default: + error_message = "unsupported OpenGL OCIO uniform type"; + return false; + } + } + + bool build_ocio_fragment_source(const OcioShaderBlueprint& blueprint, + std::string& shader_source, + std::string& error_message) + { + shader_source.clear(); + error_message.clear(); + if (!blueprint.enabled || !blueprint.valid + || blueprint.shader_text.empty()) { + error_message = "OpenGL OCIO shader blueprint is invalid"; + return false; + } + + shader_source = glsl_preview_fragment_preamble(false); + shader_source += "\n"; + shader_source += blueprint.shader_text; + shader_source += R"glsl( + +void main() +{ + vec2 src_uv = display_to_source_uv(uv_in, u_orientation); + vec4 c = texture(u_source_image, src_uv); + if (u_input_channels == 2) + c = vec4(c.rrr, c.g); + c.rgb += vec3(u_offset); + + if (u_color_mode == 1) { + c.a = 1.0; + } else if (u_color_mode == 2) { + float v = selected_channel(c, u_channel); + c = vec4(v, v, v, 1.0); + } else if (u_color_mode == 3) { + float y = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + c = vec4(y, y, y, 1.0); + } else if (u_color_mode == 4) { + float v = selected_channel(c, u_channel); + c = vec4(heatmap(v), 1.0); + } + + if (u_channel > 0 && u_color_mode != 2 && u_color_mode != 4) { + float v = selected_channel(c, u_channel); + c = vec4(v, v, v, 1.0); + } + + if (u_input_channels == 1 && u_color_mode <= 1) + c = vec4(c.rrr, 1.0); + else if (u_input_channels == 2 && u_color_mode == 0) + c = vec4(c.rrr, c.a); + else if (u_input_channels == 2 && u_color_mode == 1) + c = vec4(c.rrr, 1.0); + + out_color = )glsl"; + shader_source += blueprint.function_name; + shader_source += R"glsl((c); +} +)glsl"; + return true; + } + + bool ensure_ocio_preview_program(RendererBackendState& state, + const PlaceholderUiState& ui_state, + const LoadedImage* image, + std::string& error_message) + { + if (!ensure_basic_preview_program(state, error_message) + || !ensure_extra_procs(state, error_message)) { + return false; + } + if (!ensure_ocio_shader_runtime_glsl(ui_state, image, + state.ocio_preview.runtime, + error_message)) { + return false; + } + if (state.ocio_preview.runtime == nullptr + || state.ocio_preview.runtime->shader_desc == nullptr) { + error_message = "OpenGL OCIO runtime is not initialized"; + return false; + } + + const OcioShaderBlueprint& blueprint + = state.ocio_preview.runtime->blueprint; + if (state.ocio_preview.ready + && state.ocio_preview.shader_cache_id + == blueprint.shader_cache_id) { + return true; + } + + destroy_ocio_preview_resources(state.ocio_preview); + + std::string fragment_source; + if (!build_ocio_fragment_source(blueprint, fragment_source, + error_message)) { + return false; + } + + const std::string vertex_source + = glsl_fullscreen_triangle_vertex_shader(); + GLuint program = 0; + if (!create_shader_program(state.glsl_version, vertex_source, + fragment_source, + "failed to create OpenGL OCIO shader objects", + "failed to create OpenGL OCIO program", + program, error_message)) { + return false; + } + + state.ocio_preview.program = program; + state.ocio_preview.source_sampler_location + = glGetUniformLocation(program, "u_source_image"); + state.ocio_preview.input_channels_location + = glGetUniformLocation(program, "u_input_channels"); + state.ocio_preview.offset_location = glGetUniformLocation(program, + "u_offset"); + state.ocio_preview.color_mode_location + = glGetUniformLocation(program, "u_color_mode"); + state.ocio_preview.channel_location = glGetUniformLocation(program, + "u_channel"); + state.ocio_preview.orientation_location + = glGetUniformLocation(program, "u_orientation"); + + const auto& ocio_textures = blueprint.textures; + state.ocio_preview.textures.reserve(ocio_textures.size()); + for (size_t i = 0; i < ocio_textures.size(); ++i) { + OcioPreviewProgram::TextureDesc texture_desc; + if (!upload_ocio_texture(state, ocio_textures[i], + static_cast(i + 1), program, + texture_desc, error_message)) { + destroy_ocio_preview_resources(state.ocio_preview); + return false; + } + state.ocio_preview.textures.push_back(std::move(texture_desc)); + } + + const unsigned num_uniforms + = state.ocio_preview.runtime->shader_desc->getNumUniforms(); + state.ocio_preview.uniforms.reserve(num_uniforms); + for (unsigned idx = 0; idx < num_uniforms; ++idx) { + OCIO::GpuShaderDesc::UniformData data; + const char* name + = state.ocio_preview.runtime->shader_desc->getUniform(idx, + data); + OcioPreviewProgram::UniformDesc uniform; + if (name != nullptr) + uniform.name = name; + uniform.data = data; + uniform.location = uniform.name.empty() + ? -1 + : glGetUniformLocation(program, + uniform.name.c_str()); + state.ocio_preview.uniforms.push_back(std::move(uniform)); + } + + state.ocio_preview.shader_cache_id = blueprint.shader_cache_id; + state.ocio_preview.ready = true; + error_message.clear(); + return true; + } + + bool ensure_basic_preview_program(RendererBackendState& state, + std::string& error_message) + { + if (state.basic_preview.ready) + return true; + if (!ensure_extra_procs(state, error_message)) + return false; + + const std::string vertex_source + = glsl_fullscreen_triangle_vertex_shader(); + + std::string fragment_source = glsl_preview_fragment_preamble(true); + fragment_source += R"glsl( + +void main() +{ + vec2 src_uv = display_to_source_uv(uv_in, u_orientation); + vec4 c = texture(u_source_image, src_uv); + if (u_input_channels == 2) + c = vec4(c.rrr, c.g); + c.rgb += vec3(u_offset); + + if (u_color_mode == 1) { + c.a = 1.0; + } else if (u_color_mode == 2) { + float v = selected_channel(c, u_channel); + c = vec4(v, v, v, 1.0); + } else if (u_color_mode == 3) { + float y = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + c = vec4(y, y, y, 1.0); + } else if (u_color_mode == 4) { + float v = selected_channel(c, u_channel); + c = vec4(heatmap(v), 1.0); + } + + if (u_channel > 0 && u_color_mode != 2 && u_color_mode != 4) { + float v = selected_channel(c, u_channel); + c = vec4(v, v, v, 1.0); + } + + if (u_input_channels == 1 && u_color_mode <= 1) + c = vec4(c.rrr, 1.0); + else if (u_input_channels == 2 && u_color_mode == 0) + c = vec4(c.rrr, c.a); + else if (u_input_channels == 2 && u_color_mode == 1) + c = vec4(c.rrr, 1.0); + + float exposure_scale = exp2(u_exposure); + c.rgb *= exposure_scale; + float g = max(u_gamma, 0.01); + c.rgb = pow(max(c.rgb, vec3(0.0)), vec3(1.0 / g)); + + out_color = c; +} +)glsl"; + GLuint program = 0; + if (!create_shader_program(state.glsl_version, vertex_source, + fragment_source, + "failed to create OpenGL shader objects", + "failed to create OpenGL preview program", + program, error_message)) { + return false; + } + + GLuint vao = 0; + if (!create_vertex_array_resource(vao, + "failed to create OpenGL preview VAO", + error_message)) { + glDeleteProgram(program); + return false; + } + + state.basic_preview.program = program; + state.basic_preview.fullscreen_triangle_vao = vao; + state.basic_preview.source_sampler_location + = glGetUniformLocation(program, "u_source_image"); + state.basic_preview.input_channels_location + = glGetUniformLocation(program, "u_input_channels"); + state.basic_preview.exposure_location + = glGetUniformLocation(program, "u_exposure"); + state.basic_preview.gamma_location = glGetUniformLocation(program, + "u_gamma"); + state.basic_preview.offset_location = glGetUniformLocation(program, + "u_offset"); + state.basic_preview.color_mode_location + = glGetUniformLocation(program, "u_color_mode"); + state.basic_preview.channel_location + = glGetUniformLocation(program, "u_channel"); + state.basic_preview.orientation_location + = glGetUniformLocation(program, "u_orientation"); + state.basic_preview.ready = true; + error_message.clear(); + return true; + } + + bool ensure_preview_framebuffer(RendererBackendState& state, + std::string& error_message) + { + if (state.preview_framebuffer != 0) + return true; + if (!ensure_extra_procs(state, error_message)) + return false; + state.extra_procs.GenFramebuffers(1, &state.preview_framebuffer); + if (state.preview_framebuffer != 0) { + error_message.clear(); + return true; + } + error_message = "failed to create OpenGL preview framebuffer"; + return false; + } + + bool allocate_source_texture_storage(GLuint texture_id, GLint filter, + int width, int height, + const SourceTextureUploadDesc& upload, + std::string& error_message) + { + if (upload.pixels == nullptr || upload.pixel_stride_bytes == 0 + || upload.row_pitch_bytes == 0 || upload.unpack_row_length <= 0) { + error_message = "invalid source upload descriptor"; + return false; + } + + RowStripeUploadPlan stripe_plan; + if (!build_row_stripe_upload_plan(upload.row_pitch_bytes, + upload.pixel_stride_bytes, height, + opengl_max_upload_chunk_bytes(), 1, + stripe_plan, error_message)) { + return false; + } + + glBindTexture(GL_TEXTURE_2D, texture_id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + clear_gl_error_queue(); + glTexImage2D(GL_TEXTURE_2D, 0, upload.internal_format, width, height, 0, + upload.format, upload.type, nullptr); + GLenum err = glGetError(); + if (err == GL_NO_ERROR) { + glPixelStorei(GL_UNPACK_ROW_LENGTH, upload.unpack_row_length); + const unsigned char* source_pixels + = reinterpret_cast(upload.pixels); + for (uint32_t stripe_index = 0; + stripe_index < stripe_plan.stripe_count; ++stripe_index) { + const int stripe_y = static_cast( + stripe_index * stripe_plan.stripe_rows); + const int stripe_height + = std::min(static_cast(stripe_plan.stripe_rows), + height - stripe_y); + const void* stripe_pixels = source_pixels + + static_cast(stripe_y) + * upload.row_pitch_bytes; + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, stripe_y, width, + stripe_height, upload.format, upload.type, + stripe_pixels); + err = glGetError(); + if (err != GL_NO_ERROR) { + error_message = stripe_plan.uses_multiple_stripes + ? "OpenGL striped texture upload failed" + : "OpenGL texture upload failed"; + break; + } + } + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + } + glBindTexture(GL_TEXTURE_2D, 0); + if (err == GL_NO_ERROR) { + error_message.clear(); + return true; + } + error_message = "OpenGL texture upload failed"; + return false; + } + + bool allocate_preview_texture_storage(GLuint texture_id, GLint filter, + int width, int height, + std::string& error_message) + { + glBindTexture(GL_TEXTURE_2D, texture_id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + clear_gl_error_queue(); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, + GL_FLOAT, nullptr); + const GLenum err = glGetError(); + glBindTexture(GL_TEXTURE_2D, 0); + if (err == GL_NO_ERROR) { + error_message.clear(); + return true; + } + error_message = "OpenGL texture allocation failed"; + return false; + } + + bool render_basic_preview_texture( + RendererBackendState& state, RendererTextureBackendState& texture_state, + GLuint target_texture, const PreviewControls& controls, + std::string& error_message) + { + if (!ensure_basic_preview_program(state, error_message) + || !ensure_preview_framebuffer(state, error_message)) { + return false; + } + + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, + state.preview_framebuffer); + state.extra_procs.FramebufferTexture2D(GL_FRAMEBUFFER, + GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, target_texture, + 0); + if (state.extra_procs.CheckFramebufferStatus(GL_FRAMEBUFFER) + != GL_FRAMEBUFFER_COMPLETE) { + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, 0); + error_message = "OpenGL preview framebuffer is incomplete"; + return false; + } + + glViewport(0, 0, texture_state.width, texture_state.height); + glDisable(GL_BLEND); + glDisable(GL_DEPTH_TEST); + clear_gl_error_queue(); + glUseProgram(state.basic_preview.program); + glBindVertexArray(state.basic_preview.fullscreen_triangle_vao); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture_state.source_texture); + glUniform1i(state.basic_preview.source_sampler_location, 0); + glUniform1i(state.basic_preview.input_channels_location, + texture_state.input_channels); + state.extra_procs.Uniform1f(state.basic_preview.exposure_location, + controls.exposure); + state.extra_procs.Uniform1f(state.basic_preview.gamma_location, + std::max(0.01f, controls.gamma)); + state.extra_procs.Uniform1f(state.basic_preview.offset_location, + controls.offset); + glUniform1i(state.basic_preview.color_mode_location, + controls.color_mode); + glUniform1i(state.basic_preview.channel_location, controls.channel); + glUniform1i(state.basic_preview.orientation_location, + controls.orientation); + state.extra_procs.DrawArrays(GL_TRIANGLES, 0, 3); + + glBindTexture(GL_TEXTURE_2D, 0); + glBindVertexArray(0); + glUseProgram(0); + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, 0); + const GLenum err = glGetError(); + if (err == GL_NO_ERROR) { + error_message.clear(); + return true; + } + error_message = "OpenGL preview draw failed"; + return false; + } + + bool render_ocio_preview_texture(RendererBackendState& state, + RendererTextureBackendState& texture_state, + GLuint target_texture, + const PreviewControls& controls, + std::string& error_message) + { + if (!ensure_preview_framebuffer(state, error_message) + || !state.ocio_preview.ready + || state.ocio_preview.runtime == nullptr + || state.ocio_preview.runtime->shader_desc == nullptr + || !state.basic_preview.ready) { + error_message = "OpenGL OCIO preview state is not initialized"; + return false; + } + + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, + state.preview_framebuffer); + state.extra_procs.FramebufferTexture2D(GL_FRAMEBUFFER, + GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, target_texture, + 0); + if (state.extra_procs.CheckFramebufferStatus(GL_FRAMEBUFFER) + != GL_FRAMEBUFFER_COMPLETE) { + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, 0); + error_message = "OpenGL preview framebuffer is incomplete"; + return false; + } + + if (state.ocio_preview.runtime->exposure_property) { + state.ocio_preview.runtime->exposure_property->setValue( + static_cast(controls.exposure)); + } + if (state.ocio_preview.runtime->gamma_property) { + const double gamma + = 1.0 / std::max(1.0e-6, static_cast(controls.gamma)); + state.ocio_preview.runtime->gamma_property->setValue(gamma); + } + + glViewport(0, 0, texture_state.width, texture_state.height); + glDisable(GL_BLEND); + glDisable(GL_DEPTH_TEST); + clear_gl_error_queue(); + glUseProgram(state.ocio_preview.program); + glBindVertexArray(state.basic_preview.fullscreen_triangle_vao); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture_state.source_texture); + glUniform1i(state.ocio_preview.source_sampler_location, 0); + glUniform1i(state.ocio_preview.input_channels_location, + texture_state.input_channels); + state.extra_procs.Uniform1f(state.ocio_preview.offset_location, + controls.offset); + glUniform1i(state.ocio_preview.color_mode_location, + controls.color_mode); + glUniform1i(state.ocio_preview.channel_location, controls.channel); + glUniform1i(state.ocio_preview.orientation_location, + controls.orientation); + + for (const OcioPreviewProgram::TextureDesc& texture : + state.ocio_preview.textures) { + glActiveTexture(GL_TEXTURE0 + texture.texture_unit); + glBindTexture(texture.target, texture.texture_id); + glUniform1i(texture.sampler_location, texture.texture_unit); + } + for (const OcioPreviewProgram::UniformDesc& uniform : + state.ocio_preview.uniforms) { + if (!set_ocio_uniform(uniform.location, uniform.data, + state.extra_procs, error_message)) { + glBindVertexArray(0); + glUseProgram(0); + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, 0); + return false; + } + } + + state.extra_procs.DrawArrays(GL_TRIANGLES, 0, 3); + + glBindTexture(GL_TEXTURE_2D, 0); + glBindVertexArray(0); + glUseProgram(0); + state.extra_procs.BindFramebuffer(GL_FRAMEBUFFER, 0); + const GLenum err = glGetError(); + if (err == GL_NO_ERROR) { + error_message.clear(); + return true; + } + error_message = "OpenGL OCIO preview draw failed"; + return false; + } + + bool build_rgba_float_pixels(const LoadedImage& image, + std::vector& rgba_pixels, + std::string& error_message) + { + using namespace OIIO; + + error_message.clear(); + rgba_pixels.clear(); + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0) { + error_message = "invalid source image dimensions"; + return false; + } + + const TypeDesc format = upload_data_type_to_typedesc(image.type); + if (format == TypeUnknown) { + error_message = "unsupported source pixel type"; + return false; + } + + ImageBuf source; + if (!imagebuf_from_loaded_image(image, source, error_message)) + return false; + + const size_t width = static_cast(image.width); + const size_t height = static_cast(image.height); + const size_t channels = static_cast(image.nchannels); + + std::vector source_pixels(width * height * channels, 0.0f); + if (!source.get_pixels(ROI::All(), TypeFloat, source_pixels.data())) { + error_message = source.geterror().empty() + ? "failed to convert image pixels to float" + : source.geterror(); + return false; + } + + rgba_pixels.assign(width * height * 4, 1.0f); + for (size_t pixel = 0, src = 0; pixel < width * height; ++pixel) { + float* dst = &rgba_pixels[pixel * 4]; + if (channels == 1) { + dst[0] = source_pixels[src + 0]; + dst[1] = source_pixels[src + 0]; + dst[2] = source_pixels[src + 0]; + dst[3] = 1.0f; + } else if (channels == 2) { + dst[0] = source_pixels[src + 0]; + dst[1] = source_pixels[src + 0]; + dst[2] = source_pixels[src + 0]; + dst[3] = source_pixels[src + 1]; + } else { + dst[0] = source_pixels[src + 0]; + dst[1] = source_pixels[src + 1]; + dst[2] = source_pixels[src + 2]; + dst[3] = (channels >= 4) ? source_pixels[src + 3] : 1.0f; + } + src += channels; + } + return true; + } + + bool describe_native_source_upload(const LoadedImage& image, + SourceTextureUploadDesc& upload, + std::string& error_message) + { + upload = {}; + if (image.width <= 0 || image.height <= 0 || image.nchannels <= 0) { + error_message = "invalid source image dimensions"; + return false; + } + + const int channel_count = image.nchannels; + if (channel_count > 4) { + if (!build_rgba_float_pixels(image, upload.fallback_rgba_data, + error_message)) { + return false; + } + upload.pixels = upload.fallback_rgba_data.data(); + upload.unpack_row_length = image.width; + upload.pixel_stride_bytes = sizeof(float) * 4; + upload.row_pitch_bytes = static_cast(image.width) + * upload.pixel_stride_bytes; + error_message.clear(); + return true; + } + + const size_t pixel_stride = image.channel_bytes + * static_cast(channel_count); + if (pixel_stride == 0 || image.row_pitch_bytes == 0 + || image.row_pitch_bytes + < static_cast(image.width) * pixel_stride) { + error_message = "invalid source stride for OpenGL upload"; + return false; + } + + if (image.row_pitch_bytes % pixel_stride != 0) { + if (!build_rgba_float_pixels(image, upload.fallback_rgba_data, + error_message)) { + return false; + } + upload.pixels = upload.fallback_rgba_data.data(); + upload.unpack_row_length = image.width; + upload.pixel_stride_bytes = sizeof(float) * 4; + upload.row_pitch_bytes = static_cast(image.width) + * upload.pixel_stride_bytes; + error_message.clear(); + return true; + } + + const GLint row_length = static_cast(image.row_pitch_bytes + / pixel_stride); + upload.pixels = image.pixels.data(); + upload.unpack_row_length = row_length; + upload.pixel_stride_bytes = pixel_stride; + upload.row_pitch_bytes = image.row_pitch_bytes; + + switch (image.nchannels) { + case 1: upload.format = GL_RED; break; + case 2: upload.format = GL_RG; break; + case 3: upload.format = GL_RGB; break; + case 4: upload.format = GL_RGBA; break; + default: break; + } + + switch (image.type) { + case UploadDataType::UInt8: + upload.type = GL_UNSIGNED_BYTE; + switch (image.nchannels) { + case 1: upload.internal_format = GL_R8; break; + case 2: upload.internal_format = GL_RG8; break; + case 3: upload.internal_format = GL_RGB8; break; + case 4: upload.internal_format = GL_RGBA8; break; + default: break; + } + break; + case UploadDataType::UInt16: + upload.type = GL_UNSIGNED_SHORT; + switch (image.nchannels) { + case 1: upload.internal_format = GL_R16; break; + case 2: upload.internal_format = GL_RG16; break; + case 3: upload.internal_format = GL_RGB16; break; + case 4: upload.internal_format = GL_RGBA16; break; + default: break; + } + break; + case UploadDataType::Half: + upload.type = GL_HALF_FLOAT; + switch (image.nchannels) { + case 1: upload.internal_format = GL_R16F; break; + case 2: upload.internal_format = GL_RG16F; break; + case 3: upload.internal_format = GL_RGB16F; break; + case 4: upload.internal_format = GL_RGBA16F; break; + default: break; + } + break; + case UploadDataType::Float: + upload.type = GL_FLOAT; + switch (image.nchannels) { + case 1: upload.internal_format = GL_R32F; break; + case 2: upload.internal_format = GL_RG32F; break; + case 3: upload.internal_format = GL_RGB32F; break; + case 4: upload.internal_format = GL_RGBA32F; break; + default: break; + } + break; + case UploadDataType::UInt32: + case UploadDataType::Double: + if (!build_rgba_float_pixels(image, upload.fallback_rgba_data, + error_message)) { + return false; + } + upload.internal_format = GL_RGBA32F; + upload.format = GL_RGBA; + upload.type = GL_FLOAT; + upload.pixels = upload.fallback_rgba_data.data(); + upload.unpack_row_length = image.width; + upload.pixel_stride_bytes = sizeof(float) * 4; + upload.row_pitch_bytes = static_cast(image.width) + * upload.pixel_stride_bytes; + error_message.clear(); + return true; + case UploadDataType::Unknown: + default: error_message = "unsupported source pixel type"; return false; + } + + error_message.clear(); + return true; + } + + bool opengl_get_viewer_texture_refs(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ImTextureRef& main_texture_ref, + bool& has_main_texture, + ImTextureRef& closeup_texture_ref, + bool& has_closeup_texture) + { + const RendererTextureBackendState* state + = texture_backend_state( + viewer.texture); + if (state == nullptr || !viewer.texture.preview_initialized) + return false; + + const GLuint main_texture = ui_state.linear_interpolation + ? state->preview_linear_texture + : state->preview_nearest_texture; + if (main_texture != 0) { + main_texture_ref = ImTextureRef( + static_cast(static_cast(main_texture))); + has_main_texture = true; + } + + const GLuint closeup_texture = state->preview_nearest_texture != 0 + ? state->preview_nearest_texture + : state->preview_linear_texture; + if (closeup_texture != 0) { + closeup_texture_ref = ImTextureRef(static_cast( + static_cast(closeup_texture))); + has_closeup_texture = true; + } + return has_main_texture || has_closeup_texture; + } + + bool opengl_create_texture(RendererState& renderer_state, + const LoadedImage& image, + RendererTexture& texture, + std::string& error_message) + { + RendererBackendState* state = opengl_window_state(renderer_state, + error_message); + if (state == nullptr) + return false; + + SourceTextureUploadDesc upload; + if (!describe_native_source_upload(image, upload, error_message)) + return false; + + auto* texture_state = new RendererTextureBackendState(); + if (texture_state == nullptr) { + error_message = "failed to allocate OpenGL texture state"; + return false; + } + + platform_glfw_make_context_current(state->window); + glGenTextures(1, &texture_state->source_texture); + glGenTextures(1, &texture_state->preview_linear_texture); + glGenTextures(1, &texture_state->preview_nearest_texture); + texture_state->width = image.width; + texture_state->height = image.height; + texture_state->input_channels = image.nchannels; + texture_state->preview_dirty = true; + if (texture_state->source_texture == 0 + || texture_state->preview_linear_texture == 0 + || texture_state->preview_nearest_texture == 0 + || !allocate_source_texture_storage(texture_state->source_texture, + GL_NEAREST, image.width, + image.height, upload, + error_message) + || !allocate_preview_texture_storage( + texture_state->preview_linear_texture, GL_LINEAR, image.width, + image.height, error_message) + || !allocate_preview_texture_storage( + texture_state->preview_nearest_texture, GL_NEAREST, image.width, + image.height, error_message)) { + if (texture_state->source_texture != 0) + glDeleteTextures(1, &texture_state->source_texture); + if (texture_state->preview_linear_texture != 0) + glDeleteTextures(1, &texture_state->preview_linear_texture); + if (texture_state->preview_nearest_texture != 0) + glDeleteTextures(1, &texture_state->preview_nearest_texture); + delete texture_state; + return false; + } + + texture.backend = reinterpret_cast<::Imiv::RendererTextureBackendState*>( + texture_state); + texture.preview_initialized = false; + error_message.clear(); + return true; + } + + void opengl_destroy_texture(RendererState& renderer_state, + RendererTexture& texture) + { + RendererTextureBackendState* state + = texture_backend_state(texture); + RendererBackendState* renderer = backend_state( + renderer_state); + if (state == nullptr) { + texture.preview_initialized = false; + return; + } + if (renderer != nullptr && renderer->window != nullptr) + platform_glfw_make_context_current(renderer->window); + if (state->source_texture != 0) + glDeleteTextures(1, &state->source_texture); + if (state->preview_linear_texture != 0) + glDeleteTextures(1, &state->preview_linear_texture); + if (state->preview_nearest_texture != 0) + glDeleteTextures(1, &state->preview_nearest_texture); + delete state; + texture.backend = nullptr; + texture.preview_initialized = false; + } + + bool opengl_update_preview_texture(RendererState& renderer_state, + RendererTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) + { + RendererBackendState* state = opengl_window_state(renderer_state, + error_message); + RendererTextureBackendState* texture_state + = texture_backend_state(texture); + if (state == nullptr || texture_state == nullptr) { + error_message = "OpenGL preview state is not initialized"; + return false; + } + + PreviewControls effective_controls = controls; + std::string ocio_shader_cache_id; + bool use_ocio_preview = false; + + platform_glfw_make_context_current(state->window); + if (controls.use_ocio != 0) { + if (ensure_ocio_preview_program(*state, ui_state, image, + error_message)) { + use_ocio_preview = true; + ocio_shader_cache_id = state->ocio_preview.shader_cache_id; + } else { + if (!error_message.empty()) { + std::cerr << "imiv: OpenGL OCIO fallback: " << error_message + << "\n"; + } + effective_controls.use_ocio = 0; + ocio_shader_cache_id.clear(); + error_message.clear(); + } + } + + if (!texture_state->preview_dirty && texture_state->preview_params_valid + && preview_controls_equal(texture_state->last_preview_controls, + effective_controls) + && texture_state->last_ocio_shader_cache_id + == ocio_shader_cache_id) { + texture.preview_initialized = true; + error_message.clear(); + return true; + } + + const bool ok = use_ocio_preview + ? render_ocio_preview_texture( + *state, *texture_state, + texture_state->preview_linear_texture, + effective_controls, error_message) + && render_ocio_preview_texture( + *state, *texture_state, + texture_state->preview_nearest_texture, + effective_controls, error_message) + : render_basic_preview_texture( + *state, *texture_state, + texture_state->preview_linear_texture, + effective_controls, error_message) + && render_basic_preview_texture( + *state, *texture_state, + texture_state->preview_nearest_texture, + effective_controls, error_message); + if (!ok) { + texture.preview_initialized = false; + return false; + } + + texture_state->preview_dirty = false; + texture_state->preview_params_valid = true; + texture_state->last_preview_controls = effective_controls; + texture_state->last_ocio_shader_cache_id = ocio_shader_cache_id; + texture.preview_initialized = true; + error_message.clear(); + return true; + } + + void opengl_cleanup(RendererState& renderer_state) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state != nullptr) { + if (state->window != nullptr) + platform_glfw_make_context_current(state->window); + destroy_ocio_preview_program(state->ocio_preview); + destroy_basic_preview_program(state->basic_preview); + if (state->preview_framebuffer != 0 && state->extra_procs.ready) { + state->extra_procs.DeleteFramebuffers( + 1, &state->preview_framebuffer); + state->preview_framebuffer = 0; + } + } + delete state; + renderer_state.backend = nullptr; + } + + bool opengl_imgui_init(RendererState& renderer_state, + std::string& error_message) + { + RendererBackendState* state = opengl_window_state(renderer_state, + error_message); + if (state == nullptr) + return false; + platform_glfw_make_context_current(state->window); + if (ImGui_ImplOpenGL3_Init(state->glsl_version)) { + state->imgui_initialized = true; + error_message.clear(); + return true; + } + error_message = "ImGui_ImplOpenGL3_Init failed"; + return false; + } + + void opengl_frame_render(RendererState& renderer_state, + ImDrawData* draw_data) + { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->window == nullptr) + return; + + int display_w = 0; + int display_h = 0; + platform_glfw_get_framebuffer_size(state->window, display_w, display_h); + renderer_state.framebuffer_width = display_w; + renderer_state.framebuffer_height = display_h; + glViewport(0, 0, display_w, display_h); + glClearColor( + renderer_state.clear_color[0] * renderer_state.clear_color[3], + renderer_state.clear_color[1] * renderer_state.clear_color[3], + renderer_state.clear_color[2] * renderer_state.clear_color[3], + renderer_state.clear_color[3]); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(draw_data); + } + + bool opengl_screen_capture(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data) + { + RendererState* renderer_state = reinterpret_cast( + user_data); + if (renderer_state == nullptr || pixels == nullptr || w <= 0 || h <= 0) + return false; + + RendererBackendState* state = backend_state( + *renderer_state); + if (state == nullptr || state->window == nullptr) + return false; + + int framebuffer_width = 0; + int framebuffer_height = 0; + platform_glfw_make_context_current(state->window); + platform_glfw_get_framebuffer_size(state->window, framebuffer_width, + framebuffer_height); + if (framebuffer_width <= 0 || framebuffer_height <= 0) + return false; + + int capture_x = x; + int capture_y = y; + int capture_w = w; + int capture_h = h; + bool use_full_capture = false; + ImGuiViewport* viewport = ImGui::FindViewportByID(viewport_id); + if (viewport != nullptr && viewport->Size.x > 0.0f + && viewport->Size.y > 0.0f) { + const double scale_x = static_cast(framebuffer_width) + / static_cast(viewport->Size.x); + const double scale_y = static_cast(framebuffer_height) + / static_cast(viewport->Size.y); + capture_x = static_cast(std::lround( + (static_cast(x) - static_cast(viewport->Pos.x)) + * scale_x)); + capture_y = static_cast(std::lround( + (static_cast(y) - static_cast(viewport->Pos.y)) + * scale_y)); + capture_w = std::max(1, static_cast(std::lround( + static_cast(w) * scale_x))); + capture_h = std::max(1, static_cast(std::lround( + static_cast(h) * scale_y))); + } else if (x < 0 || y < 0) { + use_full_capture = true; + } + + if (!use_full_capture) { + if (capture_x < 0) { + capture_w += capture_x; + capture_x = 0; + } + if (capture_y < 0) { + capture_h += capture_y; + capture_y = 0; + } + if (capture_x < framebuffer_width + && capture_y < framebuffer_height) { + capture_w = std::min(capture_w, framebuffer_width - capture_x); + capture_h = std::min(capture_h, framebuffer_height - capture_y); + } + if (capture_w <= 0 || capture_h <= 0) + use_full_capture = true; + } + + if (use_full_capture) { + capture_x = 0; + capture_y = 0; + capture_w = std::max(1, framebuffer_width); + capture_h = std::max(1, framebuffer_height); + } + + const int read_y = framebuffer_height - (capture_y + capture_h); + if (read_y < 0) + return false; + + std::vector readback(static_cast(capture_w) + * static_cast(capture_h) + * 4); + while (glGetError() != GL_NO_ERROR) {} + glPixelStorei(GL_PACK_ALIGNMENT, 1); + bool read_ok = false; + const GLenum read_buffers[] = { GL_BACK, GL_FRONT }; + for (GLenum read_buffer : read_buffers) { + state->extra_procs.ReadBuffer(read_buffer); + glReadPixels(capture_x, read_y, capture_w, capture_h, GL_RGBA, + GL_UNSIGNED_BYTE, readback.data()); + if (glGetError() == GL_NO_ERROR) { + read_ok = true; + break; + } + while (glGetError() != GL_NO_ERROR) {} + } + if (!read_ok) + return false; + + unsigned char* dst_bytes = reinterpret_cast(pixels); + if (capture_w == w && capture_h == h) { + for (int row = 0; row < h; ++row) { + const size_t src_offset = static_cast(h - 1 - row) + * static_cast(w) * 4; + const size_t dst_offset = static_cast(row) + * static_cast(w) * 4; + std::memcpy(dst_bytes + dst_offset, + readback.data() + src_offset, + static_cast(w) * 4); + } + return true; + } + + const double sample_scale_x = static_cast(capture_w) + / static_cast(w); + const double sample_scale_y = static_cast(capture_h) + / static_cast(h); + for (int row = 0; row < h; ++row) { + unsigned char* dst_row = dst_bytes + + static_cast(row) + * static_cast(w) * 4; + const int sample_row = std::clamp( + static_cast(std::floor((static_cast(row) + 0.5) + * sample_scale_y)), + 0, capture_h - 1); + const unsigned char* src_row + = readback.data() + + static_cast(capture_h - 1 - sample_row) + * static_cast(capture_w) * 4; + for (int col = 0; col < w; ++col) { + const int sample_col = std::clamp( + static_cast(std::floor((static_cast(col) + 0.5) + * sample_scale_x)), + 0, capture_w - 1); + const unsigned char* src + = src_row + static_cast(sample_col) * 4; + unsigned char* dst = dst_row + static_cast(col) * 4; + dst[0] = src[0]; + dst[1] = src[1]; + dst[2] = src[2]; + dst[3] = src[3]; + } + } + return true; + } + + bool opengl_probe_runtime_support(std::string& error_message) + { + GLFWwindow* window = platform_glfw_create_main_window( + BackendKind::OpenGL, 64, 64, "imiv.opengl.probe", error_message); + if (window == nullptr) + return false; + platform_glfw_make_context_current(nullptr); + platform_glfw_destroy_window(window); + error_message.clear(); + return true; + } + + const RendererBackendVTable k_opengl_vtable = { + BackendKind::OpenGL, + opengl_probe_runtime_support, + opengl_get_viewer_texture_refs, + renderer_texture_preview_pending, + opengl_create_texture, + opengl_destroy_texture, + opengl_update_preview_texture, + renderer_noop_quiesce_texture_preview_submission, + [](RendererState& renderer_state, + ImVector& instance_extensions, + std::string& error_message) { + (void)instance_extensions; + RendererBackendState* state + = ensure_opengl_backend_state(renderer_state, error_message); + if (state == nullptr) + return false; + state->glsl_version = open_gl_glsl_version(); + error_message.clear(); + return true; + }, + [](RendererState& renderer_state, std::string& error_message) { + if (backend_state(renderer_state) + == nullptr) { + error_message = "OpenGL renderer state is not initialized"; + return false; + } + error_message.clear(); + return true; + }, + [](RendererState& renderer_state, int width, int height, + std::string& error_message) { + renderer_set_framebuffer_size(renderer_state, width, height); + error_message.clear(); + return true; + }, + [](RendererState& renderer_state, GLFWwindow* window, + std::string& error_message) { + RendererBackendState* state + = ensure_opengl_backend_state(renderer_state, error_message); + if (state == nullptr) + return false; + state->window = window; + error_message.clear(); + return true; + }, + renderer_clear_backend_window, + renderer_noop_platform_windows, + opengl_cleanup, + renderer_noop_wait_idle, + opengl_imgui_init, + ImGui_ImplOpenGL3_Shutdown, + renderer_call_backend_new_frame, + renderer_framebuffer_size_changed, + renderer_set_framebuffer_size, + renderer_set_clear_color, + [](RendererState& renderer_state) { + if (RendererBackendState* state + = backend_state(renderer_state)) { + state->backup_context = platform_glfw_get_current_context(); + } + }, + [](RendererState& renderer_state) { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->backup_context == nullptr) + return; + platform_glfw_make_context_current(state->backup_context); + state->backup_context = nullptr; + }, + opengl_frame_render, + [](RendererState& renderer_state) { + RendererBackendState* state = backend_state( + renderer_state); + if (state == nullptr || state->window == nullptr) + return; + platform_glfw_swap_buffers(state->window); + }, + opengl_screen_capture, + }; + +} // namespace +} // namespace Imiv + +namespace Imiv { + +const RendererBackendVTable* +renderer_backend_opengl_vtable() +{ + return &k_opengl_vtable; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_renderer_vulkan.cpp b/src/imiv/imiv_renderer_vulkan.cpp new file mode 100644 index 0000000000..9dbb50f8f4 --- /dev/null +++ b/src/imiv/imiv_renderer_vulkan.cpp @@ -0,0 +1,380 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_renderer_backend.h" +#include "imiv_vulkan_types.h" + +#if IMIV_WITH_VULKAN + +# include "imiv_platform_glfw.h" +# include "imiv_viewer.h" + +# include + +# define GLFW_INCLUDE_NONE +# define GLFW_INCLUDE_VULKAN +# include + +namespace Imiv { +namespace { + + bool ensure_backend_state(RendererState& renderer_state) + { + if (renderer_state.backend != nullptr) + return true; + VulkanState* vk_state = new VulkanState(); + if (vk_state == nullptr) + return false; + vk_state->verbose_logging = renderer_state.verbose_logging; + vk_state->verbose_validation_output + = renderer_state.verbose_validation_output; + vk_state->log_imgui_texture_updates + = renderer_state.log_imgui_texture_updates; + renderer_state.backend = reinterpret_cast( + vk_state); + return renderer_state.backend != nullptr; + } + + VulkanState* ensure_vulkan_backend_state(RendererState& renderer_state, + std::string& error_message) + { + if (!ensure_backend_state(renderer_state)) { + error_message = "failed to allocate Vulkan renderer state"; + return nullptr; + } + return backend_state(renderer_state); + } + + VulkanState* vulkan_backend_state(RendererState& renderer_state, + std::string& error_message) + { + VulkanState* vk_state = backend_state(renderer_state); + if (vk_state == nullptr) + error_message = "Vulkan renderer state is unavailable"; + return vk_state; + } + + const VulkanState* vulkan_backend_state(const RendererState& renderer_state) + { + return backend_state(renderer_state); + } + + bool vulkan_get_viewer_texture_refs(const ViewerState& viewer, + const PlaceholderUiState& ui_state, + ImTextureRef& main_texture_ref, + bool& has_main_texture, + ImTextureRef& closeup_texture_ref, + bool& has_closeup_texture) + { + const VulkanTexture* texture = texture_backend_state( + viewer.texture); + if (texture == nullptr || !viewer.texture.preview_initialized) + return false; + + VkDescriptorSet main_set = ui_state.linear_interpolation + ? texture->set + : texture->nearest_mag_set; + if (main_set == VK_NULL_HANDLE) + main_set = texture->set; + if (main_set != VK_NULL_HANDLE) { + main_texture_ref = ImTextureRef(static_cast( + reinterpret_cast(main_set))); + has_main_texture = true; + } + + if (texture->pixelview_set != VK_NULL_HANDLE) { + closeup_texture_ref = ImTextureRef(static_cast( + reinterpret_cast(texture->pixelview_set))); + has_closeup_texture = true; + } else if (has_main_texture) { + closeup_texture_ref = main_texture_ref; + has_closeup_texture = true; + } + return has_main_texture || has_closeup_texture; + } + + bool vulkan_texture_is_loading(const RendererTexture& texture) + { + const VulkanTexture* vk_texture = texture_backend_state( + texture); + if (vk_texture == nullptr) + return false; + return vk_texture->upload_submit_pending + || vk_texture->preview_submit_pending + || (!texture.preview_initialized + && vk_texture->set != VK_NULL_HANDLE); + } + + bool vulkan_create_texture(RendererState& renderer_state, + const LoadedImage& image, + RendererTexture& texture, + std::string& error_message) + { + VulkanState* vk_state = ensure_vulkan_backend_state(renderer_state, + error_message); + if (vk_state == nullptr) + return false; + + VulkanTexture vk_texture; + if (!create_texture(*vk_state, image, vk_texture, error_message)) + return false; + + texture.backend = reinterpret_cast( + new VulkanTexture(std::move(vk_texture))); + if (texture.backend == nullptr) { + error_message = "failed to allocate Vulkan texture state"; + return false; + } + return true; + } + + void vulkan_destroy_texture(RendererState& renderer_state, + RendererTexture& texture) + { + VulkanState* vk_state = backend_state(renderer_state); + VulkanTexture* vk_texture = texture_backend_state( + texture); + if (vk_state != nullptr && vk_texture != nullptr) + retire_texture(*vk_state, *vk_texture); + delete vk_texture; + } + + bool vulkan_update_preview_texture(RendererState& renderer_state, + RendererTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) + { + VulkanState* vk_state = vulkan_backend_state(renderer_state, + error_message); + VulkanTexture* vk_texture = texture_backend_state( + texture); + if (vk_state == nullptr || vk_texture == nullptr) { + if (vk_texture == nullptr) + error_message = "Vulkan renderer state is unavailable"; + return false; + } + const bool ok = update_preview_texture(*vk_state, *vk_texture, image, + ui_state, controls, + error_message); + texture.preview_initialized = vk_texture->preview_initialized; + return ok; + } + + bool vulkan_quiesce_texture_preview_submission(RendererState& renderer_state, + RendererTexture& texture, + std::string& error_message) + { + VulkanState* vk_state = backend_state(renderer_state); + VulkanTexture* vk_texture = texture_backend_state( + texture); + if (vk_state == nullptr || vk_texture == nullptr) { + error_message.clear(); + return true; + } + return quiesce_texture_preview_submission(*vk_state, *vk_texture, + error_message); + } + + bool vulkan_setup_instance(RendererState& renderer_state, + ImVector& instance_extensions, + std::string& error_message) + { + VulkanState* vk_state = ensure_vulkan_backend_state(renderer_state, + error_message); + return vk_state != nullptr + && setup_vulkan_instance(*vk_state, instance_extensions, + error_message); + } + + bool vulkan_wait_idle(RendererState& renderer_state, + std::string& error_message) + { + VulkanState* vk_state = backend_state(renderer_state); + if (vk_state == nullptr || vk_state->device == VK_NULL_HANDLE) { + error_message.clear(); + return true; + } + const VkResult err = vkDeviceWaitIdle(vk_state->device); + if (err == VK_SUCCESS) { + drain_retired_textures(*vk_state, true); + error_message.clear(); + return true; + } + check_vk_result(err); + error_message = "renderer wait idle failed"; + return false; + } + + bool vulkan_imgui_init(RendererState& renderer_state, + std::string& error_message) + { + VulkanState* vk_state = vulkan_backend_state(renderer_state, + error_message); + if (vk_state == nullptr) + return false; + + ImGui_ImplVulkan_InitInfo init_info = {}; + init_info.ApiVersion = vk_state->api_version; + init_info.Instance = vk_state->instance; + init_info.PhysicalDevice = vk_state->physical_device; + init_info.Device = vk_state->device; + init_info.QueueFamily = vk_state->queue_family; + init_info.Queue = vk_state->queue; + init_info.PipelineCache = vk_state->pipeline_cache; + init_info.DescriptorPool = vk_state->descriptor_pool; + init_info.MinImageCount = vk_state->min_image_count; + init_info.ImageCount = vk_state->window_data.ImageCount; + init_info.Allocator = vk_state->allocator; + init_info.PipelineInfoMain.RenderPass = vk_state->window_data.RenderPass; + init_info.PipelineInfoMain.Subpass = 0; + init_info.PipelineInfoMain.MSAASamples = VK_SAMPLE_COUNT_1_BIT; + init_info.CheckVkResultFn = check_vk_result; + if (ImGui_ImplVulkan_Init(&init_info)) { + error_message.clear(); + return true; + } + error_message = "ImGui_ImplVulkan_Init failed"; + return false; + } + + bool vulkan_needs_main_window_resize(RendererState& renderer_state, + int width, int height) + { + const VulkanState* vk_state = vulkan_backend_state(renderer_state); + if (vk_state == nullptr) + return false; + return vk_state->swapchain_rebuild + || vk_state->window_data.Width != width + || vk_state->window_data.Height != height; + } + + void vulkan_resize_main_window(RendererState& renderer_state, int width, + int height) + { + VulkanState* vk_state = backend_state(renderer_state); + if (vk_state == nullptr) + return; + renderer_set_framebuffer_size(renderer_state, width, height); + ImGui_ImplVulkan_SetMinImageCount(vk_state->min_image_count); + ImGui_ImplVulkanH_CreateOrResizeWindow( + vk_state->instance, vk_state->physical_device, vk_state->device, + &vk_state->window_data, vk_state->queue_family, vk_state->allocator, + width, height, vk_state->min_image_count, + VK_IMAGE_USAGE_TRANSFER_SRC_BIT); + name_window_frame_objects(*vk_state); + vk_state->window_data.FrameIndex = 0; + vk_state->swapchain_rebuild = false; + } + + void vulkan_set_main_clear_color(RendererState& renderer_state, float r, + float g, float b, float a) + { + VulkanState* vk_state = backend_state(renderer_state); + if (vk_state == nullptr) + return; + vk_state->window_data.ClearValue.color.float32[0] = r; + vk_state->window_data.ClearValue.color.float32[1] = g; + vk_state->window_data.ClearValue.color.float32[2] = b; + vk_state->window_data.ClearValue.color.float32[3] = a; + } + + const RendererBackendVTable k_vulkan_vtable = { + BackendKind::Vulkan, + platform_glfw_supports_vulkan, + vulkan_get_viewer_texture_refs, + vulkan_texture_is_loading, + vulkan_create_texture, + vulkan_destroy_texture, + vulkan_update_preview_texture, + vulkan_quiesce_texture_preview_submission, + vulkan_setup_instance, + [](RendererState& renderer_state, std::string& error_message) { + VulkanState* vk_state = vulkan_backend_state(renderer_state, + error_message); + return vk_state != nullptr + && setup_vulkan_device(*vk_state, error_message); + }, + [](RendererState& renderer_state, int width, int height, + std::string& error_message) { + VulkanState* vk_state = vulkan_backend_state(renderer_state, + error_message); + if (vk_state == nullptr) + return false; + renderer_set_framebuffer_size(renderer_state, width, height); + return setup_vulkan_window(*vk_state, width, height, error_message); + }, + [](RendererState& renderer_state, GLFWwindow* window, + std::string& error_message) { + VulkanState* vk_state = vulkan_backend_state(renderer_state, + error_message); + if (vk_state == nullptr) + return false; + const VkResult err = glfwCreateWindowSurface(vk_state->instance, + window, + vk_state->allocator, + &vk_state->surface); + if (err == VK_SUCCESS) { + error_message.clear(); + return true; + } + check_vk_result(err); + error_message = "glfwCreateWindowSurface failed"; + return false; + }, + [](RendererState& renderer_state) { + if (VulkanState* vk_state = backend_state( + renderer_state)) { + destroy_vulkan_surface(*vk_state); + } + }, + [](RendererState& renderer_state) { + if (VulkanState* vk_state = backend_state( + renderer_state)) { + cleanup_vulkan_window(*vk_state); + } + }, + [](RendererState& renderer_state) { + VulkanState* vk_state = backend_state(renderer_state); + if (vk_state != nullptr) + cleanup_vulkan(*vk_state); + delete vk_state; + renderer_state.backend = nullptr; + }, + vulkan_wait_idle, + vulkan_imgui_init, + ImGui_ImplVulkan_Shutdown, + renderer_call_backend_new_frame, + vulkan_needs_main_window_resize, + vulkan_resize_main_window, + vulkan_set_main_clear_color, + renderer_noop_platform_windows, + renderer_noop_platform_windows, + [](RendererState& renderer_state, ImDrawData* draw_data) { + if (VulkanState* vk_state = backend_state( + renderer_state)) { + frame_render(*vk_state, draw_data); + } + }, + [](RendererState& renderer_state) { + if (VulkanState* vk_state = backend_state( + renderer_state)) { + frame_present(*vk_state); + } + }, + imiv_vulkan_screen_capture, + }; + +} // namespace + +const RendererBackendVTable* +renderer_backend_vulkan_vtable() +{ + return &k_vulkan_vtable; +} + +} // namespace Imiv + +#endif diff --git a/src/imiv/imiv_shader_compile.cpp b/src/imiv/imiv_shader_compile.cpp new file mode 100644 index 0000000000..179b1572bd --- /dev/null +++ b/src/imiv/imiv_shader_compile.cpp @@ -0,0 +1,182 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_shader_compile.h" + +#if defined(IMIV_HAS_GLSLANG_RUNTIME) && IMIV_HAS_GLSLANG_RUNTIME +# include +# include +#endif + +namespace Imiv { + +namespace { + +#if defined(IMIV_HAS_GLSLANG_RUNTIME) && IMIV_HAS_GLSLANG_RUNTIME + + glslang_stage_t map_shader_stage(RuntimeShaderStage stage) + { + switch (stage) { + case RuntimeShaderStage::Vertex: return GLSLANG_STAGE_VERTEX; + case RuntimeShaderStage::Fragment: return GLSLANG_STAGE_FRAGMENT; + case RuntimeShaderStage::Compute: return GLSLANG_STAGE_COMPUTE; + default: return GLSLANG_STAGE_FRAGMENT; + } + } + + bool append_glslang_log(const char* text, std::string& dst) + { + if (text == nullptr || text[0] == '\0') + return false; + if (!dst.empty()) + dst += "\n"; + dst += text; + return true; + } + +#endif + +} // namespace + +bool +compile_glsl_to_spirv(RuntimeShaderStage stage, const std::string& source_code, + const char* debug_name, + std::vector& spirv_words, + std::string& error_message) +{ + spirv_words.clear(); + error_message.clear(); + +#if !(defined(IMIV_HAS_GLSLANG_RUNTIME) && IMIV_HAS_GLSLANG_RUNTIME) + (void)stage; + (void)source_code; + (void)debug_name; + error_message = "runtime GLSL compiler is unavailable in this build"; + return false; +#else + if (source_code.empty()) { + error_message = "runtime GLSL compiler received empty source"; + return false; + } + + if (!glslang_initialize_process()) { + error_message = "glslang_initialize_process failed"; + return false; + } + + const glslang_resource_t* resources = glslang_default_resource(); + glslang_input_t input = {}; + input.language = GLSLANG_SOURCE_GLSL; + input.stage = map_shader_stage(stage); + input.client = GLSLANG_CLIENT_VULKAN; + input.client_version = GLSLANG_TARGET_VULKAN_1_2; + input.target_language = GLSLANG_TARGET_SPV; + input.target_language_version = GLSLANG_TARGET_SPV_1_5; + input.code = source_code.c_str(); + input.default_version = 460; + input.default_profile = GLSLANG_CORE_PROFILE; + input.force_default_version_and_profile = 0; + input.forward_compatible = 0; + input.messages = static_cast( + GLSLANG_MSG_SPV_RULES_BIT | GLSLANG_MSG_VULKAN_RULES_BIT); + input.resource = resources; + + glslang_shader_t* shader = glslang_shader_create(&input); + if (shader == nullptr) { + glslang_finalize_process(); + error_message = "glslang_shader_create failed"; + return false; + } + + bool ok = true; + if (!glslang_shader_preprocess(shader, &input)) + ok = false; + if (ok && !glslang_shader_parse(shader, &input)) + ok = false; + if (!ok) { + if (debug_name != nullptr && debug_name[0] != '\0') { + error_message += debug_name; + error_message += ": "; + } + error_message += "GLSL preprocess/parse failed"; + append_glslang_log(glslang_shader_get_info_log(shader), error_message); + append_glslang_log(glslang_shader_get_info_debug_log(shader), + error_message); + glslang_shader_delete(shader); + glslang_finalize_process(); + return false; + } + + glslang_program_t* program = glslang_program_create(); + if (program == nullptr) { + glslang_shader_delete(shader); + glslang_finalize_process(); + error_message = "glslang_program_create failed"; + return false; + } + glslang_program_add_shader(program, shader); + + ok = glslang_program_link(program, static_cast( + GLSLANG_MSG_SPV_RULES_BIT + | GLSLANG_MSG_VULKAN_RULES_BIT)) + != 0; + if (ok) + ok = glslang_program_map_io(program) != 0; + if (!ok) { + if (debug_name != nullptr && debug_name[0] != '\0') { + error_message += debug_name; + error_message += ": "; + } + error_message += "GLSL program link/map failed"; + append_glslang_log(glslang_program_get_info_log(program), + error_message); + append_glslang_log(glslang_program_get_info_debug_log(program), + error_message); + glslang_program_delete(program); + glslang_shader_delete(shader); + glslang_finalize_process(); + return false; + } + + glslang_spv_options_t spv_options = {}; + spv_options.generate_debug_info = false; + spv_options.strip_debug_info = true; + spv_options.disable_optimizer = false; + spv_options.optimize_size = false; + spv_options.disassemble = false; + spv_options.validate = true; + spv_options.emit_nonsemantic_shader_debug_info = false; + spv_options.emit_nonsemantic_shader_debug_source = false; + spv_options.compile_only = false; + spv_options.optimize_allow_expanded_id_bound = false; + glslang_program_SPIRV_generate_with_options(program, input.stage, + &spv_options); + + const size_t word_count = glslang_program_SPIRV_get_size(program); + if (word_count == 0) { + if (debug_name != nullptr && debug_name[0] != '\0') { + error_message += debug_name; + error_message += ": "; + } + error_message += "SPIR-V generation produced no output"; + append_glslang_log(glslang_program_SPIRV_get_messages(program), + error_message); + glslang_program_delete(program); + glslang_shader_delete(shader); + glslang_finalize_process(); + return false; + } + + spirv_words.resize(word_count); + glslang_program_SPIRV_get(program, spirv_words.data()); + append_glslang_log(glslang_program_SPIRV_get_messages(program), + error_message); + glslang_program_delete(program); + glslang_shader_delete(shader); + glslang_finalize_process(); + return true; +#endif +} + +} // namespace Imiv diff --git a/src/imiv/imiv_shader_compile.h b/src/imiv/imiv_shader_compile.h new file mode 100644 index 0000000000..ed95a44773 --- /dev/null +++ b/src/imiv/imiv_shader_compile.h @@ -0,0 +1,25 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include +#include + +namespace Imiv { + +enum class RuntimeShaderStage : uint8_t { + Vertex = 0, + Fragment = 1, + Compute = 2 +}; + +bool +compile_glsl_to_spirv(RuntimeShaderStage stage, const std::string& source_code, + const char* debug_name, + std::vector& spirv_words, + std::string& error_message); + +} // namespace Imiv diff --git a/src/imiv/imiv_style.cpp b/src/imiv/imiv_style.cpp new file mode 100644 index 0000000000..f230efa138 --- /dev/null +++ b/src/imiv/imiv_style.cpp @@ -0,0 +1,234 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_style.h" + +#include + +namespace Imiv { + +namespace { + + ImVec4 lerp_color(const ImVec4& a, const ImVec4& b, float t) + { + return ImVec4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, + a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t); + } + + ImVec4 scale_color_alpha(const ImVec4& c, float alpha) + { + return ImVec4(c.x, c.y, c.z, c.w * alpha); + } + +} // namespace + +AppStylePreset +sanitize_app_style_preset(int preset_value) +{ + if (preset_value < static_cast(AppStylePreset::IvLight) + || preset_value > static_cast(AppStylePreset::ImGuiClassic)) { + return AppStylePreset::ImGuiDark; + } + return static_cast(preset_value); +} + +const char* +app_style_preset_name(AppStylePreset preset) +{ + switch (preset) { + case AppStylePreset::IvLight: return "IV Light"; + case AppStylePreset::IvDark: return "IV Dark"; + case AppStylePreset::ImGuiLight: return "ImGui Light"; + case AppStylePreset::ImGuiDark: return "ImGui Dark"; + case AppStylePreset::ImGuiClassic: return "ImGui Classic"; + } + return "ImGui Dark"; +} + +void +apply_imgui_style_defaults() +{ + ImGuiStyle& style = ImGui::GetStyle(); + style.WindowBorderSize = 0.0f; + style.ChildBorderSize = 0.0f; + style.PopupBorderSize = 0.0f; + style.FrameBorderSize = 0.0f; +} + +void +apply_imgui_color_style(AppStylePreset preset) +{ + switch (preset) { + case AppStylePreset::IvLight: StyleColorsIvLight(); break; + case AppStylePreset::IvDark: StyleColorsIvDark(); break; + case AppStylePreset::ImGuiLight: ImGui::StyleColorsLight(); break; + case AppStylePreset::ImGuiDark: ImGui::StyleColorsDark(); break; + case AppStylePreset::ImGuiClassic: ImGui::StyleColorsClassic(); break; + } +} + +void +apply_imgui_app_style(AppStylePreset preset) +{ + apply_imgui_color_style(preset); + apply_imgui_style_defaults(); +} + +void +StyleColorsIvDark() +{ + ImGuiStyle* style = &ImGui::GetStyle(); + ImVec4* colors = style->Colors; + + colors[ImGuiCol_Text] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImGuiCol_TextDisabled] = ImVec4(0.50f, 0.50f, 0.50f, 1.00f); + colors[ImGuiCol_WindowBg] = ImVec4(0.06f, 0.06f, 0.06f, 0.94f); + colors[ImGuiCol_ChildBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_PopupBg] = ImVec4(0.08f, 0.08f, 0.08f, 0.94f); + colors[ImGuiCol_Border] = ImVec4(0.50f, 0.43f, 0.43f, 0.50f); + colors[ImGuiCol_BorderShadow] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_FrameBg] = ImVec4(0.48f, 0.29f, 0.16f, 0.54f); + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.98f, 0.59f, 0.26f, 0.40f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.98f, 0.59f, 0.26f, 0.67f); + colors[ImGuiCol_TitleBg] = ImVec4(0.04f, 0.04f, 0.04f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.48f, 0.29f, 0.16f, 1.00f); + colors[ImGuiCol_TitleBgCollapsed] = ImVec4(0.00f, 0.00f, 0.00f, 0.51f); + colors[ImGuiCol_MenuBarBg] = ImVec4(0.14f, 0.14f, 0.14f, 1.00f); + colors[ImGuiCol_ScrollbarBg] = ImVec4(0.02f, 0.02f, 0.02f, 0.53f); + colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.31f, 0.31f, 0.31f, 1.00f); + colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.41f, 0.41f, 0.41f, 1.00f); + colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.51f, 0.51f, 0.51f, 1.00f); + colors[ImGuiCol_CheckMark] = ImVec4(0.98f, 0.44f, 0.26f, 1.00f); + colors[ImGuiCol_SliderGrab] = ImVec4(0.88f, 0.39f, 0.24f, 1.00f); + colors[ImGuiCol_SliderGrabActive] = ImVec4(0.98f, 0.44f, 0.26f, 1.00f); + colors[ImGuiCol_Button] = ImVec4(0.98f, 0.44f, 0.26f, 0.40f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.98f, 0.44f, 0.26f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.98f, 0.39f, 0.06f, 1.00f); + colors[ImGuiCol_Header] = ImVec4(0.98f, 0.44f, 0.26f, 0.31f); + colors[ImGuiCol_HeaderHovered] = ImVec4(0.98f, 0.44f, 0.26f, 0.80f); + colors[ImGuiCol_HeaderActive] = ImVec4(0.98f, 0.44f, 0.26f, 1.00f); + colors[ImGuiCol_Separator] = colors[ImGuiCol_Border]; + colors[ImGuiCol_SeparatorHovered] = ImVec4(0.75f, 0.30f, 0.10f, 0.78f); + colors[ImGuiCol_SeparatorActive] = ImVec4(0.75f, 0.30f, 0.10f, 1.00f); + colors[ImGuiCol_ResizeGrip] = ImVec4(0.98f, 0.44f, 0.26f, 0.20f); + colors[ImGuiCol_ResizeGripHovered] = ImVec4(0.98f, 0.44f, 0.26f, 0.67f); + colors[ImGuiCol_ResizeGripActive] = ImVec4(0.98f, 0.44f, 0.26f, 0.95f); + colors[ImGuiCol_InputTextCursor] = colors[ImGuiCol_Text]; + colors[ImGuiCol_TabHovered] = colors[ImGuiCol_HeaderHovered]; + colors[ImGuiCol_Tab] = lerp_color(colors[ImGuiCol_Header], + colors[ImGuiCol_TitleBgActive], 0.80f); + colors[ImGuiCol_TabSelected] = lerp_color(colors[ImGuiCol_HeaderActive], + colors[ImGuiCol_TitleBgActive], + 0.60f); + colors[ImGuiCol_TabSelectedOverline] = colors[ImGuiCol_HeaderActive]; + colors[ImGuiCol_TabDimmed] = lerp_color(colors[ImGuiCol_Tab], + colors[ImGuiCol_TitleBg], 0.80f); + colors[ImGuiCol_TabDimmedSelected] + = lerp_color(colors[ImGuiCol_TabSelected], colors[ImGuiCol_TitleBg], + 0.40f); + colors[ImGuiCol_TabDimmedSelectedOverline] = ImVec4(0.50f, 0.50f, 0.50f, + 0.00f); + colors[ImGuiCol_DockingPreview] + = scale_color_alpha(colors[ImGuiCol_HeaderActive], 0.7f); + colors[ImGuiCol_DockingEmptyBg] = ImVec4(0.20f, 0.20f, 0.20f, 1.00f); + colors[ImGuiCol_PlotLines] = ImVec4(0.61f, 0.61f, 0.61f, 1.00f); + colors[ImGuiCol_PlotLinesHovered] = ImVec4(1.00f, 0.43f, 0.35f, 1.00f); + colors[ImGuiCol_PlotHistogram] = ImVec4(0.90f, 0.70f, 0.00f, 1.00f); + colors[ImGuiCol_PlotHistogramHovered] = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); + colors[ImGuiCol_TableHeaderBg] = ImVec4(0.19f, 0.19f, 0.20f, 1.00f); + colors[ImGuiCol_TableBorderStrong] = ImVec4(0.31f, 0.31f, 0.35f, 1.00f); + colors[ImGuiCol_TableBorderLight] = ImVec4(0.23f, 0.23f, 0.25f, 1.00f); + colors[ImGuiCol_TableRowBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_TableRowBgAlt] = ImVec4(1.00f, 1.00f, 1.00f, 0.06f); + colors[ImGuiCol_TextLink] = colors[ImGuiCol_HeaderActive]; + colors[ImGuiCol_TextSelectedBg] = ImVec4(0.26f, 0.59f, 0.98f, 0.35f); + colors[ImGuiCol_TreeLines] = colors[ImGuiCol_Border]; + colors[ImGuiCol_DragDropTarget] = ImVec4(1.00f, 1.00f, 0.00f, 0.90f); + colors[ImGuiCol_DragDropTargetBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_UnsavedMarker] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImGuiCol_NavCursor] = ImVec4(0.26f, 0.59f, 0.98f, 1.00f); + colors[ImGuiCol_NavWindowingHighlight] = ImVec4(1.00f, 1.00f, 1.00f, 0.70f); + colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.80f, 0.80f, 0.80f, 0.20f); + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.80f, 0.80f, 0.80f, 0.35f); +} + +void +StyleColorsIvLight() +{ + ImGuiStyle* style = &ImGui::GetStyle(); + ImVec4* colors = style->Colors; + + colors[ImGuiCol_Text] = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + colors[ImGuiCol_TextDisabled] = ImVec4(0.60f, 0.60f, 0.60f, 1.00f); + colors[ImGuiCol_WindowBg] = ImVec4(0.94f, 0.94f, 0.94f, 1.00f); + colors[ImGuiCol_ChildBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_PopupBg] = ImVec4(1.00f, 1.00f, 1.00f, 0.98f); + colors[ImGuiCol_Border] = ImVec4(0.00f, 0.00f, 0.00f, 0.30f); + colors[ImGuiCol_BorderShadow] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_FrameBg] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.98f, 0.59f, 0.26f, 0.40f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.98f, 0.59f, 0.26f, 0.67f); + colors[ImGuiCol_TitleBg] = ImVec4(0.96f, 0.96f, 0.96f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.82f, 0.82f, 0.82f, 1.00f); + colors[ImGuiCol_TitleBgCollapsed] = ImVec4(1.00f, 1.00f, 1.00f, 0.51f); + colors[ImGuiCol_MenuBarBg] = ImVec4(0.86f, 0.86f, 0.86f, 1.00f); + colors[ImGuiCol_ScrollbarBg] = ImVec4(0.98f, 0.98f, 0.98f, 0.53f); + colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.69f, 0.69f, 0.69f, 0.80f); + colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.49f, 0.49f, 0.49f, 0.80f); + colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.49f, 0.49f, 0.49f, 1.00f); + colors[ImGuiCol_CheckMark] = ImVec4(0.98f, 0.59f, 0.26f, 1.00f); + colors[ImGuiCol_SliderGrab] = ImVec4(0.98f, 0.59f, 0.26f, 0.78f); + colors[ImGuiCol_SliderGrabActive] = ImVec4(0.80f, 0.54f, 0.46f, 0.60f); + colors[ImGuiCol_Button] = ImVec4(0.98f, 0.59f, 0.26f, 0.40f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.98f, 0.59f, 0.26f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.98f, 0.53f, 0.06f, 1.00f); + colors[ImGuiCol_Header] = ImVec4(0.98f, 0.59f, 0.26f, 0.31f); + colors[ImGuiCol_HeaderHovered] = ImVec4(0.98f, 0.59f, 0.26f, 0.80f); + colors[ImGuiCol_HeaderActive] = ImVec4(0.98f, 0.59f, 0.26f, 1.00f); + colors[ImGuiCol_Separator] = ImVec4(0.39f, 0.39f, 0.39f, 0.62f); + colors[ImGuiCol_SeparatorHovered] = ImVec4(0.80f, 0.44f, 0.14f, 0.78f); + colors[ImGuiCol_SeparatorActive] = ImVec4(0.80f, 0.44f, 0.14f, 1.00f); + colors[ImGuiCol_ResizeGrip] = ImVec4(0.35f, 0.35f, 0.35f, 0.17f); + colors[ImGuiCol_ResizeGripHovered] = ImVec4(0.98f, 0.59f, 0.26f, 0.67f); + colors[ImGuiCol_ResizeGripActive] = ImVec4(0.98f, 0.59f, 0.26f, 0.95f); + colors[ImGuiCol_InputTextCursor] = colors[ImGuiCol_Text]; + colors[ImGuiCol_TabHovered] = colors[ImGuiCol_HeaderHovered]; + colors[ImGuiCol_Tab] = lerp_color(colors[ImGuiCol_Header], + colors[ImGuiCol_TitleBgActive], 0.90f); + colors[ImGuiCol_TabSelected] = lerp_color(colors[ImGuiCol_HeaderActive], + colors[ImGuiCol_TitleBgActive], + 0.60f); + colors[ImGuiCol_TabSelectedOverline] = colors[ImGuiCol_HeaderActive]; + colors[ImGuiCol_TabDimmed] = lerp_color(colors[ImGuiCol_Tab], + colors[ImGuiCol_TitleBg], 0.80f); + colors[ImGuiCol_TabDimmedSelected] + = lerp_color(colors[ImGuiCol_TabSelected], colors[ImGuiCol_TitleBg], + 0.40f); + colors[ImGuiCol_TabDimmedSelectedOverline] = ImVec4(0.26f, 0.59f, 1.00f, + 0.00f); + colors[ImGuiCol_DockingPreview] = scale_color_alpha(colors[ImGuiCol_Header], + 0.7f); + colors[ImGuiCol_DockingEmptyBg] = ImVec4(0.20f, 0.20f, 0.20f, 1.00f); + colors[ImGuiCol_PlotLines] = ImVec4(0.39f, 0.39f, 0.39f, 1.00f); + colors[ImGuiCol_PlotLinesHovered] = ImVec4(1.00f, 0.43f, 0.35f, 1.00f); + colors[ImGuiCol_PlotHistogram] = ImVec4(0.90f, 0.70f, 0.00f, 1.00f); + colors[ImGuiCol_PlotHistogramHovered] = ImVec4(1.00f, 0.45f, 0.00f, 1.00f); + colors[ImGuiCol_TableHeaderBg] = ImVec4(0.78f, 0.87f, 0.98f, 1.00f); + colors[ImGuiCol_TableBorderStrong] = ImVec4(0.57f, 0.57f, 0.64f, 1.00f); + colors[ImGuiCol_TableBorderLight] = ImVec4(0.68f, 0.68f, 0.74f, 1.00f); + colors[ImGuiCol_TableRowBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_TableRowBgAlt] = ImVec4(0.30f, 0.30f, 0.30f, 0.09f); + colors[ImGuiCol_TextLink] = colors[ImGuiCol_HeaderActive]; + colors[ImGuiCol_TextSelectedBg] = ImVec4(0.26f, 0.59f, 0.98f, 0.35f); + colors[ImGuiCol_TreeLines] = colors[ImGuiCol_Border]; + colors[ImGuiCol_DragDropTarget] = ImVec4(0.26f, 0.59f, 0.98f, 0.95f); + colors[ImGuiCol_DragDropTargetBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_UnsavedMarker] = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + colors[ImGuiCol_NavCursor] = colors[ImGuiCol_HeaderHovered]; + colors[ImGuiCol_NavWindowingHighlight] = ImVec4(0.70f, 0.70f, 0.70f, 0.70f); + colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.20f, 0.20f, 0.20f, 0.20f); + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.20f, 0.20f, 0.20f, 0.35f); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_style.h b/src/imiv/imiv_style.h new file mode 100644 index 0000000000..44dd257286 --- /dev/null +++ b/src/imiv/imiv_style.h @@ -0,0 +1,32 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +namespace Imiv { + +enum class AppStylePreset : int { + IvLight = 0, + IvDark = 1, + ImGuiLight = 2, + ImGuiDark = 3, + ImGuiClassic = 4 +}; + +AppStylePreset +sanitize_app_style_preset(int preset_value); +const char* +app_style_preset_name(AppStylePreset preset); +void +apply_imgui_style_defaults(); +void +apply_imgui_color_style(AppStylePreset preset); +void +apply_imgui_app_style(AppStylePreset preset); +void +StyleColorsIvDark(); +void +StyleColorsIvLight(); + +} // namespace Imiv diff --git a/src/imiv/imiv_test_engine.cpp b/src/imiv/imiv_test_engine.cpp new file mode 100644 index 0000000000..56157e0e81 --- /dev/null +++ b/src/imiv/imiv_test_engine.cpp @@ -0,0 +1,2057 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_test_engine.h" + +#include "imiv_parse.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#if USE_EXTERNAL_PUGIXML +# include "pugixml.hpp" +#else +# include +#endif + +#if defined(IMGUI_ENABLE_TEST_ENGINE) +# include +# include +# include +# include +#endif + +using namespace OIIO; + +namespace Imiv { +namespace { + + enum class TestEngineMouseTargetMode { + None, + Absolute, + WindowRel, + ImageRel + }; + + + + struct TestEngineSyntheticAction { + int delay_frames = 0; + int post_action_delay_frames = 0; + bool has_key_chord = false; + ImGuiKeyChord key_chord = 0; + std::string set_ref; + std::string item_click_ref; + std::string item_double_click_ref; + TestEngineMouseTargetMode mouse_target_mode + = TestEngineMouseTargetMode::None; + float mouse_x = 0.0f; + float mouse_y = 0.0f; + bool has_click = false; + int click_button = 0; + bool has_double_click = false; + int double_click_button = 0; + bool has_wheel = false; + float wheel_x = 0.0f; + float wheel_y = 0.0f; + bool has_drag = false; + float drag_dx = 0.0f; + float drag_dy = 0.0f; + int drag_button = 0; + bool has_hold_drag = false; + float hold_drag_dx = 0.0f; + float hold_drag_dy = 0.0f; + int hold_drag_button = 0; + int hold_drag_frames = 1; + }; + + + + struct TestEngineScenarioOcioStep { + bool has_use = false; + bool use_ocio = false; + bool has_display = false; + bool has_view = false; + bool has_image_color_space = false; + bool has_linear_interpolation = false; + bool linear_interpolation = false; + std::string display; + std::string view; + std::string image_color_space; + }; + + + + struct TestEngineScenarioImageListStep { + bool has_visible = false; + bool visible = false; + bool has_select_index = false; + int select_index = 0; + bool has_open_new_view_index = false; + int open_new_view_index = 0; + bool has_close_active_index = false; + int close_active_index = 0; + bool has_remove_index = false; + int remove_index = 0; + }; + + + + struct TestEngineScenarioViewStep { + bool has_activate_view_index = false; + int activate_view_index = 0; + bool has_renderer_backend = false; + std::string renderer_backend; + bool has_exposure = false; + float exposure = 0.0f; + bool has_gamma = false; + float gamma = 1.0f; + bool has_offset = false; + float offset = 0.0f; + }; + + + + struct TestEngineScenarioCaptureStep { + bool screenshot = false; + bool layout = false; + bool state = false; + bool layout_items = false; + int layout_depth = 8; + }; + + + + struct TestEngineScenarioStep { + std::string name; + TestEngineSyntheticAction action; + TestEngineScenarioOcioStep ocio; + TestEngineScenarioImageListStep image_list; + TestEngineScenarioViewStep view; + TestEngineScenarioCaptureStep capture; + }; + + + + struct TestEngineScenarioDefinition { + std::filesystem::path out_dir; + bool default_layout_items = false; + int default_layout_depth = 8; + std::vector steps; + }; + + + + bool parse_test_engine_key_chord(const std::string& value, + ImGuiKeyChord& out_chord); + + + + bool parse_float_pair_value(string_view value, float& out_a, float& out_b) + { + std::vector parts = Strutil::splits(value, ",", 2); + if (parts.size() != 2) + return false; + return parse_float_string(std::string(Strutil::strip(parts[0])), out_a) + && parse_float_string(std::string(Strutil::strip(parts[1])), + out_b); + } + + + + bool parse_bool_attr(const pugi::xml_attribute& attr, bool& out) + { + return attr && parse_bool_string(attr.as_string(), out); + } + + + + bool parse_int_attr(const pugi::xml_attribute& attr, int& out) + { + return attr && parse_int_string(attr.as_string(), out); + } + + + + bool parse_float_attr(const pugi::xml_attribute& attr, float& out) + { + return attr && parse_float_string(attr.as_string(), out); + } + + + + bool parse_float_pair_attr(const pugi::xml_attribute& attr, float& out_a, + float& out_b) + { + return attr && parse_float_pair_value(attr.as_string(), out_a, out_b); + } + + + + void set_process_env_value(const char* name, const std::string* value) + { + if (name == nullptr || name[0] == '\0') + return; +#if defined(_WIN32) + _putenv_s(name, value ? value->c_str() : ""); +#else + if (value) + setenv(name, value->c_str(), 1); + else + unsetenv(name); +#endif + } + + + + bool load_test_engine_scenario(const std::filesystem::path& path, + TestEngineScenarioDefinition& out_scenario, + std::string& error_message) + { + out_scenario = TestEngineScenarioDefinition {}; + error_message.clear(); + + pugi::xml_document doc; + const std::string path_string = path.string(); + const pugi::xml_parse_result result = doc.load_file( + path_string.c_str()); + if (!result) { + error_message + = Strutil::fmt::format("failed to parse scenario XML '{}': {}", + path.string(), result.description()); + return false; + } + + const pugi::xml_node root = doc.child("imiv-scenario"); + if (!root) { + error_message = Strutil::fmt::format( + "scenario file '{}' is missing root", + path.string()); + return false; + } + + const pugi::xml_attribute out_dir_attr = root.attribute("out_dir"); + if (!out_dir_attr || out_dir_attr.as_string()[0] == '\0') { + error_message = "scenario root requires non-empty out_dir attribute"; + return false; + } + out_scenario.out_dir = std::filesystem::path(out_dir_attr.as_string()); + + int layout_depth = out_scenario.default_layout_depth; + if (parse_int_attr(root.attribute("layout_depth"), layout_depth) + && layout_depth > 0) { + out_scenario.default_layout_depth = layout_depth; + } + bool layout_items = false; + if (parse_bool_attr(root.attribute("layout_items"), layout_items)) + out_scenario.default_layout_items = layout_items; + + for (pugi::xml_node step_node = root.child("step"); step_node; + step_node = step_node.next_sibling("step")) { + TestEngineScenarioStep step; + const pugi::xml_attribute name_attr = step_node.attribute("name"); + if (!name_attr || name_attr.as_string()[0] == '\0') { + error_message + = "scenario step is missing non-empty name attribute"; + return false; + } + step.name = name_attr.as_string(); + + parse_int_attr(step_node.attribute("delay_frames"), + step.action.delay_frames); + parse_int_attr(step_node.attribute("post_action_delay_frames"), + step.action.post_action_delay_frames); + + const pugi::xml_attribute key_chord_attr = step_node.attribute( + "key_chord"); + if (key_chord_attr && key_chord_attr.as_string()[0] != '\0') { + if (!parse_test_engine_key_chord(key_chord_attr.as_string(), + step.action.key_chord)) { + error_message = Strutil::fmt::format( + "scenario step '{}' has invalid key_chord '{}'", + step.name, key_chord_attr.as_string()); + return false; + } + step.action.has_key_chord = true; + } + + const pugi::xml_attribute item_click_attr = step_node.attribute( + "item_click"); + if (item_click_attr && item_click_attr.as_string()[0] != '\0') + step.action.item_click_ref = item_click_attr.as_string(); + const pugi::xml_attribute item_double_click_attr + = step_node.attribute("item_double_click"); + if (item_double_click_attr + && item_double_click_attr.as_string()[0] != '\0') + step.action.item_double_click_ref + = item_double_click_attr.as_string(); + const pugi::xml_attribute set_ref_attr = step_node.attribute( + "set_ref"); + if (set_ref_attr && set_ref_attr.as_string()[0] != '\0') + step.action.set_ref = set_ref_attr.as_string(); + + if (parse_float_pair_attr(step_node.attribute("mouse_pos"), + step.action.mouse_x, + step.action.mouse_y)) { + step.action.mouse_target_mode + = TestEngineMouseTargetMode::Absolute; + } else if (parse_float_pair_attr(step_node.attribute( + "mouse_pos_window_rel"), + step.action.mouse_x, + step.action.mouse_y)) { + step.action.mouse_target_mode + = TestEngineMouseTargetMode::WindowRel; + } else if (parse_float_pair_attr(step_node.attribute( + "mouse_pos_image_rel"), + step.action.mouse_x, + step.action.mouse_y)) { + step.action.mouse_target_mode + = TestEngineMouseTargetMode::ImageRel; + } + + if (parse_int_attr(step_node.attribute("mouse_click_button"), + step.action.click_button)) { + step.action.has_click = true; + } + if (parse_int_attr(step_node.attribute("mouse_double_click_button"), + step.action.double_click_button)) { + step.action.has_double_click = true; + } + if (parse_float_pair_attr(step_node.attribute("mouse_wheel"), + step.action.wheel_x, + step.action.wheel_y)) { + step.action.has_wheel = true; + } + if (parse_float_pair_attr(step_node.attribute("mouse_drag"), + step.action.drag_dx, + step.action.drag_dy)) { + step.action.has_drag = true; + parse_int_attr(step_node.attribute("mouse_drag_button"), + step.action.drag_button); + } + if (parse_float_pair_attr(step_node.attribute("mouse_drag_hold"), + step.action.hold_drag_dx, + step.action.hold_drag_dy)) { + step.action.has_hold_drag = true; + parse_int_attr(step_node.attribute("mouse_drag_hold_button"), + step.action.hold_drag_button); + parse_int_attr(step_node.attribute("mouse_drag_hold_frames"), + step.action.hold_drag_frames); + } + + if (parse_bool_attr(step_node.attribute("ocio_use"), + step.ocio.use_ocio)) { + step.ocio.has_use = true; + } + const pugi::xml_attribute ocio_display_attr = step_node.attribute( + "ocio_display"); + if (ocio_display_attr && ocio_display_attr.as_string()[0] != '\0') { + step.ocio.has_display = true; + step.ocio.display = ocio_display_attr.as_string(); + } + const pugi::xml_attribute ocio_view_attr = step_node.attribute( + "ocio_view"); + if (ocio_view_attr && ocio_view_attr.as_string()[0] != '\0') { + step.ocio.has_view = true; + step.ocio.view = ocio_view_attr.as_string(); + } + const pugi::xml_attribute ocio_cs_attr = step_node.attribute( + "ocio_image_color_space"); + if (ocio_cs_attr && ocio_cs_attr.as_string()[0] != '\0') { + step.ocio.has_image_color_space = true; + step.ocio.image_color_space = ocio_cs_attr.as_string(); + } + if (parse_bool_attr(step_node.attribute("linear_interpolation"), + step.ocio.linear_interpolation)) { + step.ocio.has_linear_interpolation = true; + } + if (parse_bool_attr(step_node.attribute("image_list_visible"), + step.image_list.visible)) { + step.image_list.has_visible = true; + } + if (parse_int_attr(step_node.attribute("image_list_select_index"), + step.image_list.select_index)) { + step.image_list.has_select_index = true; + } + if (parse_int_attr(step_node.attribute( + "image_list_open_new_view_index"), + step.image_list.open_new_view_index)) { + step.image_list.has_open_new_view_index = true; + } + if (parse_int_attr(step_node.attribute( + "image_list_close_active_index"), + step.image_list.close_active_index)) { + step.image_list.has_close_active_index = true; + } + if (parse_int_attr(step_node.attribute("image_list_remove_index"), + step.image_list.remove_index)) { + step.image_list.has_remove_index = true; + } + if (parse_int_attr(step_node.attribute("view_activate_index"), + step.view.activate_view_index)) { + step.view.has_activate_view_index = true; + } + const pugi::xml_attribute renderer_backend_attr + = step_node.attribute("renderer_backend"); + if (renderer_backend_attr + && renderer_backend_attr.as_string()[0] != '\0') { + step.view.has_renderer_backend = true; + step.view.renderer_backend = renderer_backend_attr.as_string(); + } + if (parse_float_attr(step_node.attribute("exposure"), + step.view.exposure)) { + step.view.has_exposure = true; + } + if (parse_float_attr(step_node.attribute("gamma"), + step.view.gamma)) { + step.view.has_gamma = true; + } + if (parse_float_attr(step_node.attribute("offset"), + step.view.offset)) { + step.view.has_offset = true; + } + + parse_bool_attr(step_node.attribute("screenshot"), + step.capture.screenshot); + parse_bool_attr(step_node.attribute("layout"), step.capture.layout); + parse_bool_attr(step_node.attribute("state"), step.capture.state); + step.capture.layout_items = out_scenario.default_layout_items; + step.capture.layout_depth = out_scenario.default_layout_depth; + bool layout_items_override = false; + if (parse_bool_attr(step_node.attribute("layout_items"), + layout_items_override)) { + step.capture.layout_items = layout_items_override; + } + int layout_depth_override = step.capture.layout_depth; + if (parse_int_attr(step_node.attribute("layout_depth"), + layout_depth_override) + && layout_depth_override > 0) { + step.capture.layout_depth = layout_depth_override; + } + + out_scenario.steps.emplace_back(std::move(step)); + } + + if (out_scenario.steps.empty()) { + error_message = "scenario file does not contain any entries"; + return false; + } + return true; + } + + + + ImGuiKey parse_test_engine_key_token(const std::string& token) + { + if (token.size() == 1) { + const char c = token[0]; + if (c >= 'a' && c <= 'z') + return static_cast(ImGuiKey_A + (c - 'a')); + if (c >= '0' && c <= '9') + return static_cast(ImGuiKey_0 + (c - '0')); + } + if (token.size() >= 2 && token[0] == 'f') { + const char* digits = token.c_str() + 1; + char* end = nullptr; + const long index = std::strtol(digits, &end, 10); + if (end != digits && *end == '\0' && index >= 1 && index <= 12) + return static_cast(ImGuiKey_F1 + (index - 1)); + } + + if (token == "comma") + return ImGuiKey_Comma; + if (token == "period" || token == "dot") + return ImGuiKey_Period; + if (token == "equal" || token == "equals") + return ImGuiKey_Equal; + if (token == "minus" || token == "dash") + return ImGuiKey_Minus; + if (token == "leftbracket" || token == "[") + return ImGuiKey_LeftBracket; + if (token == "rightbracket" || token == "]") + return ImGuiKey_RightBracket; + if (token == "pageup") + return ImGuiKey_PageUp; + if (token == "pagedown") + return ImGuiKey_PageDown; + if (token == "escape" || token == "esc") + return ImGuiKey_Escape; + if (token == "delete" || token == "del") + return ImGuiKey_Delete; + if (token == "kp0" || token == "keypad0") + return ImGuiKey_Keypad0; + if (token == "kpdecimal" || token == "keypaddecimal") + return ImGuiKey_KeypadDecimal; + if (token == "kpadd" || token == "keypadadd") + return ImGuiKey_KeypadAdd; + if (token == "kpsubtract" || token == "keypadsubtract") + return ImGuiKey_KeypadSubtract; + + return ImGuiKey_None; + } + + + + bool parse_test_engine_key_chord(const std::string& value, + ImGuiKeyChord& out_chord) + { + const std::string trimmed = std::string(Strutil::strip(value)); + if (trimmed.empty()) + return false; + + ImGuiKeyChord chord = 0; + bool have_key = false; + size_t begin = 0; + while (begin <= trimmed.size()) { + const size_t plus = trimmed.find('+', begin); + const size_t end = (plus == std::string::npos) ? trimmed.size() + : plus; + const std::string token = Strutil::lower( + Strutil::strip(trimmed.substr(begin, end - begin))); + if (token.empty()) + return false; + + if (token == "ctrl" || token == "control") { + chord |= ImGuiMod_Ctrl; + } else if (token == "shift") { + chord |= ImGuiMod_Shift; + } else if (token == "alt") { + chord |= ImGuiMod_Alt; + } else if (token == "super" || token == "cmd" || token == "command" + || token == "win" || token == "meta") { + chord |= ImGuiMod_Super; + } else { + const ImGuiKey key = parse_test_engine_key_token(token); + if (key == ImGuiKey_None || have_key) + return false; + chord |= key; + have_key = true; + } + + if (plus == std::string::npos) + break; + begin = plus + 1; + } + + if (!have_key) + return false; + out_chord = chord; + return true; + } + + + + bool env_read_key_chord_value(const char* name, ImGuiKeyChord& out) + { + std::string value; + return read_env_value(name, value) + && parse_test_engine_key_chord(value, out); + } + + + + bool validate_test_output_path(const std::filesystem::path& path, + std::string& error_message) + { + error_message.clear(); + if (path.empty()) { + error_message = "output path is empty"; + return false; + } +#if defined(NDEBUG) + if (path.is_absolute()) { + error_message + = "absolute output paths are disabled in release builds"; + return false; + } +#endif + return true; + } + +#if defined(IMGUI_ENABLE_TEST_ENGINE) + int g_layout_dump_synthetic_item_counter = 0; + + + + struct TestEngineMouseSpaceState { + bool viewport_valid = false; + bool image_valid = false; + ImVec2 viewport_min = ImVec2(0.0f, 0.0f); + ImVec2 viewport_max = ImVec2(0.0f, 0.0f); + ImVec2 image_min = ImVec2(0.0f, 0.0f); + ImVec2 image_max = ImVec2(0.0f, 0.0f); + }; + + + + TestEngineMouseSpaceState g_test_engine_mouse_space; + TestEngineHooks g_test_engine_hooks; + + + + bool layout_dump_items_enabled() + { + return env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_ITEMS"); + } + + + + ImVec2 test_engine_rect_rel_pos(const ImVec2& rect_min, + const ImVec2& rect_max, float rel_x, + float rel_y) + { + const float clamped_x = std::clamp(rel_x, 0.0f, 1.0f); + const float clamped_y = std::clamp(rel_y, 0.0f, 1.0f); + return ImVec2(rect_min.x + (rect_max.x - rect_min.x) * clamped_x, + rect_min.y + (rect_max.y - rect_min.y) * clamped_y); + } + + + + bool resolve_test_engine_mouse_pos(ImVec2& out_pos) + { + float rel_x = 0.0f; + float rel_y = 0.0f; + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_IMAGE_REL_X", + rel_x) + && env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_IMAGE_REL_Y", + rel_y) + && g_test_engine_mouse_space.image_valid) { + out_pos + = test_engine_rect_rel_pos(g_test_engine_mouse_space.image_min, + g_test_engine_mouse_space.image_max, + rel_x, rel_y); + return true; + } + + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_WINDOW_REL_X", + rel_x) + && env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_WINDOW_REL_Y", + rel_y) + && g_test_engine_mouse_space.viewport_valid) { + out_pos = test_engine_rect_rel_pos( + g_test_engine_mouse_space.viewport_min, + g_test_engine_mouse_space.viewport_max, rel_x, rel_y); + return true; + } + + float mouse_x = 0.0f; + float mouse_y = 0.0f; + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_X", mouse_x) + && env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_Y", mouse_y)) { + out_pos = ImVec2(mouse_x, mouse_y); + return true; + } + return false; + } + + + + void mark_test_error(ImGuiTestContext* ctx) + { + if (ctx && ctx->TestOutput + && ctx->TestOutput->Status == ImGuiTestStatus_Running) { + ctx->TestOutput->Status = ImGuiTestStatus_Error; + } + } + + + + void json_write_escaped(FILE* f, const char* s) + { + std::fputc('"', f); + for (const unsigned char* p = reinterpret_cast( + s ? s : ""); + *p; ++p) { + const unsigned char c = *p; + switch (c) { + case '\\': std::fputs("\\\\", f); break; + case '"': std::fputs("\\\"", f); break; + case '\b': std::fputs("\\b", f); break; + case '\f': std::fputs("\\f", f); break; + case '\n': std::fputs("\\n", f); break; + case '\r': std::fputs("\\r", f); break; + case '\t': std::fputs("\\t", f); break; + default: + if (c < 0x20) + std::fprintf(f, "\\u%04x", static_cast(c)); + else + std::fputc(static_cast(c), f); + break; + } + } + std::fputc('"', f); + } + + + + void json_write_vec2(FILE* f, const ImVec2& v) + { + std::fprintf(f, "[%.3f,%.3f]", static_cast(v.x), + static_cast(v.y)); + } + + + + void json_write_rect(FILE* f, const ImRect& r) + { + std::fputs("{\"min\":", f); + json_write_vec2(f, r.Min); + std::fputs(",\"max\":", f); + json_write_vec2(f, r.Max); + std::fputs("}", f); + } + + + + bool write_layout_dump_json(ImGuiTestContext* ctx, + const std::filesystem::path& out_path, + bool include_items, int depth, + const std::vector* extra_windows + = nullptr) + { + if (depth <= 0) + depth = 1; + + std::string error_message; + if (!validate_test_output_path(out_path, error_message)) { + ctx->LogError("layout dump: %s", error_message.c_str()); + mark_test_error(ctx); + return false; + } + + std::error_code ec; + if (!out_path.parent_path().empty()) + std::filesystem::create_directories(out_path.parent_path(), ec); + + FILE* f = nullptr; +# if defined(_WIN32) + if (fopen_s(&f, out_path.string().c_str(), "wb") != 0) + f = nullptr; +# else + f = std::fopen(out_path.string().c_str(), "wb"); +# endif + if (!f) { + ctx->LogError("layout dump: failed to open output file: %s", + out_path.string().c_str()); + mark_test_error(ctx); + return false; + } + + ImGuiIO& io = ImGui::GetIO(); + struct WindowDumpEntry { + ImGuiTestItemInfo info; + }; + std::vector windows; + const char* image_window_title + = g_test_engine_hooks.image_window_title + ? g_test_engine_hooks.image_window_title + : "Image"; + const char* window_names[] = { + "##MainMenuBar", + image_window_title, + "iv Info", + "iv Preferences", + "iv Preview", + "Dear ImGui Demo", + "Dear ImGui Style Editor", + "Dear ImGui Metrics/Debugger", + "Dear ImGui Debug Log", + "Dear ImGui ID Stack Tool", + "About Dear ImGui", + }; + windows.reserve(IM_ARRAYSIZE(window_names)); + for (const char* window_name : window_names) { + ImGuiTestItemInfo win = ctx->WindowInfo(window_name, + ImGuiTestOpFlags_NoError); + if (win.ID == 0 || win.Window == nullptr) + continue; + bool duplicate = false; + for (const WindowDumpEntry& existing : windows) { + if (existing.info.Window == win.Window) { + duplicate = true; + break; + } + } + if (!duplicate) + windows.push_back({ win }); + } + if (extra_windows != nullptr) { + for (ImGuiWindow* extra_window : *extra_windows) { + if (extra_window == nullptr) + continue; + bool duplicate = false; + for (const WindowDumpEntry& existing : windows) { + if (existing.info.Window == extra_window) { + duplicate = true; + break; + } + } + if (duplicate) + continue; + windows.push_back({ ctx->ItemInfo(extra_window->ID, + ImGuiTestOpFlags_NoError) }); + } + } + if (windows.empty()) { + std::fclose(f); + ctx->LogError("layout dump: could not resolve any UI windows"); + mark_test_error(ctx); + return false; + } + + std::fputs("{\n", f); + std::fputs(" \"frame_count\": ", f); + std::fprintf(f, "%d,\n", ImGui::GetFrameCount()); + std::fputs(" \"display_size\": ", f); + json_write_vec2(f, io.DisplaySize); + std::fputs(",\n", f); + std::fputs(" \"windows\": [\n", f); + for (size_t wi = 0; wi < windows.size(); ++wi) { + if (wi > 0) + std::fputs(",\n", f); + ImGuiTestItemInfo& win = windows[wi].info; + std::fputs(" {\"name\": ", f); + json_write_escaped(f, win.Window->Name); + std::fputs(", \"id\": ", f); + std::fprintf(f, "%u", static_cast(win.Window->ID)); + std::fputs(", \"viewport_id\": ", f); + std::fprintf(f, "%u", + static_cast(win.Window->ViewportId)); + std::fputs(", \"pos\": ", f); + json_write_vec2(f, win.Window->Pos); + std::fputs(", \"size\": ", f); + json_write_vec2(f, win.Window->Size); + std::fputs(", \"rect\": ", f); + json_write_rect(f, win.Window->Rect()); + std::fputs(", \"collapsed\": ", f); + std::fputs(win.Window->Collapsed ? "true" : "false", f); + std::fputs(", \"active\": ", f); + std::fputs(win.Window->Active ? "true" : "false", f); + std::fputs(", \"was_active\": ", f); + std::fputs(win.Window->WasActive ? "true" : "false", f); + std::fputs(", \"hidden\": ", f); + std::fputs(win.Window->Hidden ? "true" : "false", f); + + if (include_items) { + std::fputs(", \"items\": [\n", f); + ImGuiTestItemList list; + list.Reserve(16384); + ctx->GatherItems(&list, ImGuiTestRef(win.Window->ID), depth); + + int emitted_items = 0; + for (int i = 0; i < list.GetSize(); ++i) { + const ImGuiTestItemInfo* item = list.GetByIndex(i); + if (item == nullptr || item->Window == nullptr) + continue; + if (emitted_items++ > 0) + std::fputs(",\n", f); + std::fputs(" {\"id\": ", f); + std::fprintf(f, "%u", static_cast(item->ID)); + std::fputs(", \"has_id\": ", f); + std::fputs(item->ID != 0 ? "true" : "false", f); + std::fputs(", \"parent_id\": ", f); + std::fprintf(f, "%u", + static_cast(item->ParentID)); + std::fputs(", \"depth\": ", f); + std::fprintf(f, "%d", static_cast(item->Depth)); + std::fputs(", \"debug\": ", f); + json_write_escaped(f, item->DebugLabel); + std::fputs(", \"rect_full\": ", f); + json_write_rect(f, item->RectFull); + std::fputs(", \"rect_clipped\": ", f); + json_write_rect(f, item->RectClipped); + std::fputs(", \"item_flags\": ", f); + std::fprintf(f, "%u", + static_cast(item->ItemFlags)); + std::fputs(", \"status_flags\": ", f); + std::fprintf(f, "%u", + static_cast(item->StatusFlags)); + std::fputs("}", f); + } + if (emitted_items > 0) + std::fputs("\n", f); + std::fputs(" ]", f); + } + std::fputs("}", f); + } + std::fputs("\n", f); + std::fputs(" ]\n}\n", f); + std::fflush(f); + std::fclose(f); + ctx->LogInfo("layout dump: wrote %s", out_path.string().c_str()); + return true; + } + + + + bool write_viewer_state_json(ImGuiTestContext* ctx, + const std::filesystem::path& out_path) + { + std::string error_message; + if (!validate_test_output_path(out_path, error_message)) { + ctx->LogError("state dump: %s", error_message.c_str()); + mark_test_error(ctx); + return false; + } + if (g_test_engine_hooks.write_viewer_state_json == nullptr) { + ctx->LogError("state dump: viewer state callback is unavailable"); + mark_test_error(ctx); + return false; + } + error_message.clear(); + if (!g_test_engine_hooks.write_viewer_state_json( + out_path, g_test_engine_hooks.write_viewer_state_user_data, + error_message)) { + ctx->LogError("state dump: %s", error_message.empty() + ? "viewer state dump failed" + : error_message.c_str()); + mark_test_error(ctx); + return false; + } + ctx->LogInfo("state dump: wrote %s", out_path.string().c_str()); + return true; + } + + + + bool capture_main_viewport_screenshot(ImGuiTestContext* ctx, + const char* out_file) + { + ctx->CaptureReset(); + if (out_file && out_file[0] != '\0') { + std::string error_message; + if (!validate_test_output_path(std::filesystem::path(out_file), + error_message)) { + ctx->LogError("screenshot: %s", error_message.c_str()); + mark_test_error(ctx); + return false; + } + std::snprintf(ctx->CaptureArgs->InOutputFile, + sizeof(ctx->CaptureArgs->InOutputFile), "%s", + out_file); + } + + ImGuiViewport* vp = ImGui::GetMainViewport(); + ctx->CaptureArgs->InCaptureRect.Min = vp->Pos; + ctx->CaptureArgs->InCaptureRect.Max = ImVec2(vp->Pos.x + vp->Size.x, + vp->Pos.y + vp->Size.y); + ctx->CaptureScreenshot(ImGuiCaptureFlags_Instant + | ImGuiCaptureFlags_HideMouseCursor); + return true; + } + + + + std::string sanitize_test_output_stem(const std::string& name) + { + std::string sanitized = name; + for (char& c : sanitized) { + const bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '_' || c == '-' + || c == '.'; + if (!keep) + c = '_'; + } + if (sanitized.empty()) + sanitized = "step"; + return sanitized; + } + + + + void apply_test_engine_scenario_overrides( + const TestEngineScenarioOcioStep& ocio, + const TestEngineScenarioImageListStep& image_list, + const TestEngineScenarioViewStep& view) + { + if (ocio.has_use) { + const std::string use_value = ocio.use_ocio ? "true" : "false"; + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_USE", + &use_value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_USE", nullptr); + } + if (ocio.has_display) { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_DISPLAY", + &ocio.display); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_DISPLAY", + nullptr); + } + if (ocio.has_view) { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_VIEW", + &ocio.view); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OCIO_VIEW", nullptr); + } + if (ocio.has_image_color_space) { + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_OCIO_IMAGE_COLOR_SPACE", + &ocio.image_color_space); + } else { + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_OCIO_IMAGE_COLOR_SPACE", nullptr); + } + if (ocio.has_linear_interpolation) { + const std::string value = ocio.linear_interpolation ? "1" : "0"; + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_LINEAR_INTERPOLATION", + &value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_LINEAR_INTERPOLATION", + nullptr); + } + + const int next_frame = ImGui::GetFrameCount() + 1; + if (image_list.has_visible || image_list.has_select_index + || image_list.has_open_new_view_index + || image_list.has_close_active_index + || image_list.has_remove_index) { + const std::string frame_value = Strutil::fmt::format("{}", + next_frame); + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_APPLY_FRAME", &frame_value); + } + if (image_list.has_visible) { + const std::string value = image_list.visible ? "1" : "0"; + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_VISIBLE", + &value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_VISIBLE", + nullptr); + } + if (image_list.has_select_index) { + const std::string index_value + = Strutil::fmt::format("{}", image_list.select_index); + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_SELECT_INDEX", &index_value); + } else { + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_SELECT_INDEX", nullptr); + } + if (image_list.has_open_new_view_index) { + const std::string index_value + = Strutil::fmt::format("{}", image_list.open_new_view_index); + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_OPEN_NEW_VIEW_INDEX", + &index_value); + } else { + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_OPEN_NEW_VIEW_INDEX", + nullptr); + } + if (image_list.has_close_active_index) { + const std::string index_value + = Strutil::fmt::format("{}", image_list.close_active_index); + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_CLOSE_ACTIVE_INDEX", + &index_value); + } else { + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_CLOSE_ACTIVE_INDEX", + nullptr); + } + if (image_list.has_remove_index) { + const std::string index_value + = Strutil::fmt::format("{}", image_list.remove_index); + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_REMOVE_INDEX", &index_value); + } else { + set_process_env_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_REMOVE_INDEX", nullptr); + } + + if (view.has_activate_view_index || view.has_renderer_backend + || view.has_exposure || view.has_gamma || view.has_offset) { + const std::string frame_value = Strutil::fmt::format("{}", + next_frame); + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_VIEW_APPLY_FRAME", + &frame_value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_VIEW_APPLY_FRAME", + nullptr); + } + if (view.has_activate_view_index) { + const std::string index_value + = Strutil::fmt::format("{}", view.activate_view_index); + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_ACTIVATE_VIEW_INDEX", + &index_value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_ACTIVATE_VIEW_INDEX", + nullptr); + } + if (view.has_renderer_backend) { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_RENDERER_BACKEND", + &view.renderer_backend); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_RENDERER_BACKEND", + nullptr); + } + if (view.has_exposure) { + const std::string exposure_value + = Strutil::fmt::format("{}", + static_cast(view.exposure)); + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_EXPOSURE", + &exposure_value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_EXPOSURE", nullptr); + } + if (view.has_gamma) { + const std::string gamma_value + = Strutil::fmt::format("{}", static_cast(view.gamma)); + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_GAMMA", &gamma_value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_GAMMA", nullptr); + } + if (view.has_offset) { + const std::string offset_value + = Strutil::fmt::format("{}", static_cast(view.offset)); + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OFFSET", + &offset_value); + } else { + set_process_env_value("IMIV_IMGUI_TEST_ENGINE_OFFSET", nullptr); + } + } + + + + bool write_test_engine_scenario_step_outputs( + ImGuiTestContext* ctx, const TestEngineScenarioDefinition& scenario, + const TestEngineScenarioStep& step) + { + const std::string stem = sanitize_test_output_stem(step.name); + const std::filesystem::path base_path = scenario.out_dir / stem; + + if (step.capture.screenshot) { + const std::filesystem::path screenshot_path = base_path.string() + + ".png"; + const std::string screenshot_string = screenshot_path.string(); + if (!capture_main_viewport_screenshot(ctx, + screenshot_string.c_str())) { + return false; + } + } + + if (step.capture.layout) { + const std::filesystem::path layout_path = base_path.string() + + ".layout.json"; + if (!write_layout_dump_json(ctx, layout_path, + step.capture.layout_items, + step.capture.layout_depth)) { + return false; + } + } + + if (step.capture.state) { + const std::filesystem::path state_path = base_path.string() + + ".state.json"; + if (!write_viewer_state_json(ctx, state_path)) + return false; + } + + return true; + } + + + + bool resolve_action_mouse_pos(const TestEngineSyntheticAction& action, + ImVec2& out_pos) + { + out_pos = ImVec2(0.0f, 0.0f); + switch (action.mouse_target_mode) { + case TestEngineMouseTargetMode::Absolute: + out_pos = ImVec2(action.mouse_x, action.mouse_y); + return true; + case TestEngineMouseTargetMode::WindowRel: + if (g_test_engine_mouse_space.viewport_valid) { + out_pos = test_engine_rect_rel_pos( + g_test_engine_mouse_space.viewport_min, + g_test_engine_mouse_space.viewport_max, action.mouse_x, + action.mouse_y); + return true; + } + return false; + case TestEngineMouseTargetMode::ImageRel: + if (g_test_engine_mouse_space.image_valid) { + out_pos = test_engine_rect_rel_pos( + g_test_engine_mouse_space.image_min, + g_test_engine_mouse_space.image_max, action.mouse_x, + action.mouse_y); + return true; + } + return false; + case TestEngineMouseTargetMode::None: + default: return false; + } + } + + + + int + apply_test_engine_synthetic_actions(ImGuiTestContext* ctx, + const TestEngineSyntheticAction& action) + { + int held_button = -1; + if (action.has_key_chord) { + ctx->KeyPress(action.key_chord); + ctx->Yield(1); + } + + if (!action.set_ref.empty()) { + ctx->SetRef(action.set_ref.c_str()); + ctx->Yield(1); + } + + if (!action.item_click_ref.empty()) { + ctx->ItemClick(action.item_click_ref.c_str()); + ctx->Yield(1); + } + + if (!action.item_double_click_ref.empty()) { + ctx->ItemDoubleClick(action.item_double_click_ref.c_str()); + ctx->Yield(1); + } + + ImVec2 mouse_pos(0.0f, 0.0f); + if (resolve_action_mouse_pos(action, mouse_pos)) { + ctx->MouseMoveToPos(mouse_pos); + ctx->Yield(1); + } + + if (action.has_click) { + const int click_button = std::clamp(action.click_button, 0, 4); + ctx->MouseClick(static_cast(click_button)); + ctx->Yield(1); + } + + if (action.has_double_click) { + const int click_button = std::clamp(action.double_click_button, 0, + 4); + ctx->MouseClick(static_cast(click_button)); + ctx->Yield(1); + ctx->MouseClick(static_cast(click_button)); + ctx->Yield(1); + } + + if (action.has_wheel) { + ctx->MouseWheel(ImVec2(action.wheel_x, action.wheel_y)); + ctx->Yield(1); + } + + if (action.has_drag) { + const int drag_button = std::clamp(action.drag_button, 0, 4); + const ImVec2 current_pos = ImGui::GetIO().MousePos; + ctx->MouseDown(static_cast(drag_button)); + ctx->Yield(1); + ctx->MouseMoveToPos(ImVec2(current_pos.x + action.drag_dx, + current_pos.y + action.drag_dy)); + ctx->Yield(1); + ctx->MouseUp(static_cast(drag_button)); + ctx->Yield(1); + } + if (action.has_hold_drag) { + const int drag_button = std::clamp(action.hold_drag_button, 0, 4); + const int hold_frames = std::max(0, action.hold_drag_frames); + ctx->MouseDown(static_cast(drag_button)); + ctx->Yield(1); + const ImVec2 current_pos = ImGui::GetIO().MousePos; + ctx->MouseMoveToPos(ImVec2(current_pos.x + action.hold_drag_dx, + current_pos.y + action.hold_drag_dy)); + if (hold_frames > 0) + ctx->Yield(hold_frames); + held_button = drag_button; + } + return held_button; + } + + + + int apply_test_engine_mouse_actions(ImGuiTestContext* ctx) + { + TestEngineSyntheticAction action; + ImGuiKeyChord key_chord = 0; + if (env_read_key_chord_value("IMIV_IMGUI_TEST_ENGINE_KEY_CHORD", + key_chord)) { + action.has_key_chord = true; + action.key_chord = key_chord; + } + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_IMAGE_REL_X", + action.mouse_x) + && env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_IMAGE_REL_Y", + action.mouse_y)) { + action.mouse_target_mode = TestEngineMouseTargetMode::ImageRel; + } else if (env_read_float_value( + "IMIV_IMGUI_TEST_ENGINE_MOUSE_WINDOW_REL_X", + action.mouse_x) + && env_read_float_value( + "IMIV_IMGUI_TEST_ENGINE_MOUSE_WINDOW_REL_Y", + action.mouse_y)) { + action.mouse_target_mode = TestEngineMouseTargetMode::WindowRel; + } else if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_X", + action.mouse_x) + && env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_Y", + action.mouse_y)) { + action.mouse_target_mode = TestEngineMouseTargetMode::Absolute; + } + if (env_read_int_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_CLICK_BUTTON", + action.click_button)) { + action.has_click = true; + } + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_WHEEL_X", + action.wheel_x) + || env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_WHEEL_Y", + action.wheel_y)) { + action.has_wheel = true; + } + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_DRAG_DX", + action.drag_dx) + || env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_DRAG_DY", + action.drag_dy)) { + action.has_drag = true; + env_read_int_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_DRAG_BUTTON", + action.drag_button); + } + if (env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_DRAG_DX", + action.hold_drag_dx) + || env_read_float_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_DRAG_DY", + action.hold_drag_dy)) { + action.has_hold_drag = true; + env_read_int_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_DRAG_BUTTON", + action.hold_drag_button); + action.hold_drag_frames + = env_int_value("IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_FRAMES", 1); + } + return apply_test_engine_synthetic_actions(ctx, action); + } + + + + void release_test_engine_held_mouse(ImGuiTestContext* ctx, int held_button) + { + if (held_button < 0) + return; + ctx->MouseUp(static_cast(held_button)); + ctx->Yield(1); + } + + + + void imiv_test_smoke_screenshot(ImGuiTestContext* ctx) + { + const int delay_frames = env_int_value( + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_DELAY_FRAMES", 3); + ctx->Yield(delay_frames); + const int held_button = apply_test_engine_mouse_actions(ctx); + const int post_action_delay + = env_int_value("IMIV_IMGUI_TEST_ENGINE_POST_ACTION_DELAY_FRAMES", + 0); + if (post_action_delay > 0) + ctx->Yield(post_action_delay); + + const int frames_to_capture + = env_int_value("IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_FRAMES", 1); + const bool save_all = env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_SAVE_ALL"); + + std::string out_value; + const bool has_out + = read_env_value("IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_OUT", + out_value) + && !out_value.empty(); + const char* out = has_out ? out_value.c_str() : nullptr; + + const bool capture_last_only = (!save_all && frames_to_capture > 1); + for (int i = 0; i < frames_to_capture; ++i) { + if (capture_last_only && i + 1 != frames_to_capture) { + ctx->Yield(1); + continue; + } + if (out) { + if (save_all && frames_to_capture > 1) { + const char* dot = std::strrchr(out, '.'); + char out_file[2048] = {}; + if (dot && dot != out) { + const size_t base_len = static_cast(dot - out); + std::snprintf(out_file, sizeof(out_file), + "%.*s_f%03d%s", + static_cast(base_len), out, i, dot); + } else { + std::snprintf(out_file, sizeof(out_file), + "%s_f%03d.png", out, i); + } + if (!capture_main_viewport_screenshot(ctx, out_file)) + return; + } else { + if (!capture_main_viewport_screenshot(ctx, out)) + return; + } + } else { + if (!capture_main_viewport_screenshot(ctx, nullptr)) + return; + } + std::string layout_out_value; + if (read_env_value( + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_LAYOUT_OUT", + layout_out_value) + && !layout_out_value.empty()) { + const bool include_items = env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_LAYOUT_ITEMS"); + int depth = env_int_value( + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_LAYOUT_DEPTH", 8); + if (depth <= 0) + depth = 1; + if (!write_layout_dump_json( + ctx, std::filesystem::path(layout_out_value), + include_items, depth)) { + release_test_engine_held_mouse(ctx, held_button); + return; + } + } + std::string state_out_value; + if (read_env_value( + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_STATE_OUT", + state_out_value) + && !state_out_value.empty()) { + if (!write_viewer_state_json(ctx, std::filesystem::path( + state_out_value))) { + release_test_engine_held_mouse(ctx, held_button); + return; + } + } + ctx->Yield(1); + } + release_test_engine_held_mouse(ctx, held_button); + } + + + + void imiv_test_dump_layout_json(ImGuiTestContext* ctx) + { + int delay_frames + = env_int_value("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_DELAY_FRAMES", + 3); + ctx->Yield(delay_frames); + const int held_button = apply_test_engine_mouse_actions(ctx); + const int post_action_delay + = env_int_value("IMIV_IMGUI_TEST_ENGINE_POST_ACTION_DELAY_FRAMES", + 0); + if (post_action_delay > 0) + ctx->Yield(post_action_delay); + + const bool include_items = env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_ITEMS"); + int depth = env_int_value("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_DEPTH", + 8); + if (depth <= 0) + depth = 1; + + std::string out_value; + if (!read_env_value("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_OUT", out_value) + || out_value.empty()) { + out_value = "layout.json"; + } + + if (!write_layout_dump_json(ctx, std::filesystem::path(out_value), + include_items, depth)) { + release_test_engine_held_mouse(ctx, held_button); + return; + } + release_test_engine_held_mouse(ctx, held_button); + } + + + + void imiv_test_dump_viewer_state(ImGuiTestContext* ctx) + { + const int delay_frames + = env_int_value("IMIV_IMGUI_TEST_ENGINE_STATE_DUMP_DELAY_FRAMES", + 3); + ctx->Yield(delay_frames); + const int held_button = apply_test_engine_mouse_actions(ctx); + const int post_action_delay + = env_int_value("IMIV_IMGUI_TEST_ENGINE_POST_ACTION_DELAY_FRAMES", + 0); + if (post_action_delay > 0) + ctx->Yield(post_action_delay); + ctx->Yield(1); + + std::string out_value; + if (!read_env_value("IMIV_IMGUI_TEST_ENGINE_STATE_DUMP_OUT", out_value) + || out_value.empty()) { + out_value = "viewer_state.json"; + } + + if (!write_viewer_state_json(ctx, std::filesystem::path(out_value))) { + release_test_engine_held_mouse(ctx, held_button); + return; + } + release_test_engine_held_mouse(ctx, held_button); + } + + + + void imiv_test_run_scenario(ImGuiTestContext* ctx) + { + std::string scenario_file_value; + if (!read_env_value("IMIV_IMGUI_TEST_ENGINE_SCENARIO_FILE", + scenario_file_value) + || scenario_file_value.empty()) { + ctx->LogError( + "scenario: IMIV_IMGUI_TEST_ENGINE_SCENARIO_FILE is not set"); + mark_test_error(ctx); + return; + } + + TestEngineScenarioDefinition scenario; + std::string error_message; + if (!load_test_engine_scenario(std::filesystem::path( + scenario_file_value), + scenario, error_message)) { + ctx->LogError("scenario: %s", error_message.c_str()); + mark_test_error(ctx); + return; + } + + std::error_code ec; + std::filesystem::create_directories(scenario.out_dir, ec); + if (ec) { + ctx->LogError("scenario: failed to create output directory '%s': %s", + scenario.out_dir.string().c_str(), + ec.message().c_str()); + mark_test_error(ctx); + return; + } + + int held_button = -1; + for (const TestEngineScenarioStep& step : scenario.steps) { + release_test_engine_held_mouse(ctx, held_button); + held_button = -1; + + ctx->LogInfo("scenario: step '%s'", step.name.c_str()); + + if (step.action.delay_frames > 0) + ctx->Yield(step.action.delay_frames); + + apply_test_engine_scenario_overrides(step.ocio, step.image_list, + step.view); + ctx->Yield(1); + + held_button = apply_test_engine_synthetic_actions(ctx, step.action); + + if (step.action.post_action_delay_frames > 0) + ctx->Yield(step.action.post_action_delay_frames); + + if (step.capture.screenshot || step.capture.layout + || step.capture.state) { + if (!write_test_engine_scenario_step_outputs(ctx, scenario, + step)) { + release_test_engine_held_mouse(ctx, held_button); + return; + } + } + + release_test_engine_held_mouse(ctx, held_button); + held_button = -1; + ctx->Yield(1); + } + } + + + + void imiv_test_developer_menu_metrics(ImGuiTestContext* ctx) + { + ctx->Yield(3); + const ImGuiTestItemInfo developer_menu + = ctx->ItemInfo("##MainMenuBar##MenuBar/Developer", + ImGuiTestOpFlags_NoError); + if (developer_menu.ID == 0 || developer_menu.Window == nullptr) { + ctx->LogError( + "developer menu regression: Developer menu item not found"); + mark_test_error(ctx); + return; + } + + ctx->LogInfo("developer menu regression: opening Developer menu"); + ctx->ItemClick("##MainMenuBar##MenuBar/Developer"); + ctx->Yield(1); + ctx->LogInfo("developer menu regression: clicking ImGui Demo"); + ctx->ItemClick("//$FOCUSED/ImGui Demo"); + ctx->Yield(2); + + const ImGuiTestItemInfo demo_window + = ctx->WindowInfo("Dear ImGui Demo", ImGuiTestOpFlags_NoError); + if (demo_window.ID == 0 || demo_window.Window == nullptr) { + ctx->LogError("developer menu regression: demo window did not " + "open"); + mark_test_error(ctx); + return; + } + + std::string out_value; + if (!read_env_value("IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_LAYOUT_OUT", + out_value) + || out_value.empty()) { + out_value = "developer_menu_metrics_layout.json"; + } + + int depth = env_int_value( + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_LAYOUT_DEPTH", 8); + if (depth <= 0) + depth = 1; + const bool include_items = env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_LAYOUT_ITEMS"); + std::vector extra_windows = { demo_window.Window }; + if (!write_layout_dump_json(ctx, std::filesystem::path(out_value), + include_items, depth, &extra_windows)) { + return; + } + } + + + + ImGuiTest* register_imiv_smoke_tests(ImGuiTestEngine* engine) + { + ImGuiTest* t = IM_REGISTER_TEST(engine, "imiv", "smoke_screenshot"); + t->TestFunc = imiv_test_smoke_screenshot; + return t; + } + + + + ImGuiTest* register_imiv_layout_dump_tests(ImGuiTestEngine* engine) + { + ImGuiTest* t = IM_REGISTER_TEST(engine, "imiv", "dump_layout_json"); + t->TestFunc = imiv_test_dump_layout_json; + return t; + } + + + + ImGuiTest* register_imiv_state_dump_tests(ImGuiTestEngine* engine) + { + ImGuiTest* t = IM_REGISTER_TEST(engine, "imiv", "dump_viewer_state"); + t->TestFunc = imiv_test_dump_viewer_state; + return t; + } + + + + ImGuiTest* register_imiv_developer_menu_tests(ImGuiTestEngine* engine) + { + ImGuiTest* t = IM_REGISTER_TEST(engine, "imiv", + "developer_menu_metrics_window"); + t->TestFunc = imiv_test_developer_menu_metrics; + return t; + } + + + + ImGuiTest* register_imiv_scenario_tests(ImGuiTestEngine* engine) + { + ImGuiTest* t = IM_REGISTER_TEST(engine, "imiv", "scenario"); + t->TestFunc = imiv_test_run_scenario; + return t; + } +#endif + +} // namespace + + + +TestEngineConfig +gather_test_engine_config() +{ + TestEngineConfig cfg; + cfg.trace = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_TRACE"); + cfg.auto_screenshot + = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT") + || env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_AUTO_SCREENSHOT"); + + std::string layout_out; + const bool has_layout_out + = read_env_value("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_OUT", layout_out) + && !layout_out.empty(); + cfg.layout_dump = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP") + || has_layout_out; + + std::string state_out; + const bool has_state_out + = read_env_value("IMIV_IMGUI_TEST_ENGINE_STATE_DUMP_OUT", state_out) + && !state_out.empty(); + cfg.state_dump = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_STATE_DUMP") + || has_state_out; + cfg.scenario_run = read_env_value("IMIV_IMGUI_TEST_ENGINE_SCENARIO_FILE", + cfg.scenario_file) + && !cfg.scenario_file.empty(); + cfg.developer_menu_metrics = env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_METRICS"); + cfg.state_dump_out = has_state_out ? state_out : "viewer_state.json"; + + std::string junit_out; + const bool has_junit_out + = read_env_value("IMIV_IMGUI_TEST_ENGINE_JUNIT_OUT", junit_out) + && !junit_out.empty(); + cfg.junit_xml = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE_JUNIT_XML") + || has_junit_out; + cfg.junit_xml_out = has_junit_out ? junit_out : "imiv_tests.junit.xml"; + + cfg.want_test_engine = env_flag_is_truthy("IMIV_IMGUI_TEST_ENGINE") + || cfg.auto_screenshot || cfg.layout_dump + || cfg.state_dump || cfg.scenario_run + || cfg.developer_menu_metrics || cfg.junit_xml; +#if defined(IMGUI_ENABLE_TEST_ENGINE) && !defined(NDEBUG) + cfg.want_test_engine = true; +#endif + cfg.automation_mode = cfg.auto_screenshot || cfg.layout_dump + || cfg.state_dump || cfg.scenario_run + || cfg.developer_menu_metrics; + cfg.exit_on_finish = env_flag_is_truthy( + "IMIV_IMGUI_TEST_ENGINE_EXIT_ON_FINISH") + || cfg.automation_mode; + cfg.show_windows = false; + return cfg; +} + + + +void +test_engine_start(TestEngineRuntime& runtime, TestEngineConfig& config, + const TestEngineHooks& hooks) +{ + runtime.engine = nullptr; + runtime.request_exit = false; + runtime.show_windows = config.show_windows; + +#if defined(IMGUI_ENABLE_TEST_ENGINE) + g_test_engine_hooks = hooks; + if (!config.want_test_engine) + return; + + ImGuiTestEngine* engine = ImGuiTestEngine_CreateContext(); + if (engine == nullptr) + return; + runtime.engine = engine; + + ImGuiTestEngineIO& test_io = ImGuiTestEngine_GetIO(engine); + test_io.ConfigVerboseLevel = ImGuiTestVerboseLevel_Info; + test_io.ConfigVerboseLevelOnError = ImGuiTestVerboseLevel_Debug; + test_io.ConfigRunSpeed = ImGuiTestRunSpeed_Normal; + test_io.ConfigCaptureEnabled = true; + test_io.ScreenCaptureFunc = hooks.screen_capture; + test_io.ScreenCaptureUserData = hooks.screen_capture_user_data; + if (config.trace || config.automation_mode) + test_io.ConfigLogToTTY = true; + + if (config.junit_xml) { + std::string error_message; + if (validate_test_output_path( + std::filesystem::path(config.junit_xml_out), error_message)) { + std::error_code ec; + std::filesystem::path junit_path(config.junit_xml_out); + if (!junit_path.parent_path().empty()) + std::filesystem::create_directories(junit_path.parent_path(), + ec); + test_io.ExportResultsFormat = ImGuiTestEngineExportFormat_JUnitXml; + test_io.ExportResultsFilename = config.junit_xml_out.c_str(); + } else { + config.junit_xml = false; + } + } + + ImGuiTestEngine_Start(engine, ImGui::GetCurrentContext()); + if (config.auto_screenshot) { + ImGuiTest* smoke = register_imiv_smoke_tests(engine); + ImGuiTestEngine_QueueTest(engine, smoke); + config.has_work = true; + } + if (config.layout_dump) { + ImGuiTest* dump = register_imiv_layout_dump_tests(engine); + ImGuiTestEngine_QueueTest(engine, dump); + config.has_work = true; + } + if (config.state_dump) { + ImGuiTest* dump = register_imiv_state_dump_tests(engine); + ImGuiTestEngine_QueueTest(engine, dump); + config.has_work = true; + } + if (config.scenario_run) { + ImGuiTest* scenario = register_imiv_scenario_tests(engine); + ImGuiTestEngine_QueueTest(engine, scenario); + config.has_work = true; + } + if (config.developer_menu_metrics) { + ImGuiTest* menu_test = register_imiv_developer_menu_tests(engine); + ImGuiTestEngine_QueueTest(engine, menu_test); + config.has_work = true; + } +#else + (void)hooks; +#endif +} + + + +void +test_engine_stop(TestEngineRuntime& runtime) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + ImGuiTestEngine* engine = reinterpret_cast( + runtime.engine); + runtime.request_exit = false; + runtime.show_windows = false; + if (engine != nullptr) + ImGuiTestEngine_Stop(engine); +#else + runtime.request_exit = false; + runtime.show_windows = false; +#endif +} + + + +void +test_engine_destroy(TestEngineRuntime& runtime) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + ImGuiTestEngine* engine = reinterpret_cast( + runtime.engine); + runtime.engine = nullptr; + runtime.request_exit = false; + runtime.show_windows = false; + g_test_engine_hooks = {}; + if (engine != nullptr) + ImGuiTestEngine_DestroyContext(engine); +#else + runtime.engine = nullptr; + runtime.request_exit = false; + runtime.show_windows = false; +#endif +} + + + +bool* +test_engine_show_windows_ptr(TestEngineRuntime& runtime) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + return runtime.engine ? &runtime.show_windows : nullptr; +#else + (void)runtime; + return nullptr; +#endif +} + + + +void +test_engine_maybe_show_windows(TestEngineRuntime& runtime, + const TestEngineConfig& config) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + ImGuiTestEngine* engine = reinterpret_cast( + runtime.engine); + if (engine != nullptr && runtime.show_windows && !config.automation_mode) + ImGuiTestEngine_ShowTestEngineWindows(engine, nullptr); +#else + (void)runtime; + (void)config; +#endif +} + + + +void +test_engine_post_swap(TestEngineRuntime& runtime) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + ImGuiTestEngine* engine = reinterpret_cast( + runtime.engine); + if (engine != nullptr) + ImGuiTestEngine_PostSwap(engine); +#else + (void)runtime; +#endif +} + + + +bool +test_engine_should_close(TestEngineRuntime& runtime, + const TestEngineConfig& config) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + ImGuiTestEngine* engine = reinterpret_cast( + runtime.engine); + if (engine == nullptr) + return false; + if (runtime.request_exit && !config.exit_on_finish) + return true; + if (config.exit_on_finish && config.has_work) { + ImGuiTestEngineIO& test_io = ImGuiTestEngine_GetIO(engine); + if (!test_io.IsRunningTests && !test_io.IsCapturing + && ImGuiTestEngine_IsTestQueueEmpty(engine)) { + return true; + } + } + return false; +#else + (void)runtime; + (void)config; + return false; +#endif +} + + + +void +reset_layout_dump_synthetic_items() +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + g_layout_dump_synthetic_item_counter = 0; +#endif +} + + + +void +reset_test_engine_mouse_space() +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + g_test_engine_mouse_space = TestEngineMouseSpaceState(); +#endif +} + + + +void +update_test_engine_mouse_space(const ImVec2& viewport_min, + const ImVec2& viewport_max, + const ImVec2& image_min, const ImVec2& image_max) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + g_test_engine_mouse_space.viewport_valid = true; + g_test_engine_mouse_space.viewport_min = viewport_min; + g_test_engine_mouse_space.viewport_max = viewport_max; + g_test_engine_mouse_space.image_valid = (image_max.x > image_min.x + && image_max.y > image_min.y); + g_test_engine_mouse_space.image_min = image_min; + g_test_engine_mouse_space.image_max = image_max; +#else + (void)viewport_min; + (void)viewport_max; + (void)image_min; + (void)image_max; +#endif +} + + + +void +register_layout_dump_synthetic_item(const char* kind, const char* label) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + if (!layout_dump_items_enabled()) + return; + ImGuiContext* ui_ctx = ImGui::GetCurrentContext(); + if (ui_ctx == nullptr) + return; + + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + register_layout_dump_synthetic_rect(kind, label, min, max); +#else + (void)kind; + (void)label; +#endif +} + + + +void +register_layout_dump_synthetic_rect(const char* kind, const char* label, + const ImVec2& min, const ImVec2& max) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + if (!layout_dump_items_enabled()) + return; + ImGuiContext* ui_ctx = ImGui::GetCurrentContext(); + if (ui_ctx == nullptr) + return; + if (max.x <= min.x || max.y <= min.y) + return; + + const int ordinal = ++g_layout_dump_synthetic_item_counter; + char id_source[128] = {}; + std::snprintf(id_source, sizeof(id_source), "##imiv_layout_synth_%s_%d", + kind ? kind : "item", ordinal); + const ImGuiID id = ImGui::GetID(id_source); + if (id == 0) + return; + + char debug_label[128] = {}; + if (label && label[0] != '\0') { + std::snprintf(debug_label, sizeof(debug_label), "%s: %s", + kind ? kind : "item", label); + } else { + std::snprintf(debug_label, sizeof(debug_label), "%s", + kind ? kind : "item"); + } + + if (ui_ctx->TestEngine == nullptr) + return; + const ImRect bb(min, max); + ImGuiTestEngineHook_ItemAdd(ui_ctx, id, bb, nullptr); + ImGuiTestEngineHook_ItemInfo(ui_ctx, id, debug_label, + ImGuiItemStatusFlags_None); +#else + (void)kind; + (void)label; + (void)min; + (void)max; +#endif +} + + + +void +register_test_engine_item_label(const char* label, bool openable) +{ +#if defined(IMGUI_ENABLE_TEST_ENGINE) + if (label == nullptr || label[0] == '\0') + return; + ImGuiContext* ui_ctx = ImGui::GetCurrentContext(); + if (ui_ctx == nullptr) + return; + const ImVec2 min = ImGui::GetItemRectMin(); + const ImVec2 max = ImGui::GetItemRectMax(); + if (max.x <= min.x || max.y <= min.y) + return; + const int ordinal = ++g_layout_dump_synthetic_item_counter; + char id_source[128] = {}; + std::snprintf(id_source, sizeof(id_source), "##imiv_test_item_%d", ordinal); + const ImGuiID id = ImGui::GetID(id_source); + if (id == 0) + return; + if (ui_ctx->TestEngine == nullptr) + return; + const ImRect bb(min, max); + ImGuiItemStatusFlags flags = ImGuiItemStatusFlags_None; + if (openable) + flags |= ImGuiItemStatusFlags_Openable; + ImGuiTestEngineHook_ItemAdd(ui_ctx, id, bb, nullptr); + ImGuiTestEngineHook_ItemInfo(ui_ctx, id, label, flags); +#else + (void)label; + (void)openable; +#endif +} + +} // namespace Imiv diff --git a/src/imiv/imiv_test_engine.h b/src/imiv/imiv_test_engine.h new file mode 100644 index 0000000000..cf5dc4bcf4 --- /dev/null +++ b/src/imiv/imiv_test_engine.h @@ -0,0 +1,90 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include + +#include + +namespace Imiv { + +struct TestEngineConfig { + bool want_test_engine = false; + bool trace = false; + bool auto_screenshot = false; + bool layout_dump = false; + bool state_dump = false; + bool scenario_run = false; + bool developer_menu_metrics = false; + bool junit_xml = false; + bool automation_mode = false; + bool exit_on_finish = false; + bool has_work = false; + bool show_windows = false; + std::string junit_xml_out; + std::string scenario_file; + std::string state_dump_out; +}; + +struct TestEngineRuntime { + void* engine = nullptr; + bool request_exit = false; + bool show_windows = false; +}; + +using TestEngineScreenCaptureFn = bool (*)(ImGuiID viewport_id, int x, int y, + int w, int h, unsigned int* pixels, + void* user_data); +using TestEngineWriteViewerStateJsonFn + = bool (*)(const std::filesystem::path& out_path, void* user_data, + std::string& error_message); + +struct TestEngineHooks { + const char* image_window_title = "Image"; + TestEngineScreenCaptureFn screen_capture = nullptr; + void* screen_capture_user_data = nullptr; + TestEngineWriteViewerStateJsonFn write_viewer_state_json = nullptr; + void* write_viewer_state_user_data = nullptr; +}; + +TestEngineConfig +gather_test_engine_config(); +void +test_engine_start(TestEngineRuntime& runtime, TestEngineConfig& config, + const TestEngineHooks& hooks); +void +test_engine_stop(TestEngineRuntime& runtime); +void +test_engine_destroy(TestEngineRuntime& runtime); +bool* +test_engine_show_windows_ptr(TestEngineRuntime& runtime); +void +test_engine_maybe_show_windows(TestEngineRuntime& runtime, + const TestEngineConfig& config); +void +test_engine_post_swap(TestEngineRuntime& runtime); +bool +test_engine_should_close(TestEngineRuntime& runtime, + const TestEngineConfig& config); + +void +reset_layout_dump_synthetic_items(); +void +reset_test_engine_mouse_space(); +void +update_test_engine_mouse_space(const ImVec2& viewport_min, + const ImVec2& viewport_max, + const ImVec2& image_min, + const ImVec2& image_max); +void +register_layout_dump_synthetic_item(const char* kind, const char* label); +void +register_layout_dump_synthetic_rect(const char* kind, const char* label, + const ImVec2& min, const ImVec2& max); +void +register_test_engine_item_label(const char* label, bool openable = false); + +} // namespace Imiv diff --git a/src/imiv/imiv_tiling.cpp b/src/imiv/imiv_tiling.cpp new file mode 100644 index 0000000000..9b17d9ec36 --- /dev/null +++ b/src/imiv/imiv_tiling.cpp @@ -0,0 +1,161 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_tiling.h" + +#include +#include +#include +#include +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + size_t align_up_size(size_t value, size_t alignment) + { + if (alignment <= 1) + return value; + const size_t remainder = value % alignment; + if (remainder == 0) + return value; + return value + (alignment - remainder); + } + +} // namespace + +bool +build_row_stripe_upload_plan(size_t source_row_pitch_bytes, + size_t pixel_stride_bytes, int image_height, + size_t max_descriptor_range_bytes, + size_t min_offset_alignment_bytes, + RowStripeUploadPlan& plan, + std::string& error_message) +{ + error_message.clear(); + plan = RowStripeUploadPlan(); + + if (image_height <= 0) { + error_message = "invalid image height for stripe upload plan"; + return false; + } + if (source_row_pitch_bytes == 0 || pixel_stride_bytes == 0) { + error_message = "invalid source pitch for stripe upload plan"; + return false; + } + if (max_descriptor_range_bytes == 0) { + error_message = "invalid descriptor range limit for stripe upload plan"; + return false; + } + + size_t row_alignment = std::max(4, min_offset_alignment_bytes); + if (row_alignment == 0) + row_alignment = 4; + + const size_t aligned_row_pitch = align_up_size(source_row_pitch_bytes, + row_alignment); + if (aligned_row_pitch == 0) { + error_message = "aligned row pitch overflow in stripe upload plan"; + return false; + } + if (aligned_row_pitch > max_descriptor_range_bytes) { + error_message = Strutil::fmt::format( + "aligned row pitch {} exceeds max storage buffer range {}", + aligned_row_pitch, max_descriptor_range_bytes); + return false; + } + + const size_t max_rows_per_stripe = max_descriptor_range_bytes + / aligned_row_pitch; + if (max_rows_per_stripe == 0) { + error_message + = "no rows fit into the Vulkan storage-buffer descriptor range"; + return false; + } + + const uint32_t stripe_rows = static_cast( + std::min(static_cast(image_height), + max_rows_per_stripe)); + const size_t descriptor_range = aligned_row_pitch + * static_cast(stripe_rows); + const uint32_t stripe_count = static_cast( + (static_cast(image_height) + stripe_rows - 1) / stripe_rows); + const size_t padded_upload_bytes = descriptor_range + * static_cast(stripe_count); + + if (descriptor_range > std::numeric_limits::max()) { + error_message = Strutil::fmt::format( + "descriptor range {} exceeds Vulkan dynamic-offset address space", + descriptor_range); + return false; + } + if (padded_upload_bytes > std::numeric_limits::max()) { + error_message = Strutil::fmt::format( + "padded upload size {} exceeds Vulkan dynamic-offset address space", + padded_upload_bytes); + return false; + } + + plan.aligned_row_pitch_bytes = aligned_row_pitch; + plan.descriptor_range_bytes = descriptor_range; + plan.padded_upload_bytes = padded_upload_bytes; + plan.stripe_rows = stripe_rows; + plan.stripe_count = stripe_count; + plan.uses_multiple_stripes = stripe_count > 1; + return true; +} + +bool +copy_rows_to_padded_buffer(const unsigned char* source_pixels, + size_t source_pixels_size, + size_t source_row_pitch_bytes, int image_height, + size_t padded_row_pitch_bytes, + unsigned char* destination_pixels, + size_t destination_pixels_size, + std::string& error_message) +{ + error_message.clear(); + + if (source_pixels == nullptr || destination_pixels == nullptr) { + error_message = "null image buffer in padded row copy"; + return false; + } + if (image_height <= 0 || source_row_pitch_bytes == 0 + || padded_row_pitch_bytes < source_row_pitch_bytes) { + error_message = "invalid row geometry in padded row copy"; + return false; + } + + const size_t required_source_bytes = source_row_pitch_bytes + * static_cast(image_height); + const size_t required_destination_bytes + = padded_row_pitch_bytes * static_cast(image_height); + if (source_pixels_size < required_source_bytes) { + error_message = "source image buffer is smaller than declared row span"; + return false; + } + if (destination_pixels_size < required_destination_bytes) { + error_message + = "destination image buffer is smaller than padded row span"; + return false; + } + + std::memset(destination_pixels, 0, destination_pixels_size); + for (int y = 0; y < image_height; ++y) { + const size_t source_offset = static_cast(y) + * source_row_pitch_bytes; + const size_t destination_offset = static_cast(y) + * padded_row_pitch_bytes; + std::memcpy(destination_pixels + destination_offset, + source_pixels + source_offset, source_row_pitch_bytes); + } + return true; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_tiling.h b/src/imiv/imiv_tiling.h new file mode 100644 index 0000000000..1194c52527 --- /dev/null +++ b/src/imiv/imiv_tiling.h @@ -0,0 +1,39 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include +#include + +namespace Imiv { + +struct RowStripeUploadPlan { + size_t aligned_row_pitch_bytes = 0; + size_t descriptor_range_bytes = 0; + size_t padded_upload_bytes = 0; + uint32_t stripe_rows = 0; + uint32_t stripe_count = 0; + bool uses_multiple_stripes = false; +}; + +bool +build_row_stripe_upload_plan(size_t source_row_pitch_bytes, + size_t pixel_stride_bytes, int image_height, + size_t max_descriptor_range_bytes, + size_t min_offset_alignment_bytes, + RowStripeUploadPlan& plan, + std::string& error_message); + +bool +copy_rows_to_padded_buffer(const unsigned char* source_pixels, + size_t source_pixels_size, + size_t source_row_pitch_bytes, int image_height, + size_t padded_row_pitch_bytes, + unsigned char* destination_pixels, + size_t destination_pixels_size, + std::string& error_message); + +} // namespace Imiv diff --git a/src/imiv/imiv_tiling.md b/src/imiv/imiv_tiling.md new file mode 100644 index 0000000000..6e41f50748 --- /dev/null +++ b/src/imiv/imiv_tiling.md @@ -0,0 +1,419 @@ +# imiv Tiling and Large-Image Design + +## Goal + +Add a scalable image-viewing path for large images across all `imiv` +backends while keeping color/format normalization on the GPU where practical. + +This document started as a design note. The first Vulkan/OpenGL/Metal safety slice +is now implemented: + +- large Vulkan uploads no longer rely on one giant + `VK_DESCRIPTOR_TYPE_STORAGE_BUFFER` descriptor range; +- the current Vulkan compute upload path uses aligned row stripes with dynamic + storage-buffer offsets; and +- the current OpenGL source upload path allocates first and then uploads large + images in row stripes via `glTexSubImage2D`; +- the current Metal source upload path keeps compute normalization but + dispatches it with stripe-sized `MTLBuffer` inputs; and +- the broader shared CPU/GPU tile-cache architecture described below remains + future work. + + +## Problem + +Current `imiv` behavior is a full-image path: + +- load the full image into `LoadedImage` +- upload the full source image to the backend +- create full-size preview resources + +This is acceptable for ordinary still images and the current regression suite, +but it does not scale well for: + +- large texture plates +- stitched panoramas +- very high resolution scans +- multi-image sessions with several large images loaded at once + +The immediate Vulkan bug that triggered this discussion is one symptom of that +design: + +- current Vulkan compute upload binds the full raw source payload as one + `VK_DESCRIPTOR_TYPE_STORAGE_BUFFER` +- large images can exceed `VkPhysicalDeviceLimits::maxStorageBufferRange` + +That is not a general Vulkan image-size limitation. It is a limitation of the +current upload model. + + +## What iv Did + +Old `iv` did not upload a whole image as one giant GPU resource. + +Its OpenGL path: + +- computed the visible image rectangle +- split that rectangle into texture-sized tiles +- uploaded needed image patches tile-by-tile +- rendered those tiles through the same shader/display path + +Relevant behavior in `src/iv/ivgl.cpp`: + +- texture size is clamped, with a practical cap of `4096` +- visible region is snapped to tile boundaries +- `load_texture()` uploads only the patch needed for a tile + +So `iv` already behaved like a tiled viewer, not a full-image uploader. + + +## Requirements + +1. Large images must not require one monolithic raw GPU upload buffer. +2. Small and medium images should keep a simple fast path. +3. GPU normalization should remain available: + - `RGB -> RGBA` + - integer normalization + - half/float handling +4. The design should work across: + - Vulkan + - OpenGL + - Metal +5. The backend-independent view model should not depend on one backend's + upload mechanism. + + +## Non-goals + +Not in the first implementation: + +- full sparse virtual-texture system +- recursive multi-resolution pyramid management for arbitrary formats +- backend-specific feature divergence +- replacing all current full-image paths immediately + + +## Recommended Model + +Split the problem into three layers. + +### 1. CPU image/tile layer + +Responsible for: + +- visible-region computation +- tile indexing +- tile reads from `ImageBuf` / `ImageCache` +- CPU-side cache of source tiles + +This layer should know nothing about Vulkan, OpenGL, or Metal. + +Suggested concepts: + +- `ImageTileKey` + - image identity + - subimage + - miplevel / proxy level + - tile x/y +- `ImageTileRequest` + - tile key + - pixel ROI + - source type / channel count +- `CpuTile` + - tile pixel data + - row pitch + - source type + - channel count + +### 2. GPU tile normalization layer + +Responsible for: + +- taking one source tile +- converting it to the backend sampling format +- storing the normalized tile in a GPU cache + +This is where `RGB -> RGBA` should happen on GPU where the backend supports it +well. + +Suggested output format: + +- `RGBA16F` when available and appropriate +- `RGBA32F` fallback when needed + +The output of this layer is not "the whole image". +It is one normalized tile. + +### 3. Preview/render layer + +Responsible for: + +- deciding which tiles are visible +- drawing the visible normalized tiles +- applying preview controls +- applying OCIO preview + +This layer should work from a set of normalized tiles, not from one full image +texture. + + +## View Model + +The tiled system should preserve the current view-centric direction: + +- one shared image library +- multiple views +- per-view `ViewRecipe` + +Each view should drive its own tile requests based on: + +- active image +- zoom +- pan/center +- viewport size +- orientation + +`ViewRecipe` remains independent from the tile cache. + + +## Small-image vs Large-image Mode + +Recommended hybrid model: + +### Full-image mode + +Keep the current full-image path for: + +- ordinary images +- regression simplicity +- lower complexity on common cases + +### Tiled mode + +Use the tiled path when any of these become true: + +- raw upload exceeds backend-safe limits +- image dimensions exceed a configured threshold +- total estimated source upload memory is too large +- user explicitly enables large-image mode later + +This avoids forcing complexity on small images while fixing the large-image +case correctly. + + +## Backend Strategies + +### Vulkan + +Preferred direction: + +- per-tile source upload +- per-tile compute normalization into a normalized tile image + +Avoid: + +- one full-image storage-buffer descriptor +- one monolithic upload buffer for the entire image + +For Vulkan, a tile is the natural unit for: + +- staging upload +- compute dispatch +- descriptor binding + +This also avoids the current `maxStorageBufferRange` problem. + +### Metal + +Preferred direction: + +- keep the compute normalization model already used in Metal +- move it from "whole image" to "per tile" + +This is aligned with the current Metal design and should be a relatively +natural extension. + +### OpenGL + +Two valid paths: + +1. Direct native upload per tile + - upload `R/RG/RGB/RGBA` tiles directly + - sample directly in preview shaders + +2. Normalized tile pass + - upload source tile + - render/convert into normalized RGBA tile via FBO/shader + +Recommendation: + +- keep OpenGL practical +- do not force an OpenGL compute-style design just for symmetry + +OpenGL should keep native GL strengths, but still fit the same higher-level +tile-cache model. + + +## Tile Cache + +Two caches are needed. + +### CPU tile cache + +Stores source tiles read from OIIO. + +Used to avoid repeated disk/cache reads while panning around the same area. + +### GPU tile cache + +Stores normalized backend-ready tiles. + +Used to avoid repeated normalization/upload for tiles already visible or +recently viewed. + +Suggested properties: + +- LRU-style eviction +- byte-budget based +- backend-specific capacity + + +## Tile Size + +Initial recommendation: + +- default tile size around `512` or `1024` +- backend may clamp or tune + +Tile size should be chosen based on: + +- upload overhead +- GPU cache friendliness +- descriptor/update pressure +- preview redraw cost + +It does not need to match file-internal tiling exactly. + + +## OCIO and Preview Controls + +Keep the existing conceptual split: + +- source/normalization +- preview rendering + +Per-view preview controls should remain preview-stage operations: + +- exposure +- gamma +- offset +- channel/color mode +- OCIO display/view +- interpolation choice + +These should not force source-tile re-normalization unless a backend +implementation truly needs that. + + +## Huge Images and Proxy Levels + +The first tiled design does not need a full mip/proxy system. + +But it should leave room for: + +- reduced-resolution tiles for zoomed-out display +- source subimage/miplevel mapping +- future `ImageCache`-driven proxy logic + +Suggested rule for the first pass: + +- tile the currently selected image resolution +- do not add automatic proxy selection yet + +Then add proxy selection in a later phase if needed. + + +## Phased Implementation + +### Phase 1: Immediate Vulkan/OpenGL/Metal safety fix + +Goal: + +- stop using one monolithic full-image upload step for oversized images + +Preferred approach: + +- introduce striped Vulkan source upload +- introduce striped OpenGL source upload +- introduce striped Metal source upload +- keep Vulkan/Metal compute normalization on GPU + +Avoid if possible: + +- large-image CPU fallback as the long-term answer + +### Phase 2: Shared tiling framework + +Add backend-neutral: + +- visible tile computation +- tile keys/requests +- CPU tile cache +- GPU tile cache interface + +### Phase 3: Vulkan tiled path + +Make Vulkan use the shared tile framework as the first real backend. + +This becomes the reference implementation. + +### Phase 4: Metal tiled path + +Port the same model to Metal with per-tile compute normalization. + +### Phase 5: OpenGL tiled path + +Port the shared model to OpenGL using direct native tile upload or normalized +tile rendering. + +### Phase 6: Proxy levels + +Add optional reduced-resolution / proxy selection for zoomed-out views. + + +## Testing + +Add focused tests in this order: + +1. large-image multi-file switch regression + - verifies no crash on next/previous image +2. tile visibility regression + - verifies visible region requests expected tiles +3. pan/zoom large-image regression + - verifies tile reuse/eviction behavior at a basic level +4. backend parity regression + - same large image set on Vulkan/OpenGL/Metal + +Test data should include: + +- large RGB image +- large RGBA image +- large integer image +- large half/float image when practical + + +## Recommendation + +Proceed with a tiled architecture for all backends. + +Do not abandon GPU normalization. + +The correct direction is: + +- tile-based CPU image access +- tile-based GPU upload +- per-tile GPU normalization +- per-view preview rendering from normalized tile caches + +That preserves the current renderer goals while fixing the class of large-image +bugs that the monolithic full-image upload path is now hitting. diff --git a/src/imiv/imiv_types.h b/src/imiv/imiv_types.h new file mode 100644 index 0000000000..f99a626824 --- /dev/null +++ b/src/imiv/imiv_types.h @@ -0,0 +1,83 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_build_config.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Imiv { + +enum class UploadDataType : uint32_t { + UInt8 = 0, + UInt16 = 1, + UInt32 = 2, + Half = 3, + Float = 4, + Double = 5, + Unknown = 255 +}; + +size_t +upload_data_type_size(UploadDataType type); +const char* +upload_data_type_name(UploadDataType type); +OIIO::TypeDesc +upload_data_type_to_typedesc(UploadDataType type); +bool +map_spec_type_to_upload(OIIO::TypeDesc spec_type, UploadDataType& upload_type, + OIIO::TypeDesc& read_format); + +struct LoadedImage { + std::string path; + std::string metadata_color_space; + std::string data_format_name; + int width = 0; + int height = 0; + int orientation = 1; + int nchannels = 0; + int subimage = 0; + int miplevel = 0; + int nsubimages = 1; + int nmiplevels = 1; + UploadDataType type = UploadDataType::Unknown; + size_t channel_bytes = 0; + size_t row_pitch_bytes = 0; + std::vector pixels; + std::vector channel_names; + std::vector> longinfo_rows; +}; + +struct PreviewControls { + float exposure = 0.0f; + float gamma = 1.0f; + float offset = 0.0f; + int color_mode = 0; + int channel = 0; + int use_ocio = 0; + int orientation = 1; + int linear_interpolation = 0; +}; + +inline bool +preview_controls_equal(const PreviewControls& a, const PreviewControls& b) +{ + return std::abs(a.exposure - b.exposure) < 1.0e-6f + && std::abs(a.gamma - b.gamma) < 1.0e-6f + && std::abs(a.offset - b.offset) < 1.0e-6f + && a.color_mode == b.color_mode && a.channel == b.channel + && a.use_ocio == b.use_ocio && a.orientation == b.orientation + && a.linear_interpolation == b.linear_interpolation; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_ui.cpp b/src/imiv/imiv_ui.cpp new file mode 100644 index 0000000000..804636eade --- /dev/null +++ b/src/imiv/imiv_ui.cpp @@ -0,0 +1,241 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_ui.h" +#include "imiv_loaded_image.h" + +#include +#include +#include + +#include +#include + +#include + +namespace Imiv { + +template +T +read_unaligned_value(const unsigned char* ptr) +{ + T value = {}; + std::memcpy(&value, ptr, sizeof(T)); + return value; +} + +bool +sample_loaded_pixel(const LoadedImage& image, int x, int y, + std::vector& out_channels) +{ + out_channels.clear(); + const unsigned char* src = nullptr; + LoadedImageLayout layout; + if (!loaded_image_pixel_pointer(image, x, y, src, &layout)) + return false; + + out_channels.resize(static_cast(image.nchannels)); + for (size_t c = 0; c < out_channels.size(); ++c) { + const unsigned char* channel_ptr = src + c * image.channel_bytes; + double v = 0.0; + switch (image.type) { + case UploadDataType::UInt8: + v = static_cast(*channel_ptr); + break; + case UploadDataType::UInt16: + v = static_cast( + read_unaligned_value(channel_ptr)); + break; + case UploadDataType::UInt32: + v = static_cast( + read_unaligned_value(channel_ptr)); + break; + case UploadDataType::Half: + v = static_cast( + static_cast(read_unaligned_value(channel_ptr))); + break; + case UploadDataType::Float: + v = static_cast(read_unaligned_value(channel_ptr)); + break; + case UploadDataType::Double: + v = read_unaligned_value(channel_ptr); + break; + default: return false; + } + out_channels[c] = v; + } + return true; +} + +void +draw_padded_message(const char* message, float x_pad, float y_pad) +{ + if (!message || message[0] == '\0') + return; + ImVec2 pos = ImGui::GetCursorPos(); + pos.x += x_pad; + pos.y += y_pad; + ImGui::SetCursorPos(pos); + const float wrap_width = ImGui::GetCursorPosX() + + std::max(64.0f, ImGui::GetContentRegionAvail().x + - x_pad); + ImGui::PushTextWrapPos(wrap_width); + ImGui::TextUnformatted(message); + ImGui::PopTextWrapPos(); +} + +void +set_aux_window_defaults(const ImVec2& offset, const ImVec2& size, + bool reset_layout) +{ + const ImGuiViewport* main_viewport = ImGui::GetMainViewport(); + ImVec2 base_pos(0.0f, 0.0f); + if (main_viewport != nullptr) + base_pos = main_viewport->WorkPos; + const ImGuiCond cond = reset_layout ? ImGuiCond_Always + : ImGuiCond_FirstUseEver; + ImGui::SetNextWindowPos(ImVec2(base_pos.x + offset.x, base_pos.y + offset.y), + cond); + ImGui::SetNextWindowSize(size, cond); +} + +bool +input_text_string(const char* label, std::string& value) +{ + return ImGui::InputText(label, &value); +} + +void +push_active_button_style(bool active) +{ + if (!active) + return; + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(66, 112, 171, 255)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(80, 133, 200, 255)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(57, 95, 146, 255)); +} + +void +pop_active_button_style(bool active) +{ + if (!active) + return; + ImGui::PopStyleColor(3); +} + +bool +begin_two_column_table(const char* id, float label_column_width, + ImGuiTableFlags flags, const char* label_column_name, + const char* value_column_name) +{ + if (!ImGui::BeginTable(id, 2, flags)) + return false; + ImGui::TableSetupColumn(label_column_name, ImGuiTableColumnFlags_WidthFixed, + label_column_width); + ImGui::TableSetupColumn(value_column_name, + ImGuiTableColumnFlags_WidthStretch); + return true; +} + +void +table_labeled_row(const char* label) +{ + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::TableSetColumnIndex(1); +} + +void +draw_wrapped_value_row(const char* label, const char* value) +{ + table_labeled_row(label); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(value != nullptr ? value : ""); + ImGui::PopTextWrapPos(); +} + +void +draw_section_heading(const char* title, float separator_padding_y) +{ + const ImVec2 separator_padding + = ImVec2(ImGui::GetStyle().SeparatorTextPadding.x, separator_padding_y); + ImGui::PushStyleVar(ImGuiStyleVar_SeparatorTextPadding, separator_padding); + ImGui::PushStyleColor(ImGuiCol_Text, + ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::SeparatorText(title); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +void +align_control_right(float width) +{ + const float available_width = ImGui::GetContentRegionAvail().x; + if (available_width > width) + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + available_width - width); +} + +bool +draw_right_aligned_checkbox(const char* id, bool& value) +{ + align_control_right(ImGui::GetFrameHeight()); + return ImGui::Checkbox(id, &value); +} + +void +draw_right_aligned_text(const char* value) +{ + const char* text = value != nullptr ? value : ""; + align_control_right(ImGui::CalcTextSize(text).x); + ImGui::TextUnformatted(text); +} + +bool +draw_right_aligned_int_stepper(const char* id, int& value, int step, + const char* suffix, float button_width, + float value_width) +{ + ImGui::PushID(id); + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float suffix_width = (suffix != nullptr && suffix[0] != '\0') + ? ImGui::CalcTextSize(suffix).x + spacing + : 0.0f; + const float total_width = value_width + button_width * 2.0f + spacing * 2.0f + + suffix_width; + bool changed = false; + align_control_right(total_width); + ImGui::SetNextItemWidth(value_width); + changed |= ImGui::InputInt("##value", &value, 0, 0); + ImGui::SameLine(0.0f, spacing); + if (ImGui::Button("-", ImVec2(button_width, 0.0f))) { + value -= step; + changed = true; + } + ImGui::SameLine(0.0f, spacing); + if (ImGui::Button("+", ImVec2(button_width, 0.0f))) { + value += step; + changed = true; + } + if (suffix != nullptr && suffix[0] != '\0') { + ImGui::SameLine(0.0f, spacing); + ImGui::TextUnformatted(suffix); + } + ImGui::PopID(); + return changed; +} + +void +draw_disabled_wrapped_text(const char* message) +{ + ImGui::PushStyleColor(ImGuiCol_Text, + ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(message != nullptr ? message : ""); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_ui.h b/src/imiv/imiv_ui.h new file mode 100644 index 0000000000..06c9c242f9 --- /dev/null +++ b/src/imiv/imiv_ui.h @@ -0,0 +1,87 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_backend.h" +#include "imiv_navigation.h" +#include "imiv_viewer.h" + +#include +#include + +#include + +namespace Imiv { + +inline constexpr const char* k_info_window_title = "iv Info"; +inline constexpr const char* k_preferences_window_title = "iv Preferences"; +inline constexpr const char* k_preview_window_title = "iv Preview"; +inline constexpr const char* k_about_window_title = "About imiv"; + +struct AppFonts { + ImFont* ui = nullptr; + ImFont* mono = nullptr; +}; + +struct OverlayPanelRect { + bool valid = false; + ImVec2 min = ImVec2(0.0f, 0.0f); + ImVec2 max = ImVec2(0.0f, 0.0f); +}; + +bool +sample_loaded_pixel(const LoadedImage& image, int x, int y, + std::vector& out_channels); +void +draw_padded_message(const char* message, float x_pad = 10.0f, + float y_pad = 6.0f); +void +set_aux_window_defaults(const ImVec2& offset, const ImVec2& size, + bool reset_layout); +bool +input_text_string(const char* label, std::string& value); +void +push_active_button_style(bool active); +void +pop_active_button_style(bool active); +bool +begin_two_column_table(const char* id, float label_column_width, + ImGuiTableFlags flags, + const char* label_column_name = "Label", + const char* value_column_name = "Value"); +void +table_labeled_row(const char* label); +void +draw_wrapped_value_row(const char* label, const char* value); +void +draw_section_heading(const char* title, float separator_padding_y); +void +align_control_right(float width); +bool +draw_right_aligned_checkbox(const char* id, bool& value); +void +draw_right_aligned_text(const char* value); +bool +draw_right_aligned_int_stepper(const char* id, int& value, int step, + const char* suffix, float button_width, + float value_width); +void +draw_disabled_wrapped_text(const char* message); +void +draw_info_window(const ViewerState& viewer, bool& show_window, + bool reset_layout = false); +void +draw_preferences_window(PlaceholderUiState& ui, bool& show_window, + BackendKind active_backend, bool reset_layout = false); +void +draw_preview_window(PlaceholderUiState& ui, bool& show_window, + bool reset_layout = false); +void +draw_image_selection_overlay(const ViewerState& viewer, + const ImageCoordinateMap& map); +void +draw_embedded_status_bar(ViewerState& viewer, PlaceholderUiState& ui); + +} // namespace Imiv diff --git a/src/imiv/imiv_ui_metrics.h b/src/imiv/imiv_ui_metrics.h new file mode 100644 index 0000000000..05412137f7 --- /dev/null +++ b/src/imiv/imiv_ui_metrics.h @@ -0,0 +1,130 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include + +namespace Imiv::UiMetrics { + +// Shared spacing for regular app windows. Menus intentionally keep the Dear +// ImGui defaults so menu bars and popups don't inherit tighter content +// spacing. +inline constexpr ImVec2 kAppFramePadding = ImVec2(4.0f, 4.0f); +inline constexpr ImVec2 kAppItemSpacing = ImVec2(4.0f, 4.0f); + +// Shared padding used by the standard auxiliary windows. +inline constexpr ImVec2 kAuxWindowPadding = ImVec2(10.0f, 10.0f); + +// Shared status-bar spacing used inside image windows. +inline constexpr ImVec2 kStatusBarPadding = ImVec2(8.0f, 4.0f); +inline constexpr ImVec2 kStatusBarCellPadding = ImVec2(8.0f, 4.0f); + +namespace AuxiliaryWindows { + + // Default first-use offsets and sizes for the dockable auxiliary windows. + inline constexpr ImVec2 kInfoOffset = ImVec2(72.0f, 72.0f); + inline constexpr ImVec2 kInfoSize = ImVec2(360.0f, 600.0f); + inline constexpr ImVec2 kPreferencesOffset = ImVec2(740.0f, 72.0f); + inline constexpr ImVec2 kPreferencesSize = ImVec2(360.0f, 700.0f); + inline constexpr ImVec2 kPreviewOffset = ImVec2(1030.0f, 72.0f); + inline constexpr ImVec2 kPreviewSize = ImVec2(300.0f, 360.0f); + + // Shared body sizing and small layout gaps inside auxiliary windows. + inline constexpr float kBodyBottomGap = 4.0f; + inline constexpr float kInfoBodyMinHeight = 100.0f; + inline constexpr float kPreferencesBodyMinHeight = 120.0f; + inline constexpr float kPreviewBodyMinHeight = 120.0f; + inline constexpr float kInfoCloseGap = 3.0f; + inline constexpr float kPreferencesCloseGap = 4.0f; + + // Shared auxiliary-window table widths and empty-state padding. + inline constexpr float kInfoTableLabelWidth = 120.0f; + inline constexpr ImVec2 kEmptyMessagePadding = ImVec2(8.0f, 8.0f); + +} // namespace AuxiliaryWindows + +namespace Preferences { + + // Preferences form layout and compact right-aligned stepper/button sizes. + inline constexpr float kSectionSeparatorTextPaddingY = 1.0f; + inline constexpr float kLabelColumnWidth = 150.0f; + inline constexpr float kStepperButtonWidth = 22.0f; + inline constexpr float kStepperValueWidth = 38.0f; + inline constexpr float kOcioBrowseButtonWidth = 64.0f; + inline constexpr float kCloseButtonWidth = 72.0f; + +} // namespace Preferences + +namespace Preview { + + // Compact form widths used by the preview controls tool window. + inline constexpr float kLabelColumnWidth = 90.0f; + +} // namespace Preview + +namespace ImageList { + + // Shared defaults for the docked image library panel. + inline constexpr float kDockTargetWidth = 200.0f; + inline constexpr float kDockMinRatio = 0.12f; + inline constexpr float kDockMaxRatio = 0.35f; + inline constexpr ImVec2 kFloatingOffset = ImVec2(24.0f, 72.0f); + inline constexpr ImVec2 kDefaultWindowSize = ImVec2(200.0f, 420.0f); + +} // namespace ImageList + +namespace PixelCloseup { + + // Fixed geometry for the closeup preview overlay. + inline constexpr float kWindowSize = 260.0f; + inline constexpr float kFollowMouseOffset = 15.0f; + inline constexpr float kCornerPadding = 5.0f; + inline constexpr float kTextPadX = 10.0f; + inline constexpr float kTextPadY = 8.0f; + inline constexpr float kTextLineGap = 2.0f; + inline constexpr float kTextToWindowGap = 2.0f; + inline constexpr float kFontSize = 13.5f; + +} // namespace PixelCloseup + +namespace AreaProbe { + + // Fixed text-panel spacing for the area-probe overlay. + inline constexpr float kPadY = 8.0f; + inline constexpr float kLineGap = 2.0f; + inline constexpr float kBorderMargin = 9.0f; + +} // namespace AreaProbe + +namespace OverlayPanel { + + // Shared styling for text-only overlay panels such as status popups. + inline constexpr float kPadX = 10.0f; + inline constexpr float kPadY = 8.0f; + inline constexpr float kLineGap = 2.0f; + inline constexpr float kCornerRounding = 4.0f; + inline constexpr float kBorderThickness = 1.0f; + inline constexpr float kCornerMarkerSize = 4.0f; + +} // namespace OverlayPanel + +namespace ImageView { + + // Shared image-window geometry limits. + inline constexpr float kViewportMinHeight = 64.0f; + +} // namespace ImageView + +namespace StatusBar { + + // Fixed status-bar sizing and column widths inside the image window. + inline constexpr float kMinHeight = 30.0f; + inline constexpr float kExtraHeight = 8.0f; + inline constexpr float kLoadColumnWidth = 140.0f; + inline constexpr float kMouseColumnWidth = 150.0f; + +} // namespace StatusBar + +} // namespace Imiv::UiMetrics diff --git a/src/imiv/imiv_upload_types.cpp b/src/imiv/imiv_upload_types.cpp new file mode 100644 index 0000000000..b1b1204b60 --- /dev/null +++ b/src/imiv/imiv_upload_types.cpp @@ -0,0 +1,95 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_types.h" + +using namespace OIIO; + +namespace Imiv { + +size_t +upload_data_type_size(UploadDataType type) +{ + switch (type) { + case UploadDataType::UInt8: return 1; + case UploadDataType::UInt16: return 2; + case UploadDataType::UInt32: return 4; + case UploadDataType::Half: return 2; + case UploadDataType::Float: return 4; + case UploadDataType::Double: return 8; + default: break; + } + return 0; +} + +const char* +upload_data_type_name(UploadDataType type) +{ + switch (type) { + case UploadDataType::UInt8: return "u8"; + case UploadDataType::UInt16: return "u16"; + case UploadDataType::UInt32: return "u32"; + case UploadDataType::Half: return "half"; + case UploadDataType::Float: return "float"; + case UploadDataType::Double: return "double"; + default: break; + } + return "unknown"; +} + +TypeDesc +upload_data_type_to_typedesc(UploadDataType type) +{ + switch (type) { + case UploadDataType::UInt8: return TypeUInt8; + case UploadDataType::UInt16: return TypeUInt16; + case UploadDataType::UInt32: return TypeUInt32; + case UploadDataType::Half: return TypeHalf; + case UploadDataType::Float: return TypeFloat; + case UploadDataType::Double: return TypeDesc::DOUBLE; + default: break; + } + return TypeUnknown; +} + +bool +map_spec_type_to_upload(TypeDesc spec_type, UploadDataType& upload_type, + TypeDesc& read_format) +{ + const TypeDesc::BASETYPE base = static_cast( + spec_type.basetype); + if (base == TypeDesc::UINT8) { + upload_type = UploadDataType::UInt8; + read_format = TypeUInt8; + return true; + } + if (base == TypeDesc::UINT16) { + upload_type = UploadDataType::UInt16; + read_format = TypeUInt16; + return true; + } + if (base == TypeDesc::UINT32) { + upload_type = UploadDataType::UInt32; + read_format = TypeUInt32; + return true; + } + if (base == TypeDesc::HALF) { + upload_type = UploadDataType::Half; + read_format = TypeHalf; + return true; + } + if (base == TypeDesc::FLOAT) { + upload_type = UploadDataType::Float; + read_format = TypeFloat; + return true; + } + if (base == TypeDesc::DOUBLE) { + upload_type = UploadDataType::Double; + read_format = TypeDesc::DOUBLE; + return true; + } + return false; +} + +} // namespace Imiv diff --git a/src/imiv/imiv_viewer.cpp b/src/imiv/imiv_viewer.cpp new file mode 100644 index 0000000000..f1139d11e8 --- /dev/null +++ b/src/imiv/imiv_viewer.cpp @@ -0,0 +1,595 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_viewer.h" + +#include "imiv_image_library.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace OIIO; + +namespace Imiv { + +void +reset_view_navigation_state(ViewerState& viewer) +{ + viewer.scroll = ImVec2(0.0f, 0.0f); + viewer.norm_scroll = ImVec2(0.5f, 0.5f); + viewer.max_scroll = ImVec2(0.0f, 0.0f); + viewer.zoom_pivot_screen = ImVec2(0.0f, 0.0f); + viewer.zoom_pivot_source_uv = ImVec2(0.5f, 0.5f); + viewer.zoom_pivot_pending = false; + viewer.zoom_pivot_frames_left = 0; + viewer.scroll_sync_frames_left = 2; + viewer.auto_subimage = false; + viewer.pending_auto_subimage = -1; + viewer.pending_auto_subimage_zoom = 1.0f; + viewer.pending_auto_subimage_norm_scroll = ImVec2(0.5f, 0.5f); + viewer.selection_active = false; + viewer.selection_xbegin = 0; + viewer.selection_ybegin = 0; + viewer.selection_xend = 0; + viewer.selection_yend = 0; + viewer.selection_press_active = false; + viewer.selection_drag_active = false; + viewer.selection_drag_start_uv = ImVec2(0.0f, 0.0f); + viewer.selection_drag_end_uv = ImVec2(0.0f, 0.0f); + viewer.selection_drag_start_screen = ImVec2(0.0f, 0.0f); + viewer.pan_drag_active = false; + viewer.zoom_drag_active = false; + viewer.drag_prev_mouse = ImVec2(0.0f, 0.0f); +} + +bool +has_image_selection(const ViewerState& viewer) +{ + return viewer.selection_active + && viewer.selection_xend > viewer.selection_xbegin + && viewer.selection_yend > viewer.selection_ybegin; +} + +void +clear_image_selection(ViewerState& viewer) +{ + viewer.selection_active = false; + viewer.selection_xbegin = 0; + viewer.selection_ybegin = 0; + viewer.selection_xend = 0; + viewer.selection_yend = 0; + viewer.selection_press_active = false; + viewer.selection_drag_active = false; + viewer.selection_drag_start_uv = ImVec2(0.0f, 0.0f); + viewer.selection_drag_end_uv = ImVec2(0.0f, 0.0f); + viewer.selection_drag_start_screen = ImVec2(0.0f, 0.0f); +} + +void +set_image_selection(ViewerState& viewer, int xbegin, int ybegin, int xend, + int yend) +{ + if (viewer.image.width <= 0 || viewer.image.height <= 0) { + clear_image_selection(viewer); + return; + } + + const int xmin = std::clamp(std::min(xbegin, xend), 0, viewer.image.width); + const int xmax = std::clamp(std::max(xbegin, xend), 0, viewer.image.width); + const int ymin = std::clamp(std::min(ybegin, yend), 0, viewer.image.height); + const int ymax = std::clamp(std::max(ybegin, yend), 0, viewer.image.height); + if (xmax <= xmin || ymax <= ymin) { + clear_image_selection(viewer); + return; + } + + viewer.selection_active = true; + viewer.selection_xbegin = xmin; + viewer.selection_ybegin = ymin; + viewer.selection_xend = xmax; + viewer.selection_yend = ymax; +} + +void +clamp_view_recipe(ViewRecipe& recipe) +{ + if (recipe.current_channel < 0) + recipe.current_channel = 0; + if (recipe.current_channel > 4) + recipe.current_channel = 4; + if (recipe.color_mode < 0) + recipe.color_mode = 0; + if (recipe.color_mode > 4) + recipe.color_mode = 4; + if (recipe.gamma < 0.1f) + recipe.gamma = 0.1f; + recipe.offset = std::clamp(recipe.offset, -1.0f, 1.0f); + if (recipe.ocio_display.empty()) + recipe.ocio_display = "default"; + if (recipe.ocio_view.empty()) + recipe.ocio_view = "default"; + if (recipe.ocio_image_color_space.empty()) + recipe.ocio_image_color_space = "auto"; +} + + + +void +reset_view_recipe(ViewRecipe& recipe) +{ + recipe.current_channel = 0; + recipe.color_mode = 0; + recipe.exposure = 0.0f; + recipe.gamma = 1.0f; + recipe.offset = 0.0f; +} + + + +void +apply_view_recipe_to_ui_state(const ViewRecipe& recipe, + PlaceholderUiState& ui_state) +{ + ui_state.use_ocio = recipe.use_ocio; + ui_state.linear_interpolation = recipe.linear_interpolation; + ui_state.current_channel = recipe.current_channel; + ui_state.color_mode = recipe.color_mode; + ui_state.exposure = recipe.exposure; + ui_state.gamma = recipe.gamma; + ui_state.offset = recipe.offset; + ui_state.ocio_display = recipe.ocio_display; + ui_state.ocio_view = recipe.ocio_view; + ui_state.ocio_image_color_space = recipe.ocio_image_color_space; +} + + + +void +capture_view_recipe_from_ui_state(const PlaceholderUiState& ui_state, + ViewRecipe& recipe) +{ + recipe.use_ocio = ui_state.use_ocio; + recipe.linear_interpolation = ui_state.linear_interpolation; + recipe.current_channel = ui_state.current_channel; + recipe.color_mode = ui_state.color_mode; + recipe.exposure = ui_state.exposure; + recipe.gamma = ui_state.gamma; + recipe.offset = ui_state.offset; + recipe.ocio_display = ui_state.ocio_display; + recipe.ocio_view = ui_state.ocio_view; + recipe.ocio_image_color_space = ui_state.ocio_image_color_space; +} + + + +void +clamp_placeholder_ui_state(PlaceholderUiState& ui_state) +{ + auto clamp_odd = [](int value, int min_value, int max_value) { + int clamped = std::clamp(value, min_value, max_value); + if ((clamped & 1) == 0) { + if (clamped < max_value) + ++clamped; + else + --clamped; + } + return std::clamp(clamped, min_value, max_value); + }; + + if (ui_state.max_memory_ic_mb < 64) + ui_state.max_memory_ic_mb = 64; + if (ui_state.slide_duration_seconds < 1) + ui_state.slide_duration_seconds = 1; + ui_state.closeup_pixels = clamp_odd(ui_state.closeup_pixels, 9, 25); + ui_state.closeup_avg_pixels = clamp_odd(ui_state.closeup_avg_pixels, 3, 25); + if (ui_state.closeup_avg_pixels > ui_state.closeup_pixels) + ui_state.closeup_avg_pixels = ui_state.closeup_pixels; + if (ui_state.mouse_mode < 0) + ui_state.mouse_mode = 0; + if (ui_state.mouse_mode > 4) + ui_state.mouse_mode = 4; + ui_state.style_preset = static_cast( + sanitize_app_style_preset(ui_state.style_preset)); + ui_state.renderer_backend = static_cast( + sanitize_backend_kind(ui_state.renderer_backend)); + if (ui_state.subimage_index < 0) + ui_state.subimage_index = 0; + if (ui_state.miplevel_index < 0) + ui_state.miplevel_index = 0; + ui_state.ocio_config_source + = std::clamp(ui_state.ocio_config_source, + static_cast(OcioConfigSource::Global), + static_cast(OcioConfigSource::User)); +} + + + +void +reset_per_image_preview_state(ViewRecipe& recipe) +{ + reset_view_recipe(recipe); +} + + + +void +append_longinfo_row(LoadedImage& image, const char* label, + const std::string& value) +{ + if (label == nullptr || label[0] == '\0') + return; + image.longinfo_rows.emplace_back(label, value); +} + +std::string +extract_image_color_space_metadata(const ImageSpec& spec) +{ + static constexpr const char* candidates[] = { + "oiio:ColorSpace", + "ColorSpace", + "colorspace", + }; + for (const char* attr_name : candidates) { + const std::string value = std::string( + Strutil::strip(spec.get_string_attribute(attr_name))); + if (!value.empty()) + return value; + } + return std::string(); +} + +void +build_longinfo_rows(LoadedImage& image, const ImageBuf& source, + const ImageSpec& spec) +{ + image.longinfo_rows.clear(); + if (spec.depth <= 1) { + append_longinfo_row(image, "Dimensions", + Strutil::fmt::format("{} x {} pixels", spec.width, + spec.height)); + } else { + append_longinfo_row(image, "Dimensions", + Strutil::fmt::format("{} x {} x {} pixels", + spec.width, spec.height, + spec.depth)); + } + append_longinfo_row(image, "Channels", + Strutil::fmt::format("{}", spec.nchannels)); + + std::string channel_list; + for (int i = 0; i < spec.nchannels; ++i) { + if (i > 0) + channel_list += ", "; + channel_list += spec.channelnames[i]; + } + append_longinfo_row(image, "Channel list", channel_list); + append_longinfo_row(image, "File format", + std::string(source.file_format_name())); + append_longinfo_row(image, "Data format", std::string(spec.format.c_str())); + append_longinfo_row(image, "Data size", + Strutil::fmt::format( + "{:.2f} MB", static_cast(spec.image_bytes()) + / (1024.0 * 1024.0))); + append_longinfo_row(image, "Image origin", + Strutil::fmt::format("{}, {}, {}", spec.x, spec.y, + spec.z)); + append_longinfo_row(image, "Full/display size", + Strutil::fmt::format("{} x {} x {}", spec.full_width, + spec.full_height, + spec.full_depth)); + append_longinfo_row(image, "Full/display origin", + Strutil::fmt::format("{}, {}, {}", spec.full_x, + spec.full_y, spec.full_z)); + if (spec.tile_width) { + append_longinfo_row(image, "Scanline/tile", + Strutil::fmt::format("tiled {} x {} x {}", + spec.tile_width, + spec.tile_height, + spec.tile_depth)); + } else { + append_longinfo_row(image, "Scanline/tile", "scanline"); + } + if (spec.alpha_channel >= 0) { + append_longinfo_row(image, "Alpha channel", + Strutil::fmt::format("{}", spec.alpha_channel)); + } + if (spec.z_channel >= 0) { + append_longinfo_row(image, "Depth (z) channel", + Strutil::fmt::format("{}", spec.z_channel)); + } + + ParamValueList attribs = spec.extra_attribs; + attribs.sort(false); + for (auto&& p : attribs) { + append_longinfo_row(image, p.name().c_str(), + spec.metadata_val(p, true)); + } +} + + + +bool +load_image_for_compute(const std::string& path, int requested_subimage, + int requested_miplevel, bool rawcolor, + LoadedImage& image, std::string& error_message) +{ + ImageSpec input_config; + const ImageSpec* input_config_ptr = nullptr; + if (rawcolor) { + input_config.attribute("oiio:RawColor", 1); + input_config_ptr = &input_config; + } + + std::shared_ptr imagecache = ImageCache::create(true); + ImageBuf source(path, 0, 0, imagecache, input_config_ptr); + if (!source.read(0, 0, true, TypeUnknown)) { + error_message = source.geterror(); + if (error_message.empty()) + error_message = "failed to read image"; + return false; + } + + int nsubimages = source.nsubimages(); + if (nsubimages <= 0) + nsubimages = 1; + const int resolved_subimage = std::clamp(requested_subimage, 0, + nsubimages - 1); + if (resolved_subimage != 0) { + source.reset(path, resolved_subimage, 0, imagecache, input_config_ptr); + if (!source.read(resolved_subimage, 0, true, TypeUnknown)) { + error_message = source.geterror(); + if (error_message.empty()) + error_message = "failed to read subimage"; + return false; + } + } + + int nmiplevels = source.nmiplevels(); + if (nmiplevels <= 0) + nmiplevels = 1; + const int resolved_miplevel = std::clamp(requested_miplevel, 0, + nmiplevels - 1); + if (resolved_miplevel != source.miplevel()) { + source.reset(path, resolved_subimage, resolved_miplevel, imagecache, + input_config_ptr); + if (!source.read(resolved_subimage, resolved_miplevel, true, + TypeUnknown)) { + error_message = source.geterror(); + if (error_message.empty()) + error_message = "failed to read miplevel"; + return false; + } + } + + const ImageSpec& spec = source.spec(); + if (spec.width <= 0 || spec.height <= 0 || spec.nchannels <= 0) { + error_message = "image has invalid dimensions or channel count"; + return false; + } + + UploadDataType upload_type = UploadDataType::Unknown; + TypeDesc read_format = TypeUnknown; + if (!map_spec_type_to_upload(spec.format, upload_type, read_format)) { + upload_type = UploadDataType::Float; + read_format = TypeFloat; + } + + const size_t width = static_cast(spec.width); + const size_t height = static_cast(spec.height); + const size_t channel_count = static_cast(spec.nchannels); + const size_t channel_bytes = upload_data_type_size(upload_type); + if (channel_bytes == 0) { + error_message = "unsupported pixel data type"; + return false; + } + const size_t row_pitch = width * channel_count * channel_bytes; + const size_t total_size = row_pitch * height; + + std::vector pixels(total_size); + if (!source.get_pixels(source.roi(), read_format, pixels.data())) { + error_message = source.geterror(); + if (error_message.empty()) + error_message = "failed to fetch pixel data"; + return false; + } + + image.path = path; + image.metadata_color_space = extract_image_color_space_metadata(spec); + image.data_format_name = std::string(spec.format.c_str()); + image.width = spec.width; + image.height = spec.height; + image.orientation = clamp_orientation( + spec.get_int_attribute("Orientation", 1)); + image.nchannels = spec.nchannels; + image.subimage = resolved_subimage; + image.miplevel = resolved_miplevel; + image.nsubimages = nsubimages; + image.nmiplevels = nmiplevels; + image.type = upload_type; + image.channel_bytes = channel_bytes; + image.row_pitch_bytes = row_pitch; + image.pixels = std::move(pixels); + image.channel_names.clear(); + image.channel_names.reserve(static_cast(spec.nchannels)); + for (int c = 0; c < spec.nchannels; ++c) + image.channel_names.emplace_back(spec.channelnames[c]); + build_longinfo_rows(image, source, spec); + + if (spec.format != read_format) { + print( + stderr, + "imiv: source format '{}' converted to '{}' for compute upload path\n", + spec.format.c_str(), read_format.c_str()); + } + return true; +} + + + +bool +should_reset_preview_on_load(const ViewerState& viewer, const std::string& path) +{ + if (path.empty()) + return false; + if (viewer.image.path.empty()) + return true; + const std::filesystem::path current_path(viewer.image.path); + const std::filesystem::path next_path(path); + return current_path.lexically_normal() != next_path.lexically_normal(); +} + + + +int +clamp_orientation(int orientation) +{ + return std::clamp(orientation, 1, 8); +} + + + +bool +orientation_swaps_axes(int orientation) +{ + orientation = clamp_orientation(orientation); + return orientation == 5 || orientation == 6 || orientation == 7 + || orientation == 8; +} + + + +void +oriented_image_dimensions(const LoadedImage& image, int& out_width, + int& out_height) +{ + if (orientation_swaps_axes(image.orientation)) { + out_width = image.height; + out_height = image.width; + } else { + out_width = image.width; + out_height = image.height; + } +} + +ImageViewWindow& +append_image_view(MultiViewWorkspace& workspace) +{ + std::unique_ptr view(new ImageViewWindow()); + view->id = workspace.next_view_id++; + workspace.view_windows.push_back(std::move(view)); + return *workspace.view_windows.back(); +} + +ImageViewWindow& +ensure_primary_image_view(MultiViewWorkspace& workspace) +{ + if (workspace.view_windows.empty()) { + ImageViewWindow& primary = append_image_view(workspace); + workspace.active_view_id = primary.id; + } + return *workspace.view_windows.front(); +} + +ImageViewWindow* +find_image_view(MultiViewWorkspace& workspace, int view_id) +{ + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr && view->id == view_id) + return view.get(); + } + return nullptr; +} + +const ImageViewWindow* +find_image_view(const MultiViewWorkspace& workspace, int view_id) +{ + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr && view->id == view_id) + return view.get(); + } + return nullptr; +} + +ImageViewWindow* +active_image_view(MultiViewWorkspace& workspace) +{ + ImageViewWindow* active = find_image_view(workspace, + workspace.active_view_id); + if (active != nullptr) + return active; + if (workspace.view_windows.empty()) + return nullptr; + workspace.active_view_id = workspace.view_windows.front()->id; + return workspace.view_windows.front().get(); +} + +const ImageViewWindow* +active_image_view(const MultiViewWorkspace& workspace) +{ + const ImageViewWindow* active = find_image_view(workspace, + workspace.active_view_id); + if (active != nullptr) + return active; + return workspace.view_windows.empty() + ? nullptr + : workspace.view_windows.front().get(); +} + +void +sync_workspace_library_state(MultiViewWorkspace& workspace, + const ImageLibraryState& library) +{ + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view == nullptr) + continue; + sync_viewer_library_state(view->viewer, library); + } +} + +void +erase_closed_image_views(MultiViewWorkspace& workspace) +{ + if (workspace.view_windows.size() <= 1) + return; + + const int primary_view_id = workspace.view_windows.front() != nullptr + ? workspace.view_windows.front()->id + : 0; + size_t write_index = 0; + const size_t view_count = workspace.view_windows.size(); + for (size_t read_index = 0; read_index < view_count; ++read_index) { + std::unique_ptr& view + = workspace.view_windows[read_index]; + if (view != nullptr && !view->open && view->id != primary_view_id) + continue; + if (write_index != read_index) + workspace.view_windows[write_index] = std::move(view); + ++write_index; + } + workspace.view_windows.resize(write_index); + + if (find_image_view(workspace, workspace.active_view_id) == nullptr) { + const ImageViewWindow& primary = ensure_primary_image_view(workspace); + workspace.active_view_id = primary.id; + } +} + + + +namespace { + +} // namespace +} // namespace Imiv diff --git a/src/imiv/imiv_viewer.h b/src/imiv/imiv_viewer.h new file mode 100644 index 0000000000..56045304c3 --- /dev/null +++ b/src/imiv/imiv_viewer.h @@ -0,0 +1,235 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_backend.h" +#include "imiv_renderer.h" +#include "imiv_style.h" + +#include +#include +#include +#include +#include + +namespace Imiv { + +enum class ImageSortMode : uint8_t { + ByName = 0, + ByPath = 1, + ByImageDate = 2, + ByFileDate = 3 +}; + +enum class OcioConfigSource : uint8_t { Global = 0, BuiltIn = 1, User = 2 }; + +struct ViewRecipe { + bool use_ocio = false; + bool linear_interpolation = false; + int current_channel = 0; + int color_mode = 0; + float exposure = 0.0f; + float gamma = 1.0f; + float offset = 0.0f; + std::string ocio_display = "default"; + std::string ocio_view = "default"; + std::string ocio_image_color_space = "auto"; +}; + +struct ViewerState { + LoadedImage image; + ViewRecipe recipe; + std::string status_message; + std::string last_error; + bool rawcolor = false; + bool no_autopremult = false; + float zoom = 1.0f; + bool fit_request = true; + ImVec2 scroll = ImVec2(0.0f, 0.0f); + ImVec2 norm_scroll = ImVec2(0.5f, 0.5f); + ImVec2 max_scroll = ImVec2(0.0f, 0.0f); + ImVec2 last_viewport_size = ImVec2(0.0f, 0.0f); + ImVec2 zoom_pivot_screen = ImVec2(0.0f, 0.0f); + ImVec2 zoom_pivot_source_uv = ImVec2(0.5f, 0.5f); + bool zoom_pivot_pending = false; + int zoom_pivot_frames_left = 0; + int scroll_sync_frames_left = 0; + std::vector loaded_image_paths; + int current_path_index = -1; + int last_path_index = -1; + std::string toggle_image_path; + std::vector recent_images; + ImageSortMode sort_mode = ImageSortMode::ByName; + bool sort_reverse = false; + bool auto_subimage = false; + int pending_auto_subimage = -1; + float pending_auto_subimage_zoom = 1.0f; + ImVec2 pending_auto_subimage_norm_scroll = ImVec2(0.5f, 0.5f); + bool probe_valid = false; + int probe_x = 0; + int probe_y = 0; + std::vector probe_channels; + bool area_probe_drag_active = false; + ImVec2 area_probe_drag_start_uv = ImVec2(0.0f, 0.0f); + ImVec2 area_probe_drag_end_uv = ImVec2(0.0f, 0.0f); + std::vector area_probe_lines; + bool selection_active = false; + int selection_xbegin = 0; + int selection_ybegin = 0; + int selection_xend = 0; + int selection_yend = 0; + bool selection_press_active = false; + bool selection_drag_active = false; + ImVec2 selection_drag_start_uv = ImVec2(0.0f, 0.0f); + ImVec2 selection_drag_end_uv = ImVec2(0.0f, 0.0f); + ImVec2 selection_drag_start_screen = ImVec2(0.0f, 0.0f); + bool pan_drag_active = false; + bool zoom_drag_active = false; + ImVec2 drag_prev_mouse = ImVec2(0.0f, 0.0f); + bool fullscreen_applied = false; + int windowed_x = 100; + int windowed_y = 100; + int windowed_width = 1600; + int windowed_height = 900; + double slide_last_advance_time = 0.0; + bool drag_overlay_active = false; + std::vector pending_drop_paths; + RendererTexture texture; +}; + +struct ImageLibraryState { + std::vector loaded_image_paths; + std::vector recent_images; + ImageSortMode sort_mode = ImageSortMode::ByName; + bool sort_reverse = false; +}; + +struct ImageViewWindow { + int id = 0; + bool open = true; + bool request_focus = false; + bool force_dock = true; + bool is_docked = false; + ViewerState viewer; +}; + +struct MultiViewWorkspace { + std::vector> view_windows; + int active_view_id = 0; + int next_view_id = 1; + int last_library_image_count = 0; + bool show_image_list_window = false; + bool image_list_request_focus = false; + bool image_list_force_dock = false; + bool image_list_layout_initialized = false; + ImGuiID image_view_dock_id = 0; + ImGuiID image_list_dock_id = 0; + bool image_list_was_drawn = false; + bool image_list_is_docked = false; + ImVec2 image_list_pos = ImVec2(0.0f, 0.0f); + ImVec2 image_list_size = ImVec2(0.0f, 0.0f); + std::vector image_list_item_rects; +}; + +struct PlaceholderUiState { + bool show_info_window = false; + bool show_preferences_window = false; + bool show_preview_window = false; + bool show_pixelview_window = false; + bool show_area_probe_window = false; + bool show_about_window = false; + bool show_window_guides = false; + bool show_mouse_mode_selector = false; + bool fit_image_to_window = false; + bool full_screen_mode = false; + bool window_always_on_top = false; + bool slide_show_running = false; + bool slide_loop = true; + bool use_ocio = false; + bool pixelview_follows_mouse = false; + bool pixelview_left_corner = true; + bool linear_interpolation = false; + bool auto_mipmap = false; + bool image_window_force_dock = true; + + int max_memory_ic_mb = 2048; + int slide_duration_seconds = 10; + int closeup_pixels = 13; + int closeup_avg_pixels = 11; + int current_channel = 0; + int subimage_index = 0; + int miplevel_index = 0; + int color_mode = 0; + int mouse_mode = 0; + int style_preset = static_cast(AppStylePreset::ImGuiDark); + int renderer_backend = static_cast(BackendKind::Auto); + + float exposure = 0.0f; + float gamma = 1.0f; + float offset = 0.0f; + + int ocio_config_source = static_cast(OcioConfigSource::Global); + std::string ocio_display = "default"; + std::string ocio_view = "default"; + std::string ocio_image_color_space = "auto"; + std::string ocio_user_config_path; + const char* focus_window_name = nullptr; +}; + +void +clamp_view_recipe(ViewRecipe& recipe); +void +reset_view_recipe(ViewRecipe& recipe); +void +apply_view_recipe_to_ui_state(const ViewRecipe& recipe, + PlaceholderUiState& ui_state); +void +capture_view_recipe_from_ui_state(const PlaceholderUiState& ui_state, + ViewRecipe& recipe); + +void +reset_view_navigation_state(ViewerState& viewer); +bool +has_image_selection(const ViewerState& viewer); +void +clear_image_selection(ViewerState& viewer); +void +set_image_selection(ViewerState& viewer, int xbegin, int ybegin, int xend, + int yend); +void +clamp_placeholder_ui_state(PlaceholderUiState& ui_state); +void +reset_per_image_preview_state(ViewRecipe& recipe); +int +clamp_orientation(int orientation); +void +oriented_image_dimensions(const LoadedImage& image, int& out_width, + int& out_height); +bool +load_image_for_compute(const std::string& path, int requested_subimage, + int requested_miplevel, bool rawcolor, + LoadedImage& image, std::string& error_message); +bool +should_reset_preview_on_load(const ViewerState& viewer, + const std::string& path); +ImageViewWindow& +ensure_primary_image_view(MultiViewWorkspace& workspace); +ImageViewWindow* +find_image_view(MultiViewWorkspace& workspace, int view_id); +const ImageViewWindow* +find_image_view(const MultiViewWorkspace& workspace, int view_id); +ImageViewWindow* +active_image_view(MultiViewWorkspace& workspace); +const ImageViewWindow* +active_image_view(const MultiViewWorkspace& workspace); +ImageViewWindow& +append_image_view(MultiViewWorkspace& workspace); +void +sync_workspace_library_state(MultiViewWorkspace& workspace, + const ImageLibraryState& library); +void +erase_closed_image_views(MultiViewWorkspace& workspace); + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_ocio.cpp b/src/imiv/imiv_vulkan_ocio.cpp new file mode 100644 index 0000000000..289838de22 --- /dev/null +++ b/src/imiv/imiv_vulkan_ocio.cpp @@ -0,0 +1,619 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_ocio.h" +#include "imiv_vulkan_resource_utils.h" +#include "imiv_vulkan_shader_utils.h" +#include "imiv_vulkan_types.h" + +#include +#include + +#include +#include +#include +#include +#include + +#if defined(IMIV_WITH_VULKAN) && defined(IMIV_HAS_EMBEDDED_VULKAN_SHADERS) \ + && IMIV_HAS_EMBEDDED_VULKAN_SHADERS +# include "imiv_preview_vert_spv.h" +#endif + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +namespace { + + void destroy_ocio_texture(VulkanState& vk_state, OcioVulkanTexture& texture) + { + if (texture.sampler != VK_NULL_HANDLE) { + vkDestroySampler(vk_state.device, texture.sampler, + vk_state.allocator); + texture.sampler = VK_NULL_HANDLE; + } + if (texture.view != VK_NULL_HANDLE) { + vkDestroyImageView(vk_state.device, texture.view, + vk_state.allocator); + texture.view = VK_NULL_HANDLE; + } + if (texture.image != VK_NULL_HANDLE) { + vkDestroyImage(vk_state.device, texture.image, vk_state.allocator); + texture.image = VK_NULL_HANDLE; + } + if (texture.memory != VK_NULL_HANDLE) { + vkFreeMemory(vk_state.device, texture.memory, vk_state.allocator); + texture.memory = VK_NULL_HANDLE; + } + } + + VkFormat select_ocio_format(const OcioTextureBlueprint& texture) + { + if (texture.channel == OcioTextureChannel::Red) + return VK_FORMAT_R16_SFLOAT; + return VK_FORMAT_R16G16B16A16_SFLOAT; + } + + bool check_ocio_format_features(VulkanState& vk_state, VkFormat format, + bool require_linear_filter, + std::string& error_message) + { + VkFormatProperties props = {}; + vkGetPhysicalDeviceFormatProperties(vk_state.physical_device, format, + &props); + const VkFormatFeatureFlags features = props.optimalTilingFeatures; + if ((features & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) == 0) { + error_message = OIIO::Strutil::fmt::format( + "OCIO texture format {} is not sampleable", + static_cast(format)); + return false; + } + if (require_linear_filter + && (features & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT) + == 0) { + error_message = OIIO::Strutil::fmt::format( + "OCIO texture format {} does not support linear filtering", + static_cast(format)); + return false; + } + return true; + } + + bool create_ocio_sampler(VulkanState& vk_state, + const OcioTextureBlueprint& blueprint, + VkSampler& sampler, std::string& error_message) + { + const VkFilter filter = blueprint.interpolation + == OcioInterpolation::Nearest + ? VK_FILTER_NEAREST + : VK_FILTER_LINEAR; + const VkSamplerCreateInfo create_info = make_clamped_sampler_create_info( + filter, filter, + filter == VK_FILTER_NEAREST ? VK_SAMPLER_MIPMAP_MODE_NEAREST + : VK_SAMPLER_MIPMAP_MODE_LINEAR, + 0.0f, 0.0f); + return create_sampler_resource(vk_state, create_info, sampler, + "vkCreateSampler failed for OCIO texture", + "imiv.ocio.texture.sampler", + error_message); + } + + void build_ocio_upload_data(const OcioTextureBlueprint& blueprint, + VkFormat format, + std::vector& upload_bytes) + { + upload_bytes.clear(); + const size_t texel_count = static_cast(blueprint.width) + * static_cast(blueprint.height) + * static_cast( + std::max(1u, blueprint.depth)); + if (format == VK_FORMAT_R16_SFLOAT) { + upload_bytes.resize(texel_count * sizeof(uint16_t)); + auto* dst = reinterpret_cast(upload_bytes.data()); + for (size_t i = 0; i < texel_count; ++i) + dst[i] = half(blueprint.values[i]).bits(); + return; + } + + upload_bytes.resize(texel_count * 4u * sizeof(uint16_t)); + auto* dst = reinterpret_cast(upload_bytes.data()); + for (size_t i = 0; i < texel_count; ++i) { + const size_t src = i * 3u; + const size_t dst_idx = i * 4u; + dst[dst_idx + 0] = half(blueprint.values[src + 0]).bits(); + dst[dst_idx + 1] = half(blueprint.values[src + 1]).bits(); + dst[dst_idx + 2] = half(blueprint.values[src + 2]).bits(); + dst[dst_idx + 3] = half(1.0f).bits(); + } + } + + bool upload_ocio_texture(VulkanState& vk_state, + const OcioTextureBlueprint& blueprint, + OcioVulkanTexture& texture, + std::string& error_message) + { + texture = {}; + texture.binding = blueprint.shader_binding; + texture.width = static_cast(blueprint.width); + texture.height = static_cast(blueprint.height); + texture.depth = static_cast(blueprint.depth); + + const VkFormat format = select_ocio_format(blueprint); + if (!check_ocio_format_features(vk_state, format, + blueprint.interpolation + != OcioInterpolation::Nearest, + error_message)) { + return false; + } + + std::vector upload_bytes; + build_ocio_upload_data(blueprint, format, upload_bytes); + + VkImageCreateInfo image_ci = {}; + image_ci.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + image_ci.imageType = blueprint.dimensions + == OcioTextureDimensions::Tex3D + ? VK_IMAGE_TYPE_3D + : VK_IMAGE_TYPE_2D; + image_ci.format = format; + image_ci.extent.width = blueprint.width; + image_ci.extent.height = std::max(1u, blueprint.height); + image_ci.extent.depth = std::max(1u, blueprint.depth); + image_ci.mipLevels = 1; + image_ci.arrayLayers = 1; + image_ci.samples = VK_SAMPLE_COUNT_1_BIT; + image_ci.tiling = VK_IMAGE_TILING_OPTIMAL; + image_ci.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT + | VK_IMAGE_USAGE_SAMPLED_BIT; + image_ci.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + const VkResult image_err = vkCreateImage(vk_state.device, &image_ci, + vk_state.allocator, + &texture.image); + if (image_err != VK_SUCCESS) { + error_message = "vkCreateImage failed for OCIO texture"; + return false; + } + + if (!allocate_and_bind_image_memory( + vk_state, texture.image, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, + true, texture.memory, + "failed to find memory type for OCIO texture", + "vkAllocateMemory failed for OCIO texture", + "vkBindImageMemory failed for OCIO texture", nullptr, + error_message)) { + return false; + } + + VkBuffer staging_buffer = VK_NULL_HANDLE; + VkDeviceMemory staging_memory = VK_NULL_HANDLE; + if (!create_buffer_with_memory_resource( + vk_state, upload_bytes.size(), VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT + | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + false, staging_buffer, staging_memory, + "vkCreateBuffer failed for OCIO staging texture", + "failed to find host-visible memory for OCIO texture", + "vkAllocateMemory failed for OCIO staging texture", + "vkBindBufferMemory failed for OCIO staging texture", nullptr, + nullptr, error_message)) { + return false; + } + + void* mapped = nullptr; + if (!map_memory_resource(vk_state, staging_memory, upload_bytes.size(), + mapped, + "vkMapMemory failed for OCIO staging texture", + error_message)) { + vkFreeMemory(vk_state.device, staging_memory, vk_state.allocator); + vkDestroyBuffer(vk_state.device, staging_buffer, + vk_state.allocator); + return false; + } + std::memcpy(mapped, upload_bytes.data(), upload_bytes.size()); + vkUnmapMemory(vk_state.device, staging_memory); + + VkImageAspectFlags aspect = VK_IMAGE_ASPECT_COLOR_BIT; + VkCommandBuffer command_buffer = VK_NULL_HANDLE; + if (!begin_immediate_submit(vk_state, command_buffer, error_message)) { + vkFreeMemory(vk_state.device, staging_memory, vk_state.allocator); + vkDestroyBuffer(vk_state.device, staging_buffer, + vk_state.allocator); + return false; + } + + VkImageMemoryBarrier to_transfer = make_color_image_memory_barrier( + texture.image, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, + VK_ACCESS_TRANSFER_WRITE_BIT); + to_transfer.subresourceRange.aspectMask = aspect; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, + nullptr, 1, &to_transfer); + + VkBufferImageCopy region = {}; + region.imageSubresource.aspectMask = aspect; + region.imageSubresource.mipLevel = 0; + region.imageSubresource.baseArrayLayer = 0; + region.imageSubresource.layerCount = 1; + region.imageExtent.width = blueprint.width; + region.imageExtent.height = std::max(1u, blueprint.height); + region.imageExtent.depth = std::max(1u, blueprint.depth); + vkCmdCopyBufferToImage(command_buffer, staging_buffer, texture.image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, + ®ion); + + VkImageMemoryBarrier to_sampled = make_color_image_memory_barrier( + texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT); + to_sampled.subresourceRange.aspectMask = aspect; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, + nullptr, 0, nullptr, 1, &to_sampled); + + if (!end_immediate_submit(vk_state, command_buffer, error_message)) { + vkFreeMemory(vk_state.device, staging_memory, vk_state.allocator); + vkDestroyBuffer(vk_state.device, staging_buffer, + vk_state.allocator); + return false; + } + + vkFreeMemory(vk_state.device, staging_memory, vk_state.allocator); + vkDestroyBuffer(vk_state.device, staging_buffer, vk_state.allocator); + + VkImageViewCreateInfo view_ci = {}; + view_ci.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + view_ci.image = texture.image; + view_ci.viewType = blueprint.dimensions == OcioTextureDimensions::Tex3D + ? VK_IMAGE_VIEW_TYPE_3D + : VK_IMAGE_VIEW_TYPE_2D; + view_ci.format = format; + view_ci.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + view_ci.subresourceRange.baseMipLevel = 0; + view_ci.subresourceRange.levelCount = 1; + view_ci.subresourceRange.baseArrayLayer = 0; + view_ci.subresourceRange.layerCount = 1; + if (!create_image_view_resource( + vk_state, view_ci, texture.view, + "vkCreateImageView failed for OCIO texture", nullptr, + error_message)) { + return false; + } + + return create_ocio_sampler(vk_state, blueprint, texture.sampler, + error_message); + } + + bool create_ocio_uniform_buffer_resource(VulkanState& vk_state, size_t size, + std::string& error_message) + { + if (size == 0) + return true; + + if (!create_buffer_with_memory_resource( + vk_state, size, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT + | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + false, vk_state.ocio.uniform_buffer, + vk_state.ocio.uniform_memory, + "vkCreateBuffer failed for OCIO uniform buffer", + "failed to find memory type for OCIO uniform buffer", + "vkAllocateMemory failed for OCIO uniform buffer", + "vkBindBufferMemory failed for OCIO uniform buffer", nullptr, + nullptr, error_message)) { + return false; + } + if (!map_memory_resource(vk_state, vk_state.ocio.uniform_memory, size, + vk_state.ocio.uniform_mapped, + "vkMapMemory failed for OCIO uniform buffer", + error_message)) { + return false; + } + vk_state.ocio.uniform_buffer_size = size; + return true; + } + + bool update_ocio_uniform_buffer_resource(VulkanState& vk_state, + const PreviewControls& controls, + std::string& error_message) + { + if (vk_state.ocio.runtime == nullptr) + return true; + std::vector uniform_bytes; + if (!build_ocio_uniform_buffer(*vk_state.ocio.runtime, controls, + uniform_bytes, error_message)) { + return false; + } + if (uniform_bytes.empty()) + return true; + if (vk_state.ocio.uniform_mapped == nullptr + || uniform_bytes.size() > vk_state.ocio.uniform_buffer_size) { + error_message = "OCIO uniform buffer is unavailable"; + return false; + } + std::memcpy(vk_state.ocio.uniform_mapped, uniform_bytes.data(), + uniform_bytes.size()); + return true; + } + + bool create_ocio_descriptor_resources(VulkanState& vk_state, + std::string& error_message) + { + const OcioShaderBlueprint& blueprint = vk_state.ocio.runtime->blueprint; + + std::vector bindings; + bindings.reserve(1 + blueprint.textures.size()); + + bindings.push_back(make_descriptor_set_layout_binding( + 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_SHADER_STAGE_FRAGMENT_BIT)); + + for (const OcioTextureBlueprint& texture : blueprint.textures) { + bindings.push_back(make_descriptor_set_layout_binding( + texture.shader_binding, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + VK_SHADER_STAGE_FRAGMENT_BIT)); + } + + const VkDescriptorPoolSize pool_sizes[] + = { { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1 }, + { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + static_cast(blueprint.textures.size()) } }; + const uint32_t pool_size_count = blueprint.textures.empty() ? 1u : 2u; + if (!create_descriptor_pool_resource( + vk_state, 0, 1, pool_sizes, pool_size_count, + vk_state.ocio.descriptor_pool, + "vkCreateDescriptorPool failed for OCIO", + "imiv.ocio.descriptor_pool", error_message)) { + return false; + } + + if (!create_descriptor_set_layout_resource( + vk_state, bindings.data(), + static_cast(bindings.size()), + vk_state.ocio.descriptor_set_layout, + "vkCreateDescriptorSetLayout failed for OCIO", + "imiv.ocio.set_layout", error_message)) { + return false; + } + + if (!allocate_descriptor_set_resource( + vk_state, vk_state.ocio.descriptor_pool, + vk_state.ocio.descriptor_set_layout, + vk_state.ocio.descriptor_set, + "vkAllocateDescriptorSets failed for OCIO", error_message)) { + return false; + } + + std::vector writes; + std::vector image_infos; + writes.reserve(1 + blueprint.textures.size()); + image_infos.reserve(blueprint.textures.size()); + + VkDescriptorBufferInfo buffer_info = {}; + buffer_info.buffer = vk_state.ocio.uniform_buffer; + buffer_info.offset = 0; + buffer_info.range = blueprint.uniform_buffer_size; + + writes.push_back( + make_buffer_descriptor_write(vk_state.ocio.descriptor_set, 0, + VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, + &buffer_info)); + + for (const OcioVulkanTexture& texture : vk_state.ocio.textures) { + VkDescriptorImageInfo info = {}; + info.sampler = texture.sampler; + info.imageView = texture.view; + info.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + image_infos.push_back(info); + } + for (size_t i = 0; i < vk_state.ocio.textures.size(); ++i) { + writes.push_back(make_image_descriptor_write( + vk_state.ocio.descriptor_set, vk_state.ocio.textures[i].binding, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &image_infos[i])); + } + vkUpdateDescriptorSets(vk_state.device, + static_cast(writes.size()), + writes.data(), 0, nullptr); + return true; + } + + bool create_ocio_pipeline(VulkanState& vk_state, std::string& error_message) + { + VkShaderModule vert_module = VK_NULL_HANDLE; + VkShaderModule frag_module = VK_NULL_HANDLE; + std::vector frag_words; + if (!compile_ocio_preview_fragment_spirv( + vk_state.ocio.runtime->blueprint, frag_words, error_message)) { + return false; + } + + const std::string shader_vert = std::string(IMIV_SHADER_DIR) + + "/imiv_preview.vert.spv"; +# if defined(IMIV_HAS_EMBEDDED_VULKAN_SHADERS) \ + && IMIV_HAS_EMBEDDED_VULKAN_SHADERS + const uint32_t* shader_vert_words = g_imiv_preview_vert_spv; + const size_t shader_vert_word_count = g_imiv_preview_vert_spv_word_count; +# else + const uint32_t* shader_vert_words = nullptr; + const size_t shader_vert_word_count = 0; +# endif + if (!create_shader_module_from_embedded_or_file( + vk_state.device, vk_state.allocator, shader_vert_words, + shader_vert_word_count, shader_vert, "imiv.preview.vert", + vert_module, error_message)) { + return false; + } + if (!create_shader_module_from_words( + vk_state.device, vk_state.allocator, frag_words.data(), + frag_words.size(), "imiv.ocio.preview.frag", frag_module, + error_message)) { + vkDestroyShaderModule(vk_state.device, vert_module, + vk_state.allocator); + return false; + } + + VkDescriptorSetLayout set_layouts[2] = { + vk_state.preview_descriptor_set_layout, + vk_state.ocio.descriptor_set_layout, + }; + VkPushConstantRange push = {}; + push.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + push.offset = 0; + push.size = sizeof(PreviewPushConstants); + + if (!create_pipeline_layout_resource( + vk_state, set_layouts, + static_cast(IM_ARRAYSIZE(set_layouts)), &push, 1, + vk_state.ocio.pipeline_layout, + "vkCreatePipelineLayout failed for OCIO preview", + "imiv.ocio.pipeline_layout", error_message)) { + vkDestroyShaderModule(vk_state.device, frag_module, + vk_state.allocator); + vkDestroyShaderModule(vk_state.device, vert_module, + vk_state.allocator); + return false; + } + + const bool pipeline_ok = create_fullscreen_preview_pipeline( + vk_state, vk_state.preview_render_pass, + vk_state.ocio.pipeline_layout, vert_module, frag_module, + "imiv.ocio.preview.pipeline", + "vkCreateGraphicsPipelines failed for OCIO preview", + vk_state.ocio.pipeline, error_message); + vkDestroyShaderModule(vk_state.device, frag_module, vk_state.allocator); + vkDestroyShaderModule(vk_state.device, vert_module, vk_state.allocator); + if (!pipeline_ok) { + return false; + } + return true; + } + +} // namespace + +void +destroy_ocio_preview_resources(VulkanState& vk_state) +{ + destroy_ocio_shader_runtime(vk_state.ocio.runtime); + if (vk_state.ocio.uniform_mapped != nullptr + && vk_state.ocio.uniform_memory != VK_NULL_HANDLE) { + vkUnmapMemory(vk_state.device, vk_state.ocio.uniform_memory); + vk_state.ocio.uniform_mapped = nullptr; + } + for (OcioVulkanTexture& texture : vk_state.ocio.textures) + destroy_ocio_texture(vk_state, texture); + vk_state.ocio.textures.clear(); + if (vk_state.ocio.uniform_buffer != VK_NULL_HANDLE) { + vkDestroyBuffer(vk_state.device, vk_state.ocio.uniform_buffer, + vk_state.allocator); + vk_state.ocio.uniform_buffer = VK_NULL_HANDLE; + } + if (vk_state.ocio.uniform_memory != VK_NULL_HANDLE) { + vkFreeMemory(vk_state.device, vk_state.ocio.uniform_memory, + vk_state.allocator); + vk_state.ocio.uniform_memory = VK_NULL_HANDLE; + } + if (vk_state.ocio.pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(vk_state.device, vk_state.ocio.pipeline, + vk_state.allocator); + vk_state.ocio.pipeline = VK_NULL_HANDLE; + } + if (vk_state.ocio.pipeline_layout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(vk_state.device, vk_state.ocio.pipeline_layout, + vk_state.allocator); + vk_state.ocio.pipeline_layout = VK_NULL_HANDLE; + } + if (vk_state.ocio.descriptor_set_layout != VK_NULL_HANDLE) { + vkDestroyDescriptorSetLayout(vk_state.device, + vk_state.ocio.descriptor_set_layout, + vk_state.allocator); + vk_state.ocio.descriptor_set_layout = VK_NULL_HANDLE; + } + if (vk_state.ocio.descriptor_pool != VK_NULL_HANDLE) { + vkDestroyDescriptorPool(vk_state.device, vk_state.ocio.descriptor_pool, + vk_state.allocator); + vk_state.ocio.descriptor_pool = VK_NULL_HANDLE; + } + vk_state.ocio.descriptor_set = VK_NULL_HANDLE; + vk_state.ocio.uniform_buffer_size = 0; + vk_state.ocio.shader_cache_id.clear(); + vk_state.ocio.ready = false; +} + +bool +ensure_ocio_preview_resources(VulkanState& vk_state, VulkanTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) +{ + error_message.clear(); + if (!controls.use_ocio) + return true; + + OcioShaderRuntime* old_runtime = vk_state.ocio.runtime; + if (!ensure_ocio_shader_runtime(ui_state, image, vk_state.ocio.runtime, + error_message)) { + return false; + } + + const std::string shader_cache_id + = vk_state.ocio.runtime != nullptr + ? vk_state.ocio.runtime->blueprint.shader_cache_id + : std::string(); + const bool needs_rebuild = !vk_state.ocio.ready + || vk_state.ocio.shader_cache_id + != shader_cache_id + || old_runtime != vk_state.ocio.runtime; + if (!needs_rebuild) + return update_ocio_uniform_buffer_resource(vk_state, controls, + error_message); + + if (!quiesce_texture_preview_submission(vk_state, texture, error_message)) { + return false; + } + destroy_ocio_preview_resources(vk_state); + if (!ensure_ocio_shader_runtime(ui_state, image, vk_state.ocio.runtime, + error_message)) { + return false; + } + if (vk_state.ocio.runtime == nullptr) + return true; + + if (!create_ocio_uniform_buffer_resource( + vk_state, vk_state.ocio.runtime->blueprint.uniform_buffer_size, + error_message)) { + destroy_ocio_preview_resources(vk_state); + return false; + } + + vk_state.ocio.textures.reserve( + vk_state.ocio.runtime->blueprint.textures.size()); + for (const OcioTextureBlueprint& texture_bp : + vk_state.ocio.runtime->blueprint.textures) { + vk_state.ocio.textures.emplace_back(); + if (!upload_ocio_texture(vk_state, texture_bp, + vk_state.ocio.textures.back(), + error_message)) { + destroy_ocio_preview_resources(vk_state); + return false; + } + } + + if (!create_ocio_descriptor_resources(vk_state, error_message) + || !create_ocio_pipeline(vk_state, error_message) + || !update_ocio_uniform_buffer_resource(vk_state, controls, + error_message)) { + destroy_ocio_preview_resources(vk_state); + return false; + } + + vk_state.ocio.shader_cache_id = shader_cache_id; + vk_state.ocio.ready = true; + return true; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_preview.cpp b/src/imiv/imiv_vulkan_preview.cpp new file mode 100644 index 0000000000..907d36f584 --- /dev/null +++ b/src/imiv/imiv_vulkan_preview.cpp @@ -0,0 +1,317 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_vulkan_resource_utils.h" +#include "imiv_vulkan_texture_internal.h" +#include "imiv_vulkan_types.h" + +#include +#include + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +namespace { + + bool ensure_texture_preview_submit_resources(VulkanState& vk_state, + VulkanTexture& texture, + std::string& error_message) + { + return ensure_async_submit_resources( + vk_state, texture.preview_command_pool, + texture.preview_command_buffer, texture.preview_submit_fence, + "vkCreateCommandPool failed for preview async submit", + "vkAllocateCommandBuffers failed for preview async submit", + "vkCreateFence failed for preview async submit", + "imiv.preview_async.command_pool", + "imiv.preview_async.command_buffer", "imiv.preview_async.fence", + error_message); + } + + bool poll_texture_preview_submission(VulkanState& vk_state, + VulkanTexture& texture, + const PreviewControls& controls, + bool wait_for_completion, + std::string& error_message) + { + if (!texture.preview_submit_pending) + return true; + if (texture.preview_submit_fence == VK_NULL_HANDLE) { + texture.preview_submit_pending = false; + error_message = "preview submit fence is unavailable"; + return false; + } + + VkResult err = VK_SUCCESS; + if (wait_for_completion) { + err = vkWaitForFences(vk_state.device, 1, + &texture.preview_submit_fence, VK_TRUE, + UINT64_MAX); + } else { + err = vkGetFenceStatus(vk_state.device, + texture.preview_submit_fence); + if (err == VK_NOT_READY) + return false; + } + if (err != VK_SUCCESS) { + error_message + = wait_for_completion + ? "vkWaitForFences failed for preview async submit" + : "vkGetFenceStatus failed for preview async submit"; + check_vk_result(err); + return false; + } + + texture.preview_submit_pending = false; + texture.preview_initialized = true; + texture.preview_params_valid = true; + texture.last_preview_controls = texture.preview_submit_controls; + texture.preview_dirty + = !preview_controls_equal(texture.last_preview_controls, controls); + return true; + } + +} // namespace + +void +destroy_texture_preview_submit_resources(VulkanState& vk_state, + VulkanTexture& texture) +{ + destroy_async_submit_resources(vk_state, texture.preview_command_pool, + texture.preview_command_buffer, + texture.preview_submit_fence); + texture.preview_submit_pending = false; +} + +bool +quiesce_texture_preview_submission(VulkanState& vk_state, + VulkanTexture& texture, + std::string& error_message) +{ + error_message.clear(); + if (!texture.preview_submit_pending) + return true; + return poll_texture_preview_submission(vk_state, texture, + texture.preview_submit_controls, + true, error_message); +} + +bool +update_preview_texture(VulkanState& vk_state, VulkanTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message) +{ + if (texture.image == VK_NULL_HANDLE + || texture.source_image == VK_NULL_HANDLE + || texture.preview_framebuffer == VK_NULL_HANDLE + || texture.preview_source_set == VK_NULL_HANDLE) { + return false; + } + + error_message.clear(); + PreviewControls effective_controls = controls; + if (!poll_texture_upload_submission(vk_state, texture, false, + error_message)) { + if (error_message.empty()) + return true; + return false; + } + if (!texture.source_ready) + return true; + + const bool had_ocio_ready = vk_state.ocio.ready; + const OcioShaderRuntime* old_ocio_runtime = vk_state.ocio.runtime; + const std::string old_ocio_shader_cache_id = vk_state.ocio.shader_cache_id; + if (controls.use_ocio != 0 + && !ensure_ocio_preview_resources(vk_state, texture, image, ui_state, + controls, error_message)) { + // When OCIO is unavailable, keep the image visible by falling back to + // the standard preview shader instead of failing the whole preview + // update. + if (!quiesce_texture_preview_submission(vk_state, texture, + error_message)) { + return false; + } + destroy_ocio_preview_resources(vk_state); + effective_controls.use_ocio = 0; + texture.preview_dirty = true; + texture.preview_params_valid = false; + error_message.clear(); + } + if (effective_controls.use_ocio != 0 + && (had_ocio_ready != vk_state.ocio.ready + || old_ocio_runtime != vk_state.ocio.runtime + || old_ocio_shader_cache_id != vk_state.ocio.shader_cache_id)) { + texture.preview_dirty = true; + texture.preview_params_valid = false; + } + + if (!poll_texture_preview_submission(vk_state, texture, effective_controls, + false, error_message)) { + if (error_message.empty()) + return true; + return false; + } + + if (texture.preview_params_valid + && preview_controls_equal(texture.last_preview_controls, + effective_controls) + && !texture.preview_dirty) { + return true; + } + + if (!ensure_texture_preview_submit_resources(vk_state, texture, + error_message)) { + return false; + } + + if (texture.preview_submit_pending) + return true; + + bool preview_fence_signaled = false; + if (!nonblocking_fence_status(vk_state.device, texture.preview_submit_fence, + "preview async submit", + preview_fence_signaled, error_message)) { + return false; + } + if (!preview_fence_signaled) + return true; + + VkResult err = VK_SUCCESS; + err = vkResetCommandPool(vk_state.device, texture.preview_command_pool, 0); + if (err != VK_SUCCESS) { + error_message = "vkResetCommandPool failed for preview async submit"; + return false; + } + + VkCommandBuffer command_buffer = texture.preview_command_buffer; + + VkCommandBufferBeginInfo begin = {}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + err = vkBeginCommandBuffer(command_buffer, &begin); + if (err != VK_SUCCESS) { + error_message = "vkBeginCommandBuffer failed for preview update"; + return false; + } + + VkImageMemoryBarrier to_color_attachment = make_color_image_memory_barrier( + texture.image, + texture.preview_initialized ? VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL + : VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + texture.preview_initialized ? VK_ACCESS_SHADER_READ_BIT : 0, + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT); + vkCmdPipelineBarrier(command_buffer, + texture.preview_initialized + ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT + : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, + nullptr, 0, nullptr, 1, &to_color_attachment); + + VkClearValue clear = {}; + clear.color.float32[0] = 0.0f; + clear.color.float32[1] = 0.0f; + clear.color.float32[2] = 0.0f; + clear.color.float32[3] = 1.0f; + + VkRenderPassBeginInfo rp_begin = {}; + rp_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rp_begin.renderPass = vk_state.preview_render_pass; + rp_begin.framebuffer = texture.preview_framebuffer; + rp_begin.renderArea.offset = { 0, 0 }; + rp_begin.renderArea.extent.width = static_cast(texture.width); + rp_begin.renderArea.extent.height = static_cast(texture.height); + rp_begin.clearValueCount = 1; + rp_begin.pClearValues = &clear; + + vkCmdBeginRenderPass(command_buffer, &rp_begin, VK_SUBPASS_CONTENTS_INLINE); + VkViewport vp = {}; + vp.x = 0.0f; + vp.y = 0.0f; + vp.width = static_cast(texture.width); + vp.height = static_cast(texture.height); + vp.minDepth = 0.0f; + vp.maxDepth = 1.0f; + VkRect2D scissor = {}; + scissor.extent.width = static_cast(texture.width); + scissor.extent.height = static_cast(texture.height); + vkCmdSetViewport(command_buffer, 0, 1, &vp); + vkCmdSetScissor(command_buffer, 0, 1, &scissor); + const bool use_ocio_pipeline + = effective_controls.use_ocio != 0 && vk_state.ocio.ready + && vk_state.ocio.pipeline != VK_NULL_HANDLE + && vk_state.ocio.pipeline_layout != VK_NULL_HANDLE + && vk_state.ocio.descriptor_set != VK_NULL_HANDLE; + const VkPipeline pipeline = use_ocio_pipeline ? vk_state.ocio.pipeline + : vk_state.preview_pipeline; + const VkPipelineLayout pipeline_layout + = use_ocio_pipeline ? vk_state.ocio.pipeline_layout + : vk_state.preview_pipeline_layout; + vkCmdBindPipeline(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipeline); + vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipeline_layout, 0, 1, &texture.preview_source_set, + 0, nullptr); + if (use_ocio_pipeline) { + vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipeline_layout, 1, 1, + &vk_state.ocio.descriptor_set, 0, nullptr); + } + + PreviewPushConstants push = {}; + push.exposure = effective_controls.exposure; + push.gamma = std::max(0.01f, effective_controls.gamma); + push.offset = effective_controls.offset; + push.color_mode = effective_controls.color_mode; + push.channel = effective_controls.channel; + push.use_ocio = effective_controls.use_ocio; + push.orientation = effective_controls.orientation; + vkCmdPushConstants(command_buffer, pipeline_layout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(push), &push); + vkCmdDraw(command_buffer, 3, 1, 0, 0); + vkCmdEndRenderPass(command_buffer); + + VkImageMemoryBarrier to_shader_read = make_color_image_memory_barrier( + texture.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT); + vkCmdPipelineBarrier(command_buffer, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, + 0, nullptr, 1, &to_shader_read); + + err = vkEndCommandBuffer(command_buffer); + if (err != VK_SUCCESS) { + error_message = "vkEndCommandBuffer failed for preview update"; + return false; + } + err = vkResetFences(vk_state.device, 1, &texture.preview_submit_fence); + if (err != VK_SUCCESS) { + error_message = "vkResetFences failed for preview async submit"; + return false; + } + + VkSubmitInfo submit = {}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &command_buffer; + err = vkQueueSubmit(vk_state.queue, 1, &submit, + texture.preview_submit_fence); + if (err != VK_SUCCESS) { + error_message = "vkQueueSubmit failed for preview update"; + return false; + } + + texture.preview_submit_pending = true; + texture.preview_submit_controls = effective_controls; + return true; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_resource_utils.cpp b/src/imiv/imiv_vulkan_resource_utils.cpp new file mode 100644 index 0000000000..eecf4635c7 --- /dev/null +++ b/src/imiv/imiv_vulkan_resource_utils.cpp @@ -0,0 +1,525 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_vulkan_resource_utils.h" + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +namespace { + + bool allocate_memory_resource( + VulkanState& vk_state, uint32_t memory_type_bits, + VkMemoryPropertyFlags preferred_properties, + bool allow_property_fallback, VkDeviceSize allocation_size, + VkDeviceMemory& memory, const char* no_memory_type_error, + const char* allocate_error, const char* debug_memory_name, + std::string& error_message) + { + memory = VK_NULL_HANDLE; + + uint32_t memory_type_index = 0; + const bool have_memory_type + = allow_property_fallback + ? find_memory_type_with_fallback(vk_state.physical_device, + memory_type_bits, + preferred_properties, + memory_type_index) + : find_memory_type(vk_state.physical_device, memory_type_bits, + preferred_properties, memory_type_index); + if (!have_memory_type) { + error_message = no_memory_type_error; + return false; + } + + VkMemoryAllocateInfo alloc = {}; + alloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + alloc.allocationSize = allocation_size; + alloc.memoryTypeIndex = memory_type_index; + const VkResult alloc_err = vkAllocateMemory(vk_state.device, &alloc, + vk_state.allocator, + &memory); + if (alloc_err != VK_SUCCESS) { + error_message = allocate_error; + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_DEVICE_MEMORY, memory, + debug_memory_name); + return true; + } + +} // namespace + +bool +allocate_and_bind_image_memory( + VulkanState& vk_state, VkImage image, + VkMemoryPropertyFlags preferred_properties, bool allow_property_fallback, + VkDeviceMemory& memory, const char* no_memory_type_error, + const char* allocate_error, const char* bind_error, + const char* debug_memory_name, std::string& error_message) +{ + VkMemoryRequirements requirements = {}; + vkGetImageMemoryRequirements(vk_state.device, image, &requirements); + if (!allocate_memory_resource(vk_state, requirements.memoryTypeBits, + preferred_properties, allow_property_fallback, + requirements.size, memory, + no_memory_type_error, allocate_error, + debug_memory_name, error_message)) { + return false; + } + + const VkResult bind_err = vkBindImageMemory(vk_state.device, image, memory, + 0); + if (bind_err != VK_SUCCESS) { + error_message = bind_error; + return false; + } + + return true; +} + +bool +allocate_and_bind_buffer_memory( + VulkanState& vk_state, VkBuffer buffer, + VkMemoryPropertyFlags preferred_properties, bool allow_property_fallback, + VkDeviceMemory& memory, const char* no_memory_type_error, + const char* allocate_error, const char* bind_error, + const char* debug_memory_name, std::string& error_message) +{ + VkMemoryRequirements requirements = {}; + vkGetBufferMemoryRequirements(vk_state.device, buffer, &requirements); + if (!allocate_memory_resource(vk_state, requirements.memoryTypeBits, + preferred_properties, allow_property_fallback, + requirements.size, memory, + no_memory_type_error, allocate_error, + debug_memory_name, error_message)) { + return false; + } + + const VkResult bind_err = vkBindBufferMemory(vk_state.device, buffer, + memory, 0); + if (bind_err != VK_SUCCESS) { + error_message = bind_error; + return false; + } + + return true; +} + +bool +create_buffer_resource(VulkanState& vk_state, VkDeviceSize size, + VkBufferUsageFlags usage, VkBuffer& buffer, + const char* create_error, const char* debug_name, + std::string& error_message) +{ + buffer = VK_NULL_HANDLE; + VkBufferCreateInfo ci = {}; + ci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + ci.size = size; + ci.usage = usage; + ci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + const VkResult err = vkCreateBuffer(vk_state.device, &ci, + vk_state.allocator, &buffer); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + if (debug_name != nullptr) { + set_vk_object_name(vk_state, VK_OBJECT_TYPE_BUFFER, buffer, debug_name); + } + return true; +} + +bool +create_buffer_with_memory_resource( + VulkanState& vk_state, VkDeviceSize size, VkBufferUsageFlags usage, + VkMemoryPropertyFlags preferred_properties, bool allow_property_fallback, + VkBuffer& buffer, VkDeviceMemory& memory, const char* create_error, + const char* no_memory_type_error, const char* allocate_error, + const char* bind_error, const char* debug_buffer_name, + const char* debug_memory_name, std::string& error_message) +{ + memory = VK_NULL_HANDLE; + if (!create_buffer_resource(vk_state, size, usage, buffer, create_error, + debug_buffer_name, error_message)) { + return false; + } + + if (!allocate_and_bind_buffer_memory(vk_state, buffer, preferred_properties, + allow_property_fallback, memory, + no_memory_type_error, allocate_error, + bind_error, debug_memory_name, + error_message)) { + vkDestroyBuffer(vk_state.device, buffer, vk_state.allocator); + buffer = VK_NULL_HANDLE; + return false; + } + + return true; +} + +bool +create_image_view_resource(VulkanState& vk_state, + const VkImageViewCreateInfo& create_info, + VkImageView& image_view, const char* create_error, + const char* debug_name, std::string& error_message) +{ + image_view = VK_NULL_HANDLE; + const VkResult err = vkCreateImageView(vk_state.device, &create_info, + vk_state.allocator, &image_view); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + set_vk_object_name(vk_state, VK_OBJECT_TYPE_IMAGE_VIEW, image_view, + debug_name); + return true; +} + +VkSamplerCreateInfo +make_clamped_sampler_create_info(VkFilter min_filter, VkFilter mag_filter, + VkSamplerMipmapMode mipmap_mode, float min_lod, + float max_lod) +{ + VkSamplerCreateInfo create_info = {}; + create_info.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + create_info.minFilter = min_filter; + create_info.magFilter = mag_filter; + create_info.mipmapMode = mipmap_mode; + create_info.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + create_info.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + create_info.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + create_info.minLod = min_lod; + create_info.maxLod = max_lod; + create_info.maxAnisotropy = 1.0f; + return create_info; +} + +bool +create_sampler_resource(VulkanState& vk_state, + const VkSamplerCreateInfo& create_info, + VkSampler& sampler, const char* create_error, + const char* debug_name, std::string& error_message) +{ + sampler = VK_NULL_HANDLE; + const VkResult err = vkCreateSampler(vk_state.device, &create_info, + vk_state.allocator, &sampler); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + if (debug_name != nullptr) { + set_vk_object_name(vk_state, VK_OBJECT_TYPE_SAMPLER, sampler, + debug_name); + } + return true; +} + +VkDescriptorSetLayoutBinding +make_descriptor_set_layout_binding(uint32_t binding, + VkDescriptorType descriptor_type, + VkShaderStageFlags stage_flags) +{ + VkDescriptorSetLayoutBinding layout_binding = {}; + layout_binding.binding = binding; + layout_binding.descriptorType = descriptor_type; + layout_binding.descriptorCount = 1; + layout_binding.stageFlags = stage_flags; + return layout_binding; +} + +bool +create_descriptor_pool_resource( + VulkanState& vk_state, VkDescriptorPoolCreateFlags flags, uint32_t max_sets, + const VkDescriptorPoolSize* pool_sizes, uint32_t pool_size_count, + VkDescriptorPool& descriptor_pool, const char* create_error, + const char* debug_name, std::string& error_message) +{ + descriptor_pool = VK_NULL_HANDLE; + VkDescriptorPoolCreateInfo pool = {}; + pool.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + pool.flags = flags; + pool.maxSets = max_sets; + pool.poolSizeCount = pool_size_count; + pool.pPoolSizes = pool_sizes; + const VkResult err = vkCreateDescriptorPool(vk_state.device, &pool, + vk_state.allocator, + &descriptor_pool); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + set_vk_object_name(vk_state, VK_OBJECT_TYPE_DESCRIPTOR_POOL, + descriptor_pool, debug_name); + return true; +} + +bool +create_descriptor_set_layout_resource( + VulkanState& vk_state, const VkDescriptorSetLayoutBinding* bindings, + uint32_t binding_count, VkDescriptorSetLayout& descriptor_set_layout, + const char* create_error, const char* debug_name, + std::string& error_message) +{ + descriptor_set_layout = VK_NULL_HANDLE; + VkDescriptorSetLayoutCreateInfo layout = {}; + layout.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layout.bindingCount = binding_count; + layout.pBindings = bindings; + const VkResult err = vkCreateDescriptorSetLayout(vk_state.device, &layout, + vk_state.allocator, + &descriptor_set_layout); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + set_vk_object_name(vk_state, VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, + descriptor_set_layout, debug_name); + return true; +} + +bool +allocate_descriptor_set_resource(VulkanState& vk_state, + VkDescriptorPool descriptor_pool, + VkDescriptorSetLayout descriptor_set_layout, + VkDescriptorSet& descriptor_set, + const char* allocate_error, + std::string& error_message) +{ + descriptor_set = VK_NULL_HANDLE; + VkDescriptorSetAllocateInfo allocate = {}; + allocate.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocate.descriptorPool = descriptor_pool; + allocate.descriptorSetCount = 1; + allocate.pSetLayouts = &descriptor_set_layout; + const VkResult err = vkAllocateDescriptorSets(vk_state.device, &allocate, + &descriptor_set); + if (err != VK_SUCCESS) { + error_message = allocate_error; + return false; + } + + return true; +} + +bool +create_pipeline_layout_resource( + VulkanState& vk_state, const VkDescriptorSetLayout* set_layouts, + uint32_t set_layout_count, const VkPushConstantRange* push_constant_ranges, + uint32_t push_constant_range_count, VkPipelineLayout& pipeline_layout, + const char* create_error, const char* debug_name, + std::string& error_message) +{ + pipeline_layout = VK_NULL_HANDLE; + VkPipelineLayoutCreateInfo layout = {}; + layout.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + layout.setLayoutCount = set_layout_count; + layout.pSetLayouts = set_layouts; + layout.pushConstantRangeCount = push_constant_range_count; + layout.pPushConstantRanges = push_constant_ranges; + const VkResult err = vkCreatePipelineLayout(vk_state.device, &layout, + vk_state.allocator, + &pipeline_layout); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + if (debug_name != nullptr) { + set_vk_object_name(vk_state, VK_OBJECT_TYPE_PIPELINE_LAYOUT, + pipeline_layout, debug_name); + } + return true; +} + +VkWriteDescriptorSet +make_buffer_descriptor_write(VkDescriptorSet descriptor_set, uint32_t binding, + VkDescriptorType descriptor_type, + const VkDescriptorBufferInfo* buffer_info) +{ + VkWriteDescriptorSet write = {}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = descriptor_set; + write.dstBinding = binding; + write.descriptorCount = 1; + write.descriptorType = descriptor_type; + write.pBufferInfo = buffer_info; + return write; +} + +VkWriteDescriptorSet +make_image_descriptor_write(VkDescriptorSet descriptor_set, uint32_t binding, + VkDescriptorType descriptor_type, + const VkDescriptorImageInfo* image_info) +{ + VkWriteDescriptorSet write = {}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = descriptor_set; + write.dstBinding = binding; + write.descriptorCount = 1; + write.descriptorType = descriptor_type; + write.pImageInfo = image_info; + return write; +} + +VkImageMemoryBarrier +make_color_image_memory_barrier(VkImage image, VkImageLayout old_layout, + VkImageLayout new_layout, + VkAccessFlags src_access_mask, + VkAccessFlags dst_access_mask) +{ + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = old_layout; + barrier.newLayout = new_layout; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = src_access_mask; + barrier.dstAccessMask = dst_access_mask; + return barrier; +} + +bool +nonblocking_fence_status(VkDevice device, VkFence fence, const char* context, + bool& out_signaled, std::string& error_message) +{ + out_signaled = false; + if (fence == VK_NULL_HANDLE) { + error_message = std::string(context) + " fence is unavailable"; + return false; + } + + const VkResult err = vkGetFenceStatus(device, fence); + if (err == VK_SUCCESS) { + out_signaled = true; + return true; + } + if (err == VK_NOT_READY) + return true; + + error_message = std::string("vkGetFenceStatus failed for ") + context; + check_vk_result(err); + return false; +} + +bool +ensure_async_submit_resources( + VulkanState& vk_state, VkCommandPool& command_pool, + VkCommandBuffer& command_buffer, VkFence& submit_fence, + const char* command_pool_error, const char* command_buffer_error, + const char* fence_error, const char* command_pool_debug_name, + const char* command_buffer_debug_name, const char* fence_debug_name, + std::string& error_message) +{ + if (command_pool != VK_NULL_HANDLE && command_buffer != VK_NULL_HANDLE + && submit_fence != VK_NULL_HANDLE) { + return true; + } + + destroy_async_submit_resources(vk_state, command_pool, command_buffer, + submit_fence); + + VkCommandPool new_command_pool = VK_NULL_HANDLE; + VkCommandBuffer new_command_buffer = VK_NULL_HANDLE; + VkFence new_submit_fence = VK_NULL_HANDLE; + + VkCommandPoolCreateInfo pool_ci = {}; + pool_ci.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_ci.flags = VK_COMMAND_POOL_CREATE_TRANSIENT_BIT + | VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_ci.queueFamilyIndex = vk_state.queue_family; + VkResult err = vkCreateCommandPool(vk_state.device, &pool_ci, + vk_state.allocator, &new_command_pool); + if (err != VK_SUCCESS) { + error_message = command_pool_error; + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_COMMAND_POOL, new_command_pool, + command_pool_debug_name); + + VkCommandBufferAllocateInfo command_alloc = {}; + command_alloc.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + command_alloc.commandPool = new_command_pool; + command_alloc.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + command_alloc.commandBufferCount = 1; + err = vkAllocateCommandBuffers(vk_state.device, &command_alloc, + &new_command_buffer); + if (err != VK_SUCCESS) { + vkDestroyCommandPool(vk_state.device, new_command_pool, + vk_state.allocator); + error_message = command_buffer_error; + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_COMMAND_BUFFER, + new_command_buffer, command_buffer_debug_name); + + VkFenceCreateInfo fence_ci = {}; + fence_ci.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + fence_ci.flags = VK_FENCE_CREATE_SIGNALED_BIT; + err = vkCreateFence(vk_state.device, &fence_ci, vk_state.allocator, + &new_submit_fence); + if (err != VK_SUCCESS) { + vkDestroyCommandPool(vk_state.device, new_command_pool, + vk_state.allocator); + error_message = fence_error; + return false; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_FENCE, new_submit_fence, + fence_debug_name); + + command_pool = new_command_pool; + command_buffer = new_command_buffer; + submit_fence = new_submit_fence; + return true; +} + +void +destroy_async_submit_resources(VulkanState& vk_state, + VkCommandPool& command_pool, + VkCommandBuffer& command_buffer, + VkFence& submit_fence) +{ + if (submit_fence != VK_NULL_HANDLE) { + vkDestroyFence(vk_state.device, submit_fence, vk_state.allocator); + submit_fence = VK_NULL_HANDLE; + } + command_buffer = VK_NULL_HANDLE; + if (command_pool != VK_NULL_HANDLE) { + vkDestroyCommandPool(vk_state.device, command_pool, vk_state.allocator); + command_pool = VK_NULL_HANDLE; + } +} + +bool +map_memory_resource(VulkanState& vk_state, VkDeviceMemory memory, + VkDeviceSize size, void*& mapped, const char* map_error, + std::string& error_message) +{ + mapped = nullptr; + const VkResult err = vkMapMemory(vk_state.device, memory, 0, size, 0, + &mapped); + if (err != VK_SUCCESS || mapped == nullptr) { + error_message = map_error; + mapped = nullptr; + return false; + } + + return true; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_resource_utils.h b/src/imiv/imiv_vulkan_resource_utils.h new file mode 100644 index 0000000000..b02dbd01a3 --- /dev/null +++ b/src/imiv/imiv_vulkan_resource_utils.h @@ -0,0 +1,122 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_vulkan_types.h" + +#include + +namespace Imiv { + +#if IMIV_WITH_VULKAN + +bool +allocate_and_bind_image_memory( + VulkanState& vk_state, VkImage image, + VkMemoryPropertyFlags preferred_properties, bool allow_property_fallback, + VkDeviceMemory& memory, const char* no_memory_type_error, + const char* allocate_error, const char* bind_error, + const char* debug_memory_name, std::string& error_message); +bool +allocate_and_bind_buffer_memory( + VulkanState& vk_state, VkBuffer buffer, + VkMemoryPropertyFlags preferred_properties, bool allow_property_fallback, + VkDeviceMemory& memory, const char* no_memory_type_error, + const char* allocate_error, const char* bind_error, + const char* debug_memory_name, std::string& error_message); +bool +create_buffer_resource(VulkanState& vk_state, VkDeviceSize size, + VkBufferUsageFlags usage, VkBuffer& buffer, + const char* create_error, const char* debug_name, + std::string& error_message); +bool +create_buffer_with_memory_resource( + VulkanState& vk_state, VkDeviceSize size, VkBufferUsageFlags usage, + VkMemoryPropertyFlags preferred_properties, bool allow_property_fallback, + VkBuffer& buffer, VkDeviceMemory& memory, const char* create_error, + const char* no_memory_type_error, const char* allocate_error, + const char* bind_error, const char* debug_buffer_name, + const char* debug_memory_name, std::string& error_message); +bool +create_image_view_resource(VulkanState& vk_state, + const VkImageViewCreateInfo& create_info, + VkImageView& image_view, const char* create_error, + const char* debug_name, std::string& error_message); +VkSamplerCreateInfo +make_clamped_sampler_create_info(VkFilter min_filter, VkFilter mag_filter, + VkSamplerMipmapMode mipmap_mode, float min_lod, + float max_lod); +bool +create_sampler_resource(VulkanState& vk_state, + const VkSamplerCreateInfo& create_info, + VkSampler& sampler, const char* create_error, + const char* debug_name, std::string& error_message); +VkDescriptorSetLayoutBinding +make_descriptor_set_layout_binding(uint32_t binding, + VkDescriptorType descriptor_type, + VkShaderStageFlags stage_flags); +bool +create_descriptor_pool_resource( + VulkanState& vk_state, VkDescriptorPoolCreateFlags flags, uint32_t max_sets, + const VkDescriptorPoolSize* pool_sizes, uint32_t pool_size_count, + VkDescriptorPool& descriptor_pool, const char* create_error, + const char* debug_name, std::string& error_message); +bool +create_descriptor_set_layout_resource( + VulkanState& vk_state, const VkDescriptorSetLayoutBinding* bindings, + uint32_t binding_count, VkDescriptorSetLayout& descriptor_set_layout, + const char* create_error, const char* debug_name, + std::string& error_message); +bool +allocate_descriptor_set_resource(VulkanState& vk_state, + VkDescriptorPool descriptor_pool, + VkDescriptorSetLayout descriptor_set_layout, + VkDescriptorSet& descriptor_set, + const char* allocate_error, + std::string& error_message); +bool +create_pipeline_layout_resource( + VulkanState& vk_state, const VkDescriptorSetLayout* set_layouts, + uint32_t set_layout_count, const VkPushConstantRange* push_constant_ranges, + uint32_t push_constant_range_count, VkPipelineLayout& pipeline_layout, + const char* create_error, const char* debug_name, + std::string& error_message); +VkWriteDescriptorSet +make_buffer_descriptor_write(VkDescriptorSet descriptor_set, uint32_t binding, + VkDescriptorType descriptor_type, + const VkDescriptorBufferInfo* buffer_info); +VkWriteDescriptorSet +make_image_descriptor_write(VkDescriptorSet descriptor_set, uint32_t binding, + VkDescriptorType descriptor_type, + const VkDescriptorImageInfo* image_info); +VkImageMemoryBarrier +make_color_image_memory_barrier(VkImage image, VkImageLayout old_layout, + VkImageLayout new_layout, + VkAccessFlags src_access_mask, + VkAccessFlags dst_access_mask); +bool +nonblocking_fence_status(VkDevice device, VkFence fence, const char* context, + bool& out_signaled, std::string& error_message); +bool +ensure_async_submit_resources( + VulkanState& vk_state, VkCommandPool& command_pool, + VkCommandBuffer& command_buffer, VkFence& submit_fence, + const char* command_pool_error, const char* command_buffer_error, + const char* fence_error, const char* command_pool_debug_name, + const char* command_buffer_debug_name, const char* fence_debug_name, + std::string& error_message); +void +destroy_async_submit_resources(VulkanState& vk_state, + VkCommandPool& command_pool, + VkCommandBuffer& command_buffer, + VkFence& submit_fence); +bool +map_memory_resource(VulkanState& vk_state, VkDeviceMemory memory, + VkDeviceSize size, void*& mapped, const char* map_error, + std::string& error_message); + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_runtime.cpp b/src/imiv/imiv_vulkan_runtime.cpp new file mode 100644 index 0000000000..ed5d6a6830 --- /dev/null +++ b/src/imiv/imiv_vulkan_runtime.cpp @@ -0,0 +1,249 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_vulkan_types.h" + +#include +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +namespace { + + const char* texture_status_name(ImTextureStatus status) + { + switch (status) { + case ImTextureStatus_OK: return "ok"; + case ImTextureStatus_Destroyed: return "destroyed"; + case ImTextureStatus_WantCreate: return "want_create"; + case ImTextureStatus_WantUpdates: return "want_updates"; + case ImTextureStatus_WantDestroy: return "want_destroy"; + default: break; + } + return "unknown"; + } + + unsigned short clamp_u16(int value) + { + if (value <= 0) + return 0; + if (value + > static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(value); + } + +} // namespace + +void +name_window_frame_objects(VulkanState& vk_state) +{ + if (vk_state.set_debug_object_name_fn == nullptr) + return; + + ImGui_ImplVulkanH_Window* wd = &vk_state.window_data; + set_vk_object_name(vk_state, VK_OBJECT_TYPE_SWAPCHAIN_KHR, wd->Swapchain, + "imiv.main.swapchain"); + set_vk_object_name(vk_state, VK_OBJECT_TYPE_RENDER_PASS, wd->RenderPass, + "imiv.main.render_pass"); + + for (int i = 0; i < wd->Frames.Size; ++i) { + char buffer_name[64] = {}; + std::snprintf(buffer_name, sizeof(buffer_name), + "imiv.main.frame[%d].command_buffer", i); + set_vk_object_name(vk_state, VK_OBJECT_TYPE_COMMAND_BUFFER, + wd->Frames[i].CommandBuffer, buffer_name); + + char image_name[64] = {}; + std::snprintf(image_name, sizeof(image_name), + "imiv.main.frame[%d].backbuffer", i); + set_vk_object_name(vk_state, VK_OBJECT_TYPE_IMAGE, + wd->Frames[i].Backbuffer, image_name); + } +} + +void +apply_imgui_texture_update_workarounds(VulkanState& vk_state, + ImDrawData* draw_data) +{ +# if defined(IMGUI_HAS_TEXTURES) + if (draw_data == nullptr || draw_data->Textures == nullptr) + return; + + for (ImTextureData* tex : *draw_data->Textures) { + if (tex == nullptr || tex->Status == ImTextureStatus_OK) + continue; + + if (vk_state.log_imgui_texture_updates) { + print(stderr, + "imiv: imgui texture id={} status={} size={}x{} update=({},{} " + "{}x{}) pending={}\n", + tex->UniqueID, texture_status_name(tex->Status), tex->Width, + tex->Height, tex->UpdateRect.x, tex->UpdateRect.y, + tex->UpdateRect.w, tex->UpdateRect.h, tex->Updates.Size); + } + + if (!vk_state.queue_requires_full_image_copies + || tex->Status != ImTextureStatus_WantUpdates) { + continue; + } + + ImTextureRect full_rect = {}; + full_rect.x = 0; + full_rect.y = 0; + full_rect.w = clamp_u16(tex->Width); + full_rect.h = clamp_u16(tex->Height); + tex->UpdateRect = full_rect; + tex->Updates.resize(1); + tex->Updates[0] = full_rect; + + if (!vk_state.warned_about_full_imgui_uploads) { + print(stderr, + "imiv: forcing full ImGui texture uploads on this queue " + "family due to strict transfer granularity\n"); + vk_state.warned_about_full_imgui_uploads = true; + } + } +# else + (void)vk_state; + (void)draw_data; +# endif +} + +void +frame_render(VulkanState& vk_state, ImDrawData* draw_data) +{ + ImGui_ImplVulkanH_Window* wd = &vk_state.window_data; + if (wd->Swapchain == VK_NULL_HANDLE) + return; + if (vk_state.window_frame_submit_serials.size() + != static_cast(wd->Frames.Size)) { + vk_state.window_frame_submit_serials.assign(static_cast( + wd->Frames.Size), + 0); + } + + VkResult err; + const uint32_t semaphore_index = wd->SemaphoreIndex; + VkSemaphore image_acquired_semaphore + = wd->FrameSemaphores[semaphore_index].ImageAcquiredSemaphore; + err = vkAcquireNextImageKHR(vk_state.device, wd->Swapchain, UINT64_MAX, + image_acquired_semaphore, VK_NULL_HANDLE, + &wd->FrameIndex); + if (err == VK_ERROR_OUT_OF_DATE_KHR || err == VK_SUBOPTIMAL_KHR) { + vk_state.swapchain_rebuild = true; + return; + } + check_vk_result(err); + + VkSemaphore render_complete_semaphore + = wd->FrameSemaphores[semaphore_index].RenderCompleteSemaphore; + + ImGui_ImplVulkanH_Frame* fd = &wd->Frames[wd->FrameIndex]; + { + err = vkWaitForFences(vk_state.device, 1, &fd->Fence, VK_TRUE, + UINT64_MAX); + check_vk_result(err); + + const size_t frame_slot = static_cast(wd->FrameIndex); + const uint64_t completed_serial + = vk_state.window_frame_submit_serials[frame_slot]; + if (completed_serial > vk_state.completed_main_submit_serial) { + vk_state.completed_main_submit_serial = completed_serial; + } + vk_state.window_frame_submit_serials[frame_slot] = 0; + drain_retired_textures(vk_state, false); + + err = vkResetFences(vk_state.device, 1, &fd->Fence); + check_vk_result(err); + err = vkResetCommandPool(vk_state.device, fd->CommandPool, 0); + check_vk_result(err); + VkCommandBufferBeginInfo info = {}; + info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + info.flags |= VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + err = vkBeginCommandBuffer(fd->CommandBuffer, &info); + check_vk_result(err); + } + { + VkRenderPassBeginInfo info = {}; + info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + info.renderPass = wd->RenderPass; + info.framebuffer = fd->Framebuffer; + info.renderArea.extent.width = wd->Width; + info.renderArea.extent.height = wd->Height; + info.clearValueCount = 1; + info.pClearValues = &wd->ClearValue; + vkCmdBeginRenderPass(fd->CommandBuffer, &info, + VK_SUBPASS_CONTENTS_INLINE); + } + + apply_imgui_texture_update_workarounds(vk_state, draw_data); + ImGui_ImplVulkan_RenderDrawData(draw_data, fd->CommandBuffer); + + vkCmdEndRenderPass(fd->CommandBuffer); + { + VkPipelineStageFlags wait_stage + = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + VkSubmitInfo info = {}; + info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + info.waitSemaphoreCount = 1; + info.pWaitSemaphores = &image_acquired_semaphore; + info.pWaitDstStageMask = &wait_stage; + info.commandBufferCount = 1; + info.pCommandBuffers = &fd->CommandBuffer; + info.signalSemaphoreCount = 1; + info.pSignalSemaphores = &render_complete_semaphore; + + err = vkEndCommandBuffer(fd->CommandBuffer); + check_vk_result(err); + const uint64_t submit_serial = vk_state.next_main_submit_serial++; + vk_state.window_frame_submit_serials[static_cast(wd->FrameIndex)] + = submit_serial; + err = vkQueueSubmit(vk_state.queue, 1, &info, fd->Fence); + check_vk_result(err); + } +} + +void +frame_present(VulkanState& vk_state) +{ + ImGui_ImplVulkanH_Window* wd = &vk_state.window_data; + if (wd->Swapchain == VK_NULL_HANDLE) + return; + if (vk_state.swapchain_rebuild) + return; + + VkSemaphore render_complete_semaphore + = wd->FrameSemaphores[wd->SemaphoreIndex].RenderCompleteSemaphore; + VkPresentInfoKHR info = {}; + info.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + info.waitSemaphoreCount = 1; + info.pWaitSemaphores = &render_complete_semaphore; + info.swapchainCount = 1; + info.pSwapchains = &wd->Swapchain; + info.pImageIndices = &wd->FrameIndex; + VkResult err = vkQueuePresentKHR(vk_state.queue, &info); + if (err == VK_ERROR_OUT_OF_DATE_KHR || err == VK_SUBOPTIMAL_KHR) { + vk_state.swapchain_rebuild = true; + return; + } + check_vk_result(err); + wd->SemaphoreIndex = (wd->SemaphoreIndex + 1) % wd->SemaphoreCount; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_setup.cpp b/src/imiv/imiv_vulkan_setup.cpp new file mode 100644 index 0000000000..2f17ff7b9a --- /dev/null +++ b/src/imiv/imiv_vulkan_setup.cpp @@ -0,0 +1,1445 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_vulkan_resource_utils.h" +#include "imiv_vulkan_shader_utils.h" +#include "imiv_vulkan_types.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#if defined(IMIV_WITH_VULKAN) +# include +# include +# define GLFW_INCLUDE_NONE +# define GLFW_INCLUDE_VULKAN +# include +#endif + +#include + +#if defined(IMIV_WITH_VULKAN) && defined(IMIV_HAS_EMBEDDED_VULKAN_SHADERS) \ + && IMIV_HAS_EMBEDDED_VULKAN_SHADERS +# include "imiv_preview_frag_spv.h" +# include "imiv_preview_vert_spv.h" +# include "imiv_upload_to_rgba16f_fp64_spv.h" +# include "imiv_upload_to_rgba16f_spv.h" +# include "imiv_upload_to_rgba32f_fp64_spv.h" +# include "imiv_upload_to_rgba32f_spv.h" +#endif + +using namespace OIIO; + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +bool +is_extension_available(const ImVector& properties, + const char* extension_name) +{ + for (const VkExtensionProperties& p : properties) { + if (std::strcmp(p.extensionName, extension_name) == 0) + return true; + } + return false; +} + + + +void +append_unique_extension(ImVector& extensions, + const char* extension_name) +{ + for (const char* existing_name : extensions) { + if (std::strcmp(existing_name, extension_name) == 0) + return; + } + extensions.push_back(extension_name); +} + + + +bool +is_layer_available(const char* layer_name) +{ + uint32_t count = 0; + VkResult err = vkEnumerateInstanceLayerProperties(&count, nullptr); + if (err != VK_SUCCESS) + return false; + + std::vector properties(count); + err = vkEnumerateInstanceLayerProperties(&count, properties.data()); + if (err != VK_SUCCESS) + return false; + + for (const VkLayerProperties& p : properties) { + if (std::strcmp(p.layerName, layer_name) == 0) + return true; + } + return false; +} + + + +uint32_t +vulkan_header_api_version() +{ +# ifdef VK_HEADER_VERSION_COMPLETE + return VK_HEADER_VERSION_COMPLETE; +# else + return VK_API_VERSION_1_0; +# endif +} + + + +uint32_t +query_loader_api_version() +{ + uint32_t version = VK_API_VERSION_1_0; + PFN_vkEnumerateInstanceVersion enumerate_instance_version + = reinterpret_cast( + vkGetInstanceProcAddr(VK_NULL_HANDLE, "vkEnumerateInstanceVersion")); + if (enumerate_instance_version != nullptr) { + uint32_t loader_version = VK_API_VERSION_1_0; + if (enumerate_instance_version(&loader_version) == VK_SUCCESS) + version = loader_version; + } + return version; +} + + + +bool +read_vulkan_limit_override(const char* name, uint32_t& out_value) +{ + out_value = 0; + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + return false; + char* end = nullptr; + unsigned long raw = std::strtoul(value, &end, 10); + if (end == value || *end != '\0' + || raw > static_cast( + std::numeric_limits::max())) { + return false; + } + out_value = static_cast(raw); + return out_value > 0; +} + + + +uint32_t +choose_instance_api_version() +{ + return std::min(std::min(vulkan_header_api_version(), + query_loader_api_version()), + VK_API_VERSION_1_3); +} + + + +const char* +portability_enumeration_extension_name() +{ +# ifdef VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME + return VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME; +# else + return "VK_KHR_portability_enumeration"; +# endif +} + + + +const char* +portability_subset_extension_name() +{ +# ifdef VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME + return VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME; +# else + return "VK_KHR_portability_subset"; +# endif +} + + + +const char* +physical_device_type_name(VkPhysicalDeviceType type) +{ + switch (type) { + case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated"; + case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete"; + case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual"; + case VK_PHYSICAL_DEVICE_TYPE_CPU: return "cpu"; + default: break; + } + return "other"; +} + + + +const char* +severity_name(VkDebugUtilsMessageSeverityFlagBitsEXT severity) +{ + if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) + return "error"; + if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) + return "warning"; + if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT) + return "info"; + if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT) + return "verbose"; + return "unknown"; +} + + + +void +print_message_type(VkDebugUtilsMessageTypeFlagsEXT message_type) +{ + bool first = true; + + if (message_type & VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT) { + print(stderr, "general"); + first = false; + } + if (message_type & VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT) { + if (!first) + print(stderr, "|"); + print(stderr, "validation"); + first = false; + } + if (message_type & VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT) { + if (!first) + print(stderr, "|"); + print(stderr, "performance"); + first = false; + } +# ifdef VK_DEBUG_UTILS_MESSAGE_TYPE_DEVICE_ADDRESS_BINDING_BIT_EXT + if (message_type + & VK_DEBUG_UTILS_MESSAGE_TYPE_DEVICE_ADDRESS_BINDING_BIT_EXT) { + if (!first) + print(stderr, "|"); + print(stderr, "device_address_binding"); + first = false; + } +# endif + + if (first) + print(stderr, "unknown"); +} + + + +const char* +object_type_name(VkObjectType object_type) +{ + switch (object_type) { + case VK_OBJECT_TYPE_INSTANCE: return "instance"; + case VK_OBJECT_TYPE_PHYSICAL_DEVICE: return "physical_device"; + case VK_OBJECT_TYPE_DEVICE: return "device"; + case VK_OBJECT_TYPE_QUEUE: return "queue"; + case VK_OBJECT_TYPE_SEMAPHORE: return "semaphore"; + case VK_OBJECT_TYPE_COMMAND_BUFFER: return "command_buffer"; + case VK_OBJECT_TYPE_FENCE: return "fence"; + case VK_OBJECT_TYPE_DEVICE_MEMORY: return "device_memory"; + case VK_OBJECT_TYPE_BUFFER: return "buffer"; + case VK_OBJECT_TYPE_IMAGE: return "image"; + case VK_OBJECT_TYPE_EVENT: return "event"; + case VK_OBJECT_TYPE_QUERY_POOL: return "query_pool"; + case VK_OBJECT_TYPE_BUFFER_VIEW: return "buffer_view"; + case VK_OBJECT_TYPE_IMAGE_VIEW: return "image_view"; + case VK_OBJECT_TYPE_SHADER_MODULE: return "shader_module"; + case VK_OBJECT_TYPE_PIPELINE_CACHE: return "pipeline_cache"; + case VK_OBJECT_TYPE_PIPELINE_LAYOUT: return "pipeline_layout"; + case VK_OBJECT_TYPE_RENDER_PASS: return "render_pass"; + case VK_OBJECT_TYPE_PIPELINE: return "pipeline"; + case VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT: return "descriptor_set_layout"; + case VK_OBJECT_TYPE_SAMPLER: return "sampler"; + case VK_OBJECT_TYPE_DESCRIPTOR_POOL: return "descriptor_pool"; + case VK_OBJECT_TYPE_DESCRIPTOR_SET: return "descriptor_set"; + case VK_OBJECT_TYPE_FRAMEBUFFER: return "framebuffer"; + case VK_OBJECT_TYPE_COMMAND_POOL: return "command_pool"; + case VK_OBJECT_TYPE_SURFACE_KHR: return "surface"; + case VK_OBJECT_TYPE_SWAPCHAIN_KHR: return "swapchain"; + case VK_OBJECT_TYPE_DEBUG_UTILS_MESSENGER_EXT: + return "debug_utils_messenger"; + default: break; + } + return "unknown"; +} + + + +VKAPI_ATTR VkBool32 VKAPI_CALL +vulkan_debug_callback(VkDebugUtilsMessageSeverityFlagBitsEXT severity, + VkDebugUtilsMessageTypeFlagsEXT message_type, + const VkDebugUtilsMessengerCallbackDataEXT* callback_data, + void* user_data) +{ + (void)user_data; + + const char* message_id_name = ""; + const char* message = ""; + int32_t message_id_number = 0; + if (callback_data != nullptr) { + if (callback_data->pMessageIdName != nullptr) + message_id_name = callback_data->pMessageIdName; + if (callback_data->pMessage != nullptr) + message = callback_data->pMessage; + message_id_number = callback_data->messageIdNumber; + } + + print(stderr, "imiv: vk[{}][", severity_name(severity)); + print_message_type(message_type); + print(stderr, "] id={} {}: {}\n", message_id_number, message_id_name, + message); + + const bool print_objects + = (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) + || (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT); + if (print_objects && callback_data != nullptr + && callback_data->objectCount > 0) { + for (uint32_t i = 0; i < callback_data->objectCount; ++i) { + const VkDebugUtilsObjectNameInfoEXT& o = callback_data->pObjects[i]; + const char* object_name = (o.pObjectName != nullptr + && o.pObjectName[0] != '\0') + ? o.pObjectName + : ""; + print(stderr, "imiv: vk object[{}] type={} handle={} name={}\n", i, + object_type_name(o.objectType), o.objectHandle, object_name); + } + } + return VK_FALSE; +} + + + +std::string +queue_flags_string(VkQueueFlags flags) +{ + std::string out; + if (flags & VK_QUEUE_GRAPHICS_BIT) { + if (!out.empty()) + out += "|"; + out += "graphics"; + } + if (flags & VK_QUEUE_COMPUTE_BIT) { + if (!out.empty()) + out += "|"; + out += "compute"; + } + if (flags & VK_QUEUE_TRANSFER_BIT) { + if (!out.empty()) + out += "|"; + out += "transfer"; + } + if (flags & VK_QUEUE_SPARSE_BINDING_BIT) { + if (!out.empty()) + out += "|"; + out += "sparse"; + } +# ifdef VK_QUEUE_PROTECTED_BIT + if (flags & VK_QUEUE_PROTECTED_BIT) { + if (!out.empty()) + out += "|"; + out += "protected"; + } +# endif + if (out.empty()) + out = "none"; + return out; +} + + + +bool +cache_queue_family_properties(VulkanState& vk_state, std::string& error_message) +{ + uint32_t queue_family_count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(vk_state.physical_device, + &queue_family_count, nullptr); + if (queue_family_count == 0) { + error_message + = "vkGetPhysicalDeviceQueueFamilyProperties found no queue families"; + return false; + } + if (vk_state.queue_family >= queue_family_count) { + error_message = "selected queue family index is out of range"; + return false; + } + + std::vector properties(queue_family_count); + vkGetPhysicalDeviceQueueFamilyProperties(vk_state.physical_device, + &queue_family_count, + properties.data()); + vk_state.queue_family_properties = properties[vk_state.queue_family]; + + const VkExtent3D granularity + = vk_state.queue_family_properties.minImageTransferGranularity; + vk_state.queue_requires_full_image_copies = (granularity.width == 0 + || granularity.height == 0 + || granularity.depth == 0); + + if (vk_state.verbose_logging) { + print("imiv: Vulkan queue family {} flags={} granularity=({}, {}, " + "{}) count={}\n", + vk_state.queue_family, + queue_flags_string(vk_state.queue_family_properties.queueFlags), + granularity.width, granularity.height, granularity.depth, + vk_state.queue_family_properties.queueCount); + } + if (vk_state.queue_requires_full_image_copies) { + print(stderr, + "imiv: queue family {} requires full-image transfer copies; " + "enabling conservative ImGui texture upload workaround\n", + vk_state.queue_family); + } + return true; +} + + + +void +populate_debug_messenger_ci(VkDebugUtilsMessengerCreateInfoEXT& ci, + bool verbose_output) +{ + ci = {}; + ci.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT; + ci.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT + | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT; + if (verbose_output) { + ci.messageSeverity |= VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT + | VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT; + } + ci.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT + | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT + | VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT; + ci.pfnUserCallback = vulkan_debug_callback; +} + + + +bool +setup_debug_messenger(VulkanState& vk_state, std::string& error_message) +{ + PFN_vkCreateDebugUtilsMessengerEXT create_fn + = reinterpret_cast( + vkGetInstanceProcAddr(vk_state.instance, + "vkCreateDebugUtilsMessengerEXT")); + if (create_fn == nullptr) { + error_message = "vkCreateDebugUtilsMessengerEXT not available"; + return false; + } + + VkDebugUtilsMessengerCreateInfoEXT ci = {}; + populate_debug_messenger_ci(ci, vk_state.verbose_validation_output); + VkResult err = create_fn(vk_state.instance, &ci, vk_state.allocator, + &vk_state.debug_messenger); + if (err != VK_SUCCESS) { + error_message = "vkCreateDebugUtilsMessengerEXT failed"; + return false; + } + return true; +} + + + +bool +has_format_features(VkPhysicalDevice physical_device, VkFormat format, + VkFormatFeatureFlags required_features) +{ + VkFormatProperties props = {}; + vkGetPhysicalDeviceFormatProperties(physical_device, format, &props); + return (props.optimalTilingFeatures & required_features) + == required_features; +} + + + +bool +device_supports_required_extensions(VkPhysicalDevice physical_device, + bool& has_portability_subset, + std::string& error_message) +{ + has_portability_subset = false; + + uint32_t device_extension_count = 0; + VkResult err = vkEnumerateDeviceExtensionProperties(physical_device, + nullptr, + &device_extension_count, + nullptr); + if (err != VK_SUCCESS) { + error_message = "vkEnumerateDeviceExtensionProperties failed"; + return false; + } + + ImVector device_properties; + device_properties.resize(device_extension_count); + err = vkEnumerateDeviceExtensionProperties(physical_device, nullptr, + &device_extension_count, + device_properties.Data); + if (err != VK_SUCCESS) { + error_message = "vkEnumerateDeviceExtensionProperties failed"; + return false; + } + + if (!is_extension_available(device_properties, + VK_KHR_SWAPCHAIN_EXTENSION_NAME)) + return false; + +# ifdef VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME + has_portability_subset + = is_extension_available(device_properties, + VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME); +# endif + return true; +} + + + +int +score_device_type(VkPhysicalDeviceType type) +{ + switch (type) { + case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return 1000; + case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return 500; + case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return 250; + case VK_PHYSICAL_DEVICE_TYPE_CPU: return 50; + default: break; + } + return 10; +} + + + +int +score_queue_family(const VkQueueFamilyProperties& properties) +{ + int score = 0; + if ((properties.queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0) + score += 100; + if ((properties.queueFlags & VK_QUEUE_COMPUTE_BIT) != 0) + score += 100; + if ((properties.queueFlags & VK_QUEUE_TRANSFER_BIT) != 0) + score += 25; + score += static_cast(properties.queueCount); + return score; +} + + + +bool +select_physical_device_and_queue(VulkanState& vk_state, + std::string& error_message) +{ + const VkFormatFeatureFlags required_compute_output_features + = VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT + | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + + uint32_t device_count = 0; + VkResult err = vkEnumeratePhysicalDevices(vk_state.instance, &device_count, + nullptr); + if (err != VK_SUCCESS) { + error_message = "vkEnumeratePhysicalDevices failed"; + return false; + } + if (device_count == 0) { + error_message = "no Vulkan physical device found"; + return false; + } + + ImVector devices; + devices.resize(device_count); + err = vkEnumeratePhysicalDevices(vk_state.instance, &device_count, + devices.Data); + if (err != VK_SUCCESS) { + error_message = "vkEnumeratePhysicalDevices failed"; + return false; + } + + int best_score = std::numeric_limits::min(); + VkPhysicalDevice best_device = VK_NULL_HANDLE; + uint32_t best_queue_family = static_cast(-1); + VkPhysicalDeviceProperties best_properties = {}; + + for (VkPhysicalDevice device : devices) { + bool has_portability_subset = false; + std::string extension_error; + if (!device_supports_required_extensions(device, has_portability_subset, + extension_error)) { + if (!extension_error.empty()) { + error_message = extension_error; + return false; + } + continue; + } + + VkPhysicalDeviceProperties properties = {}; + vkGetPhysicalDeviceProperties(device, &properties); + + VkPhysicalDeviceFeatures features = {}; + vkGetPhysicalDeviceFeatures(device, &features); + + if (!has_format_features(device, VK_FORMAT_R16G16B16A16_SFLOAT, + required_compute_output_features) + && !has_format_features(device, VK_FORMAT_R32G32B32A32_SFLOAT, + required_compute_output_features)) { + continue; + } + + uint32_t queue_family_count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(device, &queue_family_count, + nullptr); + if (queue_family_count == 0) + continue; + + std::vector queue_families(queue_family_count); + vkGetPhysicalDeviceQueueFamilyProperties(device, &queue_family_count, + queue_families.data()); + + for (uint32_t family_index = 0; family_index < queue_family_count; + ++family_index) { + const VkQueueFamilyProperties& family_properties + = queue_families[family_index]; + if ((family_properties.queueFlags & VK_QUEUE_GRAPHICS_BIT) == 0 + || (family_properties.queueFlags & VK_QUEUE_COMPUTE_BIT) == 0 + || family_properties.queueCount == 0) { + continue; + } + + VkBool32 supports_present = VK_FALSE; + err = vkGetPhysicalDeviceSurfaceSupportKHR(device, family_index, + vk_state.surface, + &supports_present); + if (err != VK_SUCCESS) { + error_message = "vkGetPhysicalDeviceSurfaceSupportKHR failed"; + return false; + } + if (supports_present != VK_TRUE) + continue; + + int score = score_device_type(properties.deviceType); + score += score_queue_family(family_properties); + if (features.shaderFloat64 == VK_TRUE) + score += 50; + if (has_portability_subset) + score += 5; + + if (score > best_score) { + best_score = score; + best_device = device; + best_queue_family = family_index; + best_properties = properties; + } + } + } + + if (best_device == VK_NULL_HANDLE + || best_queue_family == static_cast(-1)) { + error_message + = "no Vulkan device/queue family supports graphics+compute+present for the window surface"; + return false; + } + + vk_state.physical_device = best_device; + vk_state.queue_family = best_queue_family; + vk_state.max_image_dimension_2d = best_properties.limits.maxImageDimension2D; + vk_state.max_storage_buffer_range + = best_properties.limits.maxStorageBufferRange; + vk_state.min_storage_buffer_offset_alignment = static_cast( + std::max( + 1, best_properties.limits.minStorageBufferOffsetAlignment)); + uint32_t storage_buffer_override = 0; + if (read_vulkan_limit_override( + "IMIV_VULKAN_MAX_STORAGE_BUFFER_RANGE_OVERRIDE", + storage_buffer_override)) { + vk_state.max_storage_buffer_range + = std::min(vk_state.max_storage_buffer_range, + storage_buffer_override); + } + if (!cache_queue_family_properties(vk_state, error_message)) + return false; + + if (vk_state.verbose_logging) { + print("imiv: selected Vulkan device='{}' type={} api={}.{}.{} " + "queue_family={} maxImageDimension2D={} " + "maxStorageBufferRange={} minStorageBufferOffsetAlignment={}\n", + best_properties.deviceName, + physical_device_type_name(best_properties.deviceType), + VK_API_VERSION_MAJOR(best_properties.apiVersion), + VK_API_VERSION_MINOR(best_properties.apiVersion), + VK_API_VERSION_PATCH(best_properties.apiVersion), + vk_state.queue_family, vk_state.max_image_dimension_2d, + vk_state.max_storage_buffer_range, + vk_state.min_storage_buffer_offset_alignment); + } + return true; +} + + + +bool +create_compute_pipeline(VulkanState& vk_state, const uint32_t* shader_words, + size_t shader_word_count, + const std::string& shader_path, + const char* shader_label, const char* debug_name, + VkPipeline& out_pipeline, std::string& error_message) +{ + VkShaderModule shader_module = VK_NULL_HANDLE; + out_pipeline = VK_NULL_HANDLE; + const char* module_name = (shader_words != nullptr + && shader_word_count != 0) + ? shader_label + : shader_path.c_str(); + if (!create_shader_module_from_embedded_or_file( + vk_state.device, vk_state.allocator, shader_words, + shader_word_count, shader_path, shader_label, shader_module, + error_message)) { + return false; + } + + VkPipelineShaderStageCreateInfo stage_ci = {}; + stage_ci.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stage_ci.stage = VK_SHADER_STAGE_COMPUTE_BIT; + stage_ci.module = shader_module; + stage_ci.pName = "main"; + + VkComputePipelineCreateInfo pipeline_ci = {}; + pipeline_ci.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO; + pipeline_ci.stage = stage_ci; + pipeline_ci.layout = vk_state.compute_pipeline_layout; + const VkResult err + = vkCreateComputePipelines(vk_state.device, vk_state.pipeline_cache, 1, + &pipeline_ci, vk_state.allocator, + &out_pipeline); + vkDestroyShaderModule(vk_state.device, shader_module, vk_state.allocator); + if (err != VK_SUCCESS) { + error_message + = Strutil::fmt::format("vkCreateComputePipelines failed for '{}'", + module_name); + out_pipeline = VK_NULL_HANDLE; + return false; + } + + set_vk_object_name(vk_state, VK_OBJECT_TYPE_PIPELINE, out_pipeline, + debug_name); + return true; +} + +static void +destroy_shader_module_resource(VulkanState& vk_state, + VkShaderModule& shader_module) +{ + if (shader_module != VK_NULL_HANDLE) { + vkDestroyShaderModule(vk_state.device, shader_module, + vk_state.allocator); + shader_module = VK_NULL_HANDLE; + } +} + +static void +destroy_pipeline_resource(VulkanState& vk_state, VkPipeline& pipeline) +{ + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(vk_state.device, pipeline, vk_state.allocator); + pipeline = VK_NULL_HANDLE; + } +} + +static void +destroy_pipeline_layout_resource(VulkanState& vk_state, + VkPipelineLayout& pipeline_layout) +{ + if (pipeline_layout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(vk_state.device, pipeline_layout, + vk_state.allocator); + pipeline_layout = VK_NULL_HANDLE; + } +} + +static void +destroy_descriptor_set_layout_resource( + VulkanState& vk_state, VkDescriptorSetLayout& descriptor_set_layout) +{ + if (descriptor_set_layout != VK_NULL_HANDLE) { + vkDestroyDescriptorSetLayout(vk_state.device, descriptor_set_layout, + vk_state.allocator); + descriptor_set_layout = VK_NULL_HANDLE; + } +} + +static void +destroy_descriptor_pool_resource(VulkanState& vk_state, + VkDescriptorPool& descriptor_pool) +{ + if (descriptor_pool != VK_NULL_HANDLE) { + vkDestroyDescriptorPool(vk_state.device, descriptor_pool, + vk_state.allocator); + descriptor_pool = VK_NULL_HANDLE; + } +} + +static void +destroy_render_pass_resource(VulkanState& vk_state, VkRenderPass& render_pass) +{ + if (render_pass != VK_NULL_HANDLE) { + vkDestroyRenderPass(vk_state.device, render_pass, vk_state.allocator); + render_pass = VK_NULL_HANDLE; + } +} + + + +void +destroy_preview_resources(VulkanState& vk_state) +{ + destroy_ocio_preview_resources(vk_state); + destroy_pipeline_resource(vk_state, vk_state.preview_pipeline); + destroy_pipeline_layout_resource(vk_state, + vk_state.preview_pipeline_layout); + destroy_descriptor_set_layout_resource( + vk_state, vk_state.preview_descriptor_set_layout); + destroy_descriptor_pool_resource(vk_state, + vk_state.preview_descriptor_pool); + destroy_render_pass_resource(vk_state, vk_state.preview_render_pass); +} + + + +bool +init_preview_resources(VulkanState& vk_state, std::string& error_message) +{ +# if !(defined(IMIV_HAS_COMPUTE_UPLOAD_SHADERS) \ + && IMIV_HAS_COMPUTE_UPLOAD_SHADERS) + error_message = "preview shaders were not generated at build time"; + return false; +# else + destroy_preview_resources(vk_state); + + if (!has_format_features(vk_state.physical_device, + vk_state.compute_output_format, + VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT)) { + error_message + = "selected preview output format does not support color attachment"; + return false; + } + + VkAttachmentDescription attachment = {}; + attachment.format = vk_state.compute_output_format; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachment.initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + attachment.finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + VkAttachmentReference color_ref = {}; + color_ref.attachment = 0; + color_ref.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + VkSubpassDescription subpass = {}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &color_ref; + + VkRenderPassCreateInfo render_pass_ci = {}; + render_pass_ci.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + render_pass_ci.attachmentCount = 1; + render_pass_ci.pAttachments = &attachment; + render_pass_ci.subpassCount = 1; + render_pass_ci.pSubpasses = &subpass; + const VkDescriptorPoolSize preview_pool_sizes[] + = { { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 256 } }; + const VkDescriptorSetLayoutBinding preview_bindings[] + = { make_descriptor_set_layout_binding( + 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + VK_SHADER_STAGE_FRAGMENT_BIT) }; + VkPushConstantRange preview_push = {}; + preview_push.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + preview_push.offset = 0; + preview_push.size = sizeof(PreviewPushConstants); + + const std::string shader_vert = std::string(IMIV_SHADER_DIR) + + "/imiv_preview.vert.spv"; + const std::string shader_frag = std::string(IMIV_SHADER_DIR) + + "/imiv_preview.frag.spv"; +# if defined(IMIV_HAS_EMBEDDED_VULKAN_SHADERS) \ + && IMIV_HAS_EMBEDDED_VULKAN_SHADERS + const uint32_t* shader_vert_words = g_imiv_preview_vert_spv; + const size_t shader_vert_word_count = g_imiv_preview_vert_spv_word_count; + const uint32_t* shader_frag_words = g_imiv_preview_frag_spv; + const size_t shader_frag_word_count = g_imiv_preview_frag_spv_word_count; +# else + const uint32_t* shader_vert_words = nullptr; + const size_t shader_vert_word_count = 0; + const uint32_t* shader_frag_words = nullptr; + const size_t shader_frag_word_count = 0; +# endif + VkShaderModule vert_module = VK_NULL_HANDLE; + VkShaderModule frag_module = VK_NULL_HANDLE; + bool ok = false; + do { + VkResult err = vkCreateRenderPass(vk_state.device, &render_pass_ci, + vk_state.allocator, + &vk_state.preview_render_pass); + if (err != VK_SUCCESS) { + error_message = "vkCreateRenderPass failed for preview pipeline"; + break; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_RENDER_PASS, + vk_state.preview_render_pass, + "imiv.preview.render_pass"); + + if (!create_descriptor_pool_resource( + vk_state, VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, + 256, preview_pool_sizes, + static_cast(IM_ARRAYSIZE(preview_pool_sizes)), + vk_state.preview_descriptor_pool, + "vkCreateDescriptorPool failed for preview", + "imiv.preview.descriptor_pool", error_message)) { + break; + } + if (!create_descriptor_set_layout_resource( + vk_state, preview_bindings, + static_cast(IM_ARRAYSIZE(preview_bindings)), + vk_state.preview_descriptor_set_layout, + "vkCreateDescriptorSetLayout failed for preview", + "imiv.preview.set_layout", error_message)) { + break; + } + if (!create_pipeline_layout_resource( + vk_state, &vk_state.preview_descriptor_set_layout, 1, + &preview_push, 1, vk_state.preview_pipeline_layout, + "vkCreatePipelineLayout failed for preview", + "imiv.preview.pipeline_layout", error_message)) { + break; + } + if (!create_shader_module_from_embedded_or_file( + vk_state.device, vk_state.allocator, shader_vert_words, + shader_vert_word_count, shader_vert, "imiv.preview.vert", + vert_module, error_message)) { + break; + } + if (!create_shader_module_from_embedded_or_file( + vk_state.device, vk_state.allocator, shader_frag_words, + shader_frag_word_count, shader_frag, "imiv.preview.frag", + frag_module, error_message)) { + break; + } + if (!create_fullscreen_preview_pipeline( + vk_state, vk_state.preview_render_pass, + vk_state.preview_pipeline_layout, vert_module, frag_module, + "imiv.preview.pipeline", + "vkCreateGraphicsPipelines failed for preview", + vk_state.preview_pipeline, error_message)) { + break; + } + ok = true; + } while (false); + + destroy_shader_module_resource(vk_state, frag_module); + destroy_shader_module_resource(vk_state, vert_module); + if (!ok) + destroy_preview_resources(vk_state); + return ok; +# endif +} + + + +void +destroy_compute_upload_resources(VulkanState& vk_state) +{ + destroy_pipeline_resource(vk_state, vk_state.compute_pipeline_fp64); + destroy_pipeline_resource(vk_state, vk_state.compute_pipeline); + destroy_pipeline_layout_resource(vk_state, + vk_state.compute_pipeline_layout); + destroy_descriptor_set_layout_resource( + vk_state, vk_state.compute_descriptor_set_layout); + destroy_descriptor_pool_resource(vk_state, + vk_state.compute_descriptor_pool); + vk_state.compute_output_format = VK_FORMAT_UNDEFINED; + vk_state.compute_upload_ready = false; +} + + + +bool +init_compute_upload_resources(VulkanState& vk_state, std::string& error_message) +{ +# if !(defined(IMIV_HAS_COMPUTE_UPLOAD_SHADERS) \ + && IMIV_HAS_COMPUTE_UPLOAD_SHADERS) + error_message = "compute upload shaders were not generated at build time"; + return false; +# else + destroy_compute_upload_resources(vk_state); + + if ((vk_state.queue_family_properties.queueFlags & VK_QUEUE_COMPUTE_BIT) + == 0) { + error_message = "selected Vulkan queue family does not support compute"; + return false; + } + + VkPhysicalDeviceFeatures features = {}; + vkGetPhysicalDeviceFeatures(vk_state.physical_device, &features); + vk_state.compute_supports_float64 = (features.shaderFloat64 == VK_TRUE); + + const VkFormatFeatureFlags required = VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT + | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + + std::string shader_path; + std::string shader_path_fp64; + const uint32_t* shader_words = nullptr; + size_t shader_word_count = 0; + const uint32_t* shader_words_fp64 = nullptr; + size_t shader_word_count_fp64 = 0; + if (has_format_features(vk_state.physical_device, + VK_FORMAT_R16G16B16A16_SFLOAT, required)) { + vk_state.compute_output_format = VK_FORMAT_R16G16B16A16_SFLOAT; + shader_path = std::string(IMIV_SHADER_DIR) + + "/imiv_upload_to_rgba16f.comp.spv"; + shader_path_fp64 = std::string(IMIV_SHADER_DIR) + + "/imiv_upload_to_rgba16f_fp64.comp.spv"; +# if defined(IMIV_HAS_EMBEDDED_VULKAN_SHADERS) \ + && IMIV_HAS_EMBEDDED_VULKAN_SHADERS + shader_words = g_imiv_upload_to_rgba16f_spv; + shader_word_count = g_imiv_upload_to_rgba16f_spv_word_count; + shader_words_fp64 = g_imiv_upload_to_rgba16f_fp64_spv; + shader_word_count_fp64 = g_imiv_upload_to_rgba16f_fp64_spv_word_count; +# endif + } else if (has_format_features(vk_state.physical_device, + VK_FORMAT_R32G32B32A32_SFLOAT, required)) { + vk_state.compute_output_format = VK_FORMAT_R32G32B32A32_SFLOAT; + shader_path = std::string(IMIV_SHADER_DIR) + + "/imiv_upload_to_rgba32f.comp.spv"; + shader_path_fp64 = std::string(IMIV_SHADER_DIR) + + "/imiv_upload_to_rgba32f_fp64.comp.spv"; +# if defined(IMIV_HAS_EMBEDDED_VULKAN_SHADERS) \ + && IMIV_HAS_EMBEDDED_VULKAN_SHADERS + shader_words = g_imiv_upload_to_rgba32f_spv; + shader_word_count = g_imiv_upload_to_rgba32f_spv_word_count; + shader_words_fp64 = g_imiv_upload_to_rgba32f_fp64_spv; + shader_word_count_fp64 = g_imiv_upload_to_rgba32f_fp64_spv_word_count; +# endif + } else { + error_message + = "no compute output format support for rgba16f/rgba32f storage image"; + return false; + } + + const VkDescriptorPoolSize pool_sizes[] + = { { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, 64 }, + { VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 64 } }; + const VkDescriptorSetLayoutBinding bindings[] = { + make_descriptor_set_layout_binding( + 0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, + VK_SHADER_STAGE_COMPUTE_BIT), + make_descriptor_set_layout_binding(1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + VK_SHADER_STAGE_COMPUTE_BIT) + }; + + VkPushConstantRange push_range = {}; + push_range.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; + push_range.offset = 0; + push_range.size = sizeof(UploadComputePushConstants); + bool ok = false; + do { + if (!create_descriptor_pool_resource( + vk_state, VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, 64, + pool_sizes, static_cast(IM_ARRAYSIZE(pool_sizes)), + vk_state.compute_descriptor_pool, + "vkCreateDescriptorPool failed for compute upload", + "imiv.compute_upload.descriptor_pool", error_message)) { + break; + } + if (!create_descriptor_set_layout_resource( + vk_state, bindings, + static_cast(IM_ARRAYSIZE(bindings)), + vk_state.compute_descriptor_set_layout, + "vkCreateDescriptorSetLayout failed for compute upload", + "imiv.compute_upload.set_layout", error_message)) { + break; + } + if (!create_pipeline_layout_resource( + vk_state, &vk_state.compute_descriptor_set_layout, 1, + &push_range, 1, vk_state.compute_pipeline_layout, + "vkCreatePipelineLayout failed for compute upload", + "imiv.compute_upload.pipeline_layout", error_message)) { + break; + } + if (!create_compute_pipeline(vk_state, shader_words, shader_word_count, + shader_path, "imiv.upload_to_rgba", + "imiv.compute_upload.pipeline", + vk_state.compute_pipeline, + error_message)) { + break; + } + ok = true; + } while (false); + if (!ok) { + destroy_compute_upload_resources(vk_state); + return false; + } + + if (vk_state.compute_supports_float64) { + std::string fp64_error; + if (!create_compute_pipeline(vk_state, shader_words_fp64, + shader_word_count_fp64, shader_path_fp64, + "imiv.upload_to_rgba.fp64", + "imiv.compute_upload.pipeline_fp64", + vk_state.compute_pipeline_fp64, + fp64_error)) { + print(stderr, + "imiv: fp64 compute pipeline unavailable, will fallback " + "double->float on CPU: {}\n", + fp64_error); + } + } + + vk_state.compute_upload_ready = true; + if (vk_state.verbose_logging) { + print( + "imiv: compute upload ready output_format={} shader={} float64_support={} fp64_pipeline={}\n", + static_cast(vk_state.compute_output_format), shader_path, + vk_state.compute_supports_float64 ? "yes" : "no", + vk_state.compute_pipeline_fp64 != VK_NULL_HANDLE ? "yes" : "no"); + } + return true; +# endif +} + + + +bool +setup_vulkan_instance(VulkanState& vk_state, + ImVector& instance_extensions, + std::string& error_message) +{ + VkResult err; + + VkInstanceCreateInfo instance_ci = {}; + instance_ci.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + ImVector instance_layers; + +# if defined(IMIV_VULKAN_VALIDATION) && IMIV_VULKAN_VALIDATION + if (is_layer_available("VK_LAYER_KHRONOS_validation")) { + vk_state.validation_layer_enabled = true; + instance_layers.push_back("VK_LAYER_KHRONOS_validation"); + } else { + print( + stderr, + "imiv: Vulkan validation requested but VK_LAYER_KHRONOS_validation was not found\n"); + } +# endif + + uint32_t extension_count = 0; + err = vkEnumerateInstanceExtensionProperties(nullptr, &extension_count, + nullptr); + if (err != VK_SUCCESS) { + error_message = "vkEnumerateInstanceExtensionProperties failed"; + return false; + } + + ImVector instance_properties; + instance_properties.resize(extension_count); + err = vkEnumerateInstanceExtensionProperties(nullptr, &extension_count, + instance_properties.Data); + if (err != VK_SUCCESS) { + error_message = "vkEnumerateInstanceExtensionProperties failed"; + return false; + } + + if (is_extension_available( + instance_properties, + VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME)) + append_unique_extension( + instance_extensions, + VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME); +# ifdef VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR + if (is_extension_available(instance_properties, + portability_enumeration_extension_name())) { + append_unique_extension(instance_extensions, + portability_enumeration_extension_name()); + instance_ci.flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; + } +# endif + if (vk_state.validation_layer_enabled) { + if (is_extension_available(instance_properties, + VK_EXT_DEBUG_UTILS_EXTENSION_NAME)) { + append_unique_extension(instance_extensions, + VK_EXT_DEBUG_UTILS_EXTENSION_NAME); + vk_state.debug_utils_enabled = true; + } else { + print( + stderr, + "imiv: VK_EXT_debug_utils not found; validation output will be limited\n"); + } + } + + VkApplicationInfo app_info = {}; + app_info.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + app_info.pApplicationName = "imiv"; + app_info.applicationVersion = VK_MAKE_API_VERSION(0, 0, 1, 0); + app_info.pEngineName = "OpenImageIO"; + app_info.engineVersion = VK_MAKE_API_VERSION(0, 0, 1, 0); + vk_state.api_version = choose_instance_api_version(); + app_info.apiVersion = vk_state.api_version; + instance_ci.pApplicationInfo = &app_info; + instance_ci.enabledExtensionCount = static_cast( + instance_extensions.Size); + instance_ci.ppEnabledExtensionNames = instance_extensions.Data; + instance_ci.enabledLayerCount = static_cast(instance_layers.Size); + instance_ci.ppEnabledLayerNames = instance_layers.Data; + + VkDebugUtilsMessengerCreateInfoEXT debug_ci = {}; + if (vk_state.validation_layer_enabled && vk_state.debug_utils_enabled) { + populate_debug_messenger_ci(debug_ci, + vk_state.verbose_validation_output); + instance_ci.pNext = &debug_ci; + } + + err = vkCreateInstance(&instance_ci, vk_state.allocator, + &vk_state.instance); + if (err != VK_SUCCESS) { + error_message = "vkCreateInstance failed"; + return false; + } + if (vk_state.validation_layer_enabled) { + if (vk_state.debug_utils_enabled) { + if (!setup_debug_messenger(vk_state, error_message)) + return false; + if (vk_state.verbose_validation_output) { + print("imiv: Vulkan validation enabled with verbose debug " + "utils output\n"); + } else if (vk_state.verbose_logging) { + print("imiv: Vulkan validation enabled with warnings/errors " + "only\n"); + } + } else { + if (vk_state.verbose_logging) { + print("imiv: Vulkan validation enabled without debug utils " + "messenger\n"); + } + } + } + return true; +} + +bool +setup_vulkan_device(VulkanState& vk_state, std::string& error_message) +{ + VkResult err; + + if (!select_physical_device_and_queue(vk_state, error_message)) + return false; + + ImVector device_extensions; + device_extensions.push_back(VK_KHR_SWAPCHAIN_EXTENSION_NAME); + + uint32_t device_extension_count = 0; + err = vkEnumerateDeviceExtensionProperties(vk_state.physical_device, + nullptr, &device_extension_count, + nullptr); + if (err != VK_SUCCESS) { + error_message = "vkEnumerateDeviceExtensionProperties failed"; + return false; + } + ImVector device_properties; + device_properties.resize(device_extension_count); + err = vkEnumerateDeviceExtensionProperties(vk_state.physical_device, + nullptr, &device_extension_count, + device_properties.Data); + if (err != VK_SUCCESS) { + error_message = "vkEnumerateDeviceExtensionProperties failed"; + return false; + } + if (is_extension_available(device_properties, + portability_subset_extension_name())) { + append_unique_extension(device_extensions, + portability_subset_extension_name()); + } + + const float queue_priority[] = { 1.0f }; + VkDeviceQueueCreateInfo queue_ci = {}; + queue_ci.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + queue_ci.queueFamilyIndex = vk_state.queue_family; + queue_ci.queueCount = 1; + queue_ci.pQueuePriorities = queue_priority; + + VkPhysicalDeviceFeatures supported_features = {}; + vkGetPhysicalDeviceFeatures(vk_state.physical_device, &supported_features); + VkPhysicalDeviceFeatures enabled_features = {}; + if (supported_features.shaderFloat64 == VK_TRUE) { + enabled_features.shaderFloat64 = VK_TRUE; + vk_state.compute_supports_float64 = true; + } else { + vk_state.compute_supports_float64 = false; + } + + VkDeviceCreateInfo device_ci = {}; + device_ci.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + device_ci.queueCreateInfoCount = 1; + device_ci.pQueueCreateInfos = &queue_ci; + device_ci.enabledExtensionCount = static_cast( + device_extensions.Size); + device_ci.ppEnabledExtensionNames = device_extensions.Data; + device_ci.pEnabledFeatures = &enabled_features; + err = vkCreateDevice(vk_state.physical_device, &device_ci, + vk_state.allocator, &vk_state.device); + if (err != VK_SUCCESS) { + error_message = "vkCreateDevice failed"; + return false; + } + + if (vk_state.debug_utils_enabled) { + vk_state.set_debug_object_name_fn + = reinterpret_cast( + vkGetDeviceProcAddr(vk_state.device, + "vkSetDebugUtilsObjectNameEXT")); + if (vk_state.set_debug_object_name_fn == nullptr) { + print( + stderr, + "imiv: vkSetDebugUtilsObjectNameEXT not available on device\n"); + } + } + + vkGetDeviceQueue(vk_state.device, vk_state.queue_family, 0, + &vk_state.queue); + set_vk_object_name(vk_state, VK_OBJECT_TYPE_QUEUE, vk_state.queue, + "imiv.main.queue"); + + const VkDescriptorPoolSize pool_sizes[] + = { { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1024 } }; + if (!create_descriptor_pool_resource( + vk_state, VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, 1024, + pool_sizes, static_cast(IM_ARRAYSIZE(pool_sizes)), + vk_state.descriptor_pool, "vkCreateDescriptorPool failed", + "imiv.main.descriptor_pool", error_message)) { + return false; + } + + if (!init_compute_upload_resources(vk_state, error_message)) + return false; + if (!init_preview_resources(vk_state, error_message)) + return false; + + return true; +} + +bool +setup_vulkan_window(VulkanState& vk_state, int width, int height, + std::string& error_message) +{ + VkBool32 has_wsi = VK_FALSE; + vkGetPhysicalDeviceSurfaceSupportKHR(vk_state.physical_device, + vk_state.queue_family, + vk_state.surface, &has_wsi); + if (has_wsi != VK_TRUE) { + error_message = "no WSI support on selected device"; + return false; + } + + const VkFormat request_surface_formats[] + = { VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_B8G8R8_UNORM, VK_FORMAT_R8G8B8_UNORM }; + const VkColorSpaceKHR request_color_space = VK_COLORSPACE_SRGB_NONLINEAR_KHR; + vk_state.window_data.Surface = vk_state.surface; + vk_state.window_data.SurfaceFormat = ImGui_ImplVulkanH_SelectSurfaceFormat( + vk_state.physical_device, vk_state.window_data.Surface, + request_surface_formats, + static_cast(IM_ARRAYSIZE(request_surface_formats)), + request_color_space); + + const VkPresentModeKHR present_modes[] = { VK_PRESENT_MODE_FIFO_KHR }; + vk_state.window_data.PresentMode = ImGui_ImplVulkanH_SelectPresentMode( + vk_state.physical_device, vk_state.window_data.Surface, present_modes, + static_cast(IM_ARRAYSIZE(present_modes))); + + ImGui_ImplVulkanH_CreateOrResizeWindow( + vk_state.instance, vk_state.physical_device, vk_state.device, + &vk_state.window_data, vk_state.queue_family, vk_state.allocator, width, + height, vk_state.min_image_count, VK_IMAGE_USAGE_TRANSFER_SRC_BIT); + name_window_frame_objects(vk_state); + return true; +} + +void +destroy_vulkan_surface(VulkanState& vk_state) +{ + if (vk_state.surface != VK_NULL_HANDLE) { + vkDestroySurfaceKHR(vk_state.instance, vk_state.surface, + vk_state.allocator); + vk_state.surface = VK_NULL_HANDLE; + } +} + +void +cleanup_vulkan_window(VulkanState& vk_state) +{ + if (vk_state.window_data.Swapchain != VK_NULL_HANDLE) { + ImGui_ImplVulkanH_DestroyWindow(vk_state.instance, vk_state.device, + &vk_state.window_data, + vk_state.allocator); + vk_state.window_data = ImGui_ImplVulkanH_Window(); + } + destroy_vulkan_surface(vk_state); +} + + + +void +cleanup_vulkan(VulkanState& vk_state) +{ + if (vk_state.device != VK_NULL_HANDLE) + drain_retired_textures(vk_state, true); + if (vk_state.debug_messenger != VK_NULL_HANDLE) { + PFN_vkDestroyDebugUtilsMessengerEXT destroy_fn + = reinterpret_cast( + vkGetInstanceProcAddr(vk_state.instance, + "vkDestroyDebugUtilsMessengerEXT")); + if (destroy_fn != nullptr) { + destroy_fn(vk_state.instance, vk_state.debug_messenger, + vk_state.allocator); + } + vk_state.debug_messenger = VK_NULL_HANDLE; + } + if (vk_state.device != VK_NULL_HANDLE) + destroy_compute_upload_resources(vk_state); + if (vk_state.device != VK_NULL_HANDLE) + destroy_preview_resources(vk_state); + if (vk_state.device != VK_NULL_HANDLE) + destroy_immediate_submit_resources(vk_state); + destroy_descriptor_pool_resource(vk_state, vk_state.descriptor_pool); + if (vk_state.pipeline_cache != VK_NULL_HANDLE) { + vkDestroyPipelineCache(vk_state.device, vk_state.pipeline_cache, + vk_state.allocator); + vk_state.pipeline_cache = VK_NULL_HANDLE; + } + if (vk_state.device != VK_NULL_HANDLE) { + vkDestroyDevice(vk_state.device, vk_state.allocator); + vk_state.device = VK_NULL_HANDLE; + } + if (vk_state.instance != VK_NULL_HANDLE) { + vkDestroyInstance(vk_state.instance, vk_state.allocator); + vk_state.instance = VK_NULL_HANDLE; + } +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_shader_utils.cpp b/src/imiv/imiv_vulkan_shader_utils.cpp new file mode 100644 index 0000000000..e4ff4e2c42 --- /dev/null +++ b/src/imiv/imiv_vulkan_shader_utils.cpp @@ -0,0 +1,200 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_vulkan_shader_utils.h" + +#include + +#include + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +bool +read_spirv_words(const std::string& path, std::vector& out_words, + std::string& error_message) +{ + error_message.clear(); + out_words.clear(); + + std::ifstream in(path, std::ios::binary | std::ios::ate); + if (!in) { + error_message + = OIIO::Strutil::fmt::format("failed to open shader file '{}'", + path); + return false; + } + + const std::streamsize size = in.tellg(); + if (size <= 0 || (size % 4) != 0) { + error_message + = OIIO::Strutil::fmt::format("invalid SPIR-V size for '{}'", path); + return false; + } + + out_words.resize(static_cast(size) / sizeof(uint32_t)); + in.seekg(0, std::ios::beg); + if (!in.read(reinterpret_cast(out_words.data()), size)) { + error_message + = OIIO::Strutil::fmt::format("failed to read shader file '{}'", + path); + out_words.clear(); + return false; + } + + return true; +} + +bool +create_shader_module_from_words(VkDevice device, + VkAllocationCallbacks* allocator, + const uint32_t* words, size_t word_count, + const char* debug_name, + VkShaderModule& shader_module, + std::string& error_message) +{ + shader_module = VK_NULL_HANDLE; + if (words == nullptr || word_count == 0) { + error_message + = OIIO::Strutil::fmt::format("missing SPIR-V words for {}", + debug_name ? debug_name : "shader"); + return false; + } + + VkShaderModuleCreateInfo ci = {}; + ci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + ci.codeSize = word_count * sizeof(uint32_t); + ci.pCode = words; + const VkResult err = vkCreateShaderModule(device, &ci, allocator, + &shader_module); + if (err != VK_SUCCESS) { + error_message + = OIIO::Strutil::fmt::format("vkCreateShaderModule failed for {}", + debug_name ? debug_name : "shader"); + return false; + } + + return true; +} + +bool +create_shader_module_from_file(VkDevice device, + VkAllocationCallbacks* allocator, + const std::string& path, + VkShaderModule& shader_module, + std::string& error_message) +{ + std::vector words; + if (!read_spirv_words(path, words, error_message)) + return false; + + return create_shader_module_from_words(device, allocator, words.data(), + words.size(), path.c_str(), + shader_module, error_message); +} + +bool +create_shader_module_from_embedded_or_file( + VkDevice device, VkAllocationCallbacks* allocator, const uint32_t* words, + size_t word_count, const std::string& path, const char* debug_name, + VkShaderModule& shader_module, std::string& error_message) +{ + if (words != nullptr && word_count != 0) { + return create_shader_module_from_words(device, allocator, words, + word_count, debug_name, + shader_module, error_message); + } + + return create_shader_module_from_file(device, allocator, path, + shader_module, error_message); +} + +bool +create_fullscreen_preview_pipeline( + VulkanState& vk_state, VkRenderPass render_pass, + VkPipelineLayout pipeline_layout, VkShaderModule vert_module, + VkShaderModule frag_module, const char* debug_name, + const char* create_error, VkPipeline& pipeline, std::string& error_message) +{ + pipeline = VK_NULL_HANDLE; + + VkPipelineShaderStageCreateInfo stages[2] = {}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = vert_module; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = frag_module; + stages[1].pName = "main"; + + VkPipelineVertexInputStateCreateInfo vertex_input = {}; + vertex_input.sType + = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + VkPipelineInputAssemblyStateCreateInfo input_assembly = {}; + input_assembly.sType + = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + input_assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo viewport_state = {}; + viewport_state.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + viewport_state.viewportCount = 1; + viewport_state.scissorCount = 1; + VkPipelineRasterizationStateCreateInfo raster = {}; + raster.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + raster.polygonMode = VK_POLYGON_MODE_FILL; + raster.cullMode = VK_CULL_MODE_NONE; + raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + raster.lineWidth = 1.0f; + VkPipelineMultisampleStateCreateInfo multisample = {}; + multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineColorBlendAttachmentState color_blend_attachment = {}; + color_blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT + | VK_COLOR_COMPONENT_G_BIT + | VK_COLOR_COMPONENT_B_BIT + | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo color_blend = {}; + color_blend.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + color_blend.attachmentCount = 1; + color_blend.pAttachments = &color_blend_attachment; + VkDynamicState dynamic_states[] = { VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR }; + VkPipelineDynamicStateCreateInfo dynamic_state = {}; + dynamic_state.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dynamic_state.dynamicStateCount = static_cast( + IM_ARRAYSIZE(dynamic_states)); + dynamic_state.pDynamicStates = dynamic_states; + + VkGraphicsPipelineCreateInfo pipeline_ci = {}; + pipeline_ci.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pipeline_ci.stageCount = 2; + pipeline_ci.pStages = stages; + pipeline_ci.pVertexInputState = &vertex_input; + pipeline_ci.pInputAssemblyState = &input_assembly; + pipeline_ci.pViewportState = &viewport_state; + pipeline_ci.pRasterizationState = &raster; + pipeline_ci.pMultisampleState = &multisample; + pipeline_ci.pColorBlendState = &color_blend; + pipeline_ci.pDynamicState = &dynamic_state; + pipeline_ci.layout = pipeline_layout; + pipeline_ci.renderPass = render_pass; + pipeline_ci.subpass = 0; + + const VkResult err + = vkCreateGraphicsPipelines(vk_state.device, vk_state.pipeline_cache, 1, + &pipeline_ci, vk_state.allocator, + &pipeline); + if (err != VK_SUCCESS) { + error_message = create_error; + return false; + } + + set_vk_object_name(vk_state, VK_OBJECT_TYPE_PIPELINE, pipeline, debug_name); + return true; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_shader_utils.h b/src/imiv/imiv_vulkan_shader_utils.h new file mode 100644 index 0000000000..5234091ab7 --- /dev/null +++ b/src/imiv/imiv_vulkan_shader_utils.h @@ -0,0 +1,46 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_vulkan_types.h" + +#include +#include + +namespace Imiv { + +#if IMIV_WITH_VULKAN + +bool +read_spirv_words(const std::string& path, std::vector& out_words, + std::string& error_message); +bool +create_shader_module_from_words(VkDevice device, + VkAllocationCallbacks* allocator, + const uint32_t* words, size_t word_count, + const char* debug_name, + VkShaderModule& shader_module, + std::string& error_message); +bool +create_shader_module_from_file(VkDevice device, + VkAllocationCallbacks* allocator, + const std::string& path, + VkShaderModule& shader_module, + std::string& error_message); +bool +create_shader_module_from_embedded_or_file( + VkDevice device, VkAllocationCallbacks* allocator, const uint32_t* words, + size_t word_count, const std::string& path, const char* debug_name, + VkShaderModule& shader_module, std::string& error_message); +bool +create_fullscreen_preview_pipeline( + VulkanState& vk_state, VkRenderPass render_pass, + VkPipelineLayout pipeline_layout, VkShaderModule vert_module, + VkShaderModule frag_module, const char* debug_name, + const char* create_error, VkPipeline& pipeline, std::string& error_message); + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_texture.cpp b/src/imiv/imiv_vulkan_texture.cpp new file mode 100644 index 0000000000..bda50c4838 --- /dev/null +++ b/src/imiv/imiv_vulkan_texture.cpp @@ -0,0 +1,955 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_loaded_image.h" +#include "imiv_parse.h" +#include "imiv_tiling.h" +#include "imiv_vulkan_resource_utils.h" +#include "imiv_vulkan_texture_internal.h" +#include "imiv_vulkan_types.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +namespace { + + bool upload_stage_logging_enabled(const VulkanState& vk_state) + { + return vk_state.verbose_logging + || env_flag_is_truthy("IMIV_VULKAN_UPLOAD_STAGE_LOG"); + } + + void log_upload_stage(const VulkanState& vk_state, + const VulkanTexture& texture, const char* stage, + const std::string& details = {}) + { + if (!upload_stage_logging_enabled(vk_state)) + return; + if (details.empty()) { + print(stderr, "imiv: Vulkan upload stage [{}] '{}'\n", stage, + texture.debug_label); + return; + } + print(stderr, "imiv: Vulkan upload stage [{}] '{}' {}\n", stage, + texture.debug_label, details); + } + + bool texture_has_allocated_resources(const VulkanTexture& texture) + { + return texture.source_image != VK_NULL_HANDLE + || texture.source_view != VK_NULL_HANDLE + || texture.source_memory != VK_NULL_HANDLE + || texture.image != VK_NULL_HANDLE + || texture.view != VK_NULL_HANDLE + || texture.memory != VK_NULL_HANDLE + || texture.preview_framebuffer != VK_NULL_HANDLE + || texture.preview_source_set != VK_NULL_HANDLE + || texture.sampler != VK_NULL_HANDLE + || texture.nearest_mag_sampler != VK_NULL_HANDLE + || texture.pixelview_sampler != VK_NULL_HANDLE + || texture.set != VK_NULL_HANDLE + || texture.nearest_mag_set != VK_NULL_HANDLE + || texture.pixelview_set != VK_NULL_HANDLE + || texture.upload_staging_buffer != VK_NULL_HANDLE + || texture.upload_staging_memory != VK_NULL_HANDLE + || texture.upload_source_buffer != VK_NULL_HANDLE + || texture.upload_source_memory != VK_NULL_HANDLE + || texture.upload_compute_set != VK_NULL_HANDLE + || texture.upload_command_pool != VK_NULL_HANDLE + || texture.upload_command_buffer != VK_NULL_HANDLE + || texture.upload_submit_fence != VK_NULL_HANDLE + || texture.preview_command_pool != VK_NULL_HANDLE + || texture.preview_command_buffer != VK_NULL_HANDLE + || texture.preview_submit_fence != VK_NULL_HANDLE; + } + + void destroy_texture_now(VulkanState& vk_state, VulkanTexture& texture) + { + if (!texture_has_allocated_resources(texture)) { + texture.width = 0; + texture.height = 0; + texture.debug_label.clear(); + texture.source_ready = false; + texture.preview_initialized = false; + texture.preview_dirty = false; + texture.preview_params_valid = false; + return; + } + + if (vk_state.verbose_logging) { + print("imiv: Vulkan texture destroy-now '{}'\n", + texture.debug_label); + } + + if (texture.upload_submit_pending + && texture.upload_submit_fence != VK_NULL_HANDLE) { + VkResult err = vkWaitForFences(vk_state.device, 1, + &texture.upload_submit_fence, + VK_TRUE, UINT64_MAX); + check_vk_result(err); + texture.upload_submit_pending = false; + } + if (texture.preview_submit_pending + && texture.preview_submit_fence != VK_NULL_HANDLE) { + VkResult err = vkWaitForFences(vk_state.device, 1, + &texture.preview_submit_fence, + VK_TRUE, UINT64_MAX); + check_vk_result(err); + texture.preview_submit_pending = false; + } + if (texture.pixelview_set != VK_NULL_HANDLE) { + ImGui_ImplVulkan_RemoveTexture(texture.pixelview_set); + texture.pixelview_set = VK_NULL_HANDLE; + } + if (texture.nearest_mag_set != VK_NULL_HANDLE) { + ImGui_ImplVulkan_RemoveTexture(texture.nearest_mag_set); + texture.nearest_mag_set = VK_NULL_HANDLE; + } + if (texture.set != VK_NULL_HANDLE) { + ImGui_ImplVulkan_RemoveTexture(texture.set); + texture.set = VK_NULL_HANDLE; + } + if (texture.preview_source_set != VK_NULL_HANDLE + && vk_state.preview_descriptor_pool != VK_NULL_HANDLE) { + vkFreeDescriptorSets(vk_state.device, + vk_state.preview_descriptor_pool, 1, + &texture.preview_source_set); + texture.preview_source_set = VK_NULL_HANDLE; + } + if (texture.preview_framebuffer != VK_NULL_HANDLE) { + vkDestroyFramebuffer(vk_state.device, texture.preview_framebuffer, + vk_state.allocator); + texture.preview_framebuffer = VK_NULL_HANDLE; + } + if (texture.sampler != VK_NULL_HANDLE) { + vkDestroySampler(vk_state.device, texture.sampler, + vk_state.allocator); + texture.sampler = VK_NULL_HANDLE; + } + if (texture.nearest_mag_sampler != VK_NULL_HANDLE) { + vkDestroySampler(vk_state.device, texture.nearest_mag_sampler, + vk_state.allocator); + texture.nearest_mag_sampler = VK_NULL_HANDLE; + } + if (texture.pixelview_sampler != VK_NULL_HANDLE) { + vkDestroySampler(vk_state.device, texture.pixelview_sampler, + vk_state.allocator); + texture.pixelview_sampler = VK_NULL_HANDLE; + } + destroy_texture_upload_submit_resources(vk_state, texture); + destroy_texture_preview_submit_resources(vk_state, texture); + if (texture.view != VK_NULL_HANDLE) { + vkDestroyImageView(vk_state.device, texture.view, + vk_state.allocator); + texture.view = VK_NULL_HANDLE; + } + if (texture.image != VK_NULL_HANDLE) { + vkDestroyImage(vk_state.device, texture.image, vk_state.allocator); + texture.image = VK_NULL_HANDLE; + } + if (texture.memory != VK_NULL_HANDLE) { + vkFreeMemory(vk_state.device, texture.memory, vk_state.allocator); + texture.memory = VK_NULL_HANDLE; + } + if (texture.source_view != VK_NULL_HANDLE) { + vkDestroyImageView(vk_state.device, texture.source_view, + vk_state.allocator); + texture.source_view = VK_NULL_HANDLE; + } + if (texture.source_image != VK_NULL_HANDLE) { + vkDestroyImage(vk_state.device, texture.source_image, + vk_state.allocator); + texture.source_image = VK_NULL_HANDLE; + } + if (texture.source_memory != VK_NULL_HANDLE) { + vkFreeMemory(vk_state.device, texture.source_memory, + vk_state.allocator); + texture.source_memory = VK_NULL_HANDLE; + } + texture.width = 0; + texture.height = 0; + texture.debug_label.clear(); + texture.source_ready = false; + texture.preview_initialized = false; + texture.preview_dirty = false; + texture.preview_params_valid = false; + } + + bool ensure_texture_upload_submit_resources(VulkanState& vk_state, + VulkanTexture& texture, + std::string& error_message) + { + return ensure_async_submit_resources( + vk_state, texture.upload_command_pool, + texture.upload_command_buffer, texture.upload_submit_fence, + "vkCreateCommandPool failed for upload async submit", + "vkAllocateCommandBuffers failed for upload async submit", + "vkCreateFence failed for upload async submit", + "imiv.upload_async.command_pool", + "imiv.upload_async.command_buffer", "imiv.upload_async.fence", + error_message); + } + +} // namespace + +void +destroy_texture_upload_submit_resources(VulkanState& vk_state, + VulkanTexture& texture) +{ + if (texture.upload_compute_set != VK_NULL_HANDLE + && vk_state.compute_descriptor_pool != VK_NULL_HANDLE) { + vkFreeDescriptorSets(vk_state.device, vk_state.compute_descriptor_pool, + 1, &texture.upload_compute_set); + texture.upload_compute_set = VK_NULL_HANDLE; + } + if (texture.upload_source_buffer != VK_NULL_HANDLE) { + vkDestroyBuffer(vk_state.device, texture.upload_source_buffer, + vk_state.allocator); + texture.upload_source_buffer = VK_NULL_HANDLE; + } + if (texture.upload_source_memory != VK_NULL_HANDLE) { + vkFreeMemory(vk_state.device, texture.upload_source_memory, + vk_state.allocator); + texture.upload_source_memory = VK_NULL_HANDLE; + } + if (texture.upload_staging_buffer != VK_NULL_HANDLE) { + vkDestroyBuffer(vk_state.device, texture.upload_staging_buffer, + vk_state.allocator); + texture.upload_staging_buffer = VK_NULL_HANDLE; + } + if (texture.upload_staging_memory != VK_NULL_HANDLE) { + vkFreeMemory(vk_state.device, texture.upload_staging_memory, + vk_state.allocator); + texture.upload_staging_memory = VK_NULL_HANDLE; + } + destroy_async_submit_resources(vk_state, texture.upload_command_pool, + texture.upload_command_buffer, + texture.upload_submit_fence); + texture.upload_submit_pending = false; +} + +bool +poll_texture_upload_submission(VulkanState& vk_state, VulkanTexture& texture, + bool wait_for_completion, + std::string& error_message) +{ + if (texture.source_ready) + return true; + if (!texture.upload_submit_pending) + return false; + if (texture.upload_submit_fence == VK_NULL_HANDLE) { + texture.upload_submit_pending = false; + error_message = "upload submit fence is unavailable"; + return false; + } + + VkResult err = VK_SUCCESS; + if (wait_for_completion) { + log_upload_stage(vk_state, texture, "wait_begin"); + err = vkWaitForFences(vk_state.device, 1, &texture.upload_submit_fence, + VK_TRUE, UINT64_MAX); + } else { + err = vkGetFenceStatus(vk_state.device, texture.upload_submit_fence); + if (err == VK_NOT_READY) + return false; + } + if (err != VK_SUCCESS) { + log_upload_stage(vk_state, texture, + wait_for_completion ? "wait_error" : "poll_error", + Strutil::fmt::format("vk_result={}", + static_cast(err))); + error_message = wait_for_completion + ? "vkWaitForFences failed for upload async submit" + : "vkGetFenceStatus failed for upload async submit"; + check_vk_result(err); + return false; + } + + texture.upload_submit_pending = false; + texture.source_ready = true; + texture.preview_dirty = true; + log_upload_stage(vk_state, texture, + wait_for_completion ? "wait_complete" : "poll_complete"); + destroy_texture_upload_submit_resources(vk_state, texture); + return true; +} + +void +check_vk_result(VkResult err) +{ + if (err == VK_SUCCESS) + return; + print(stderr, "imiv: Vulkan error {}\n", static_cast(err)); + if (err == VK_ERROR_DEVICE_LOST || err == VK_ERROR_OUT_OF_DEVICE_MEMORY + || err == VK_ERROR_OUT_OF_HOST_MEMORY + || err == VK_ERROR_INITIALIZATION_FAILED) { + print(stderr, + "imiv: fatal Vulkan error {}; aborting to avoid undefined " + "behavior\n", + static_cast(err)); + std::abort(); + } +} + +bool +find_memory_type(VkPhysicalDevice physical_device, uint32_t type_bits, + VkMemoryPropertyFlags required, uint32_t& memory_type_index) +{ + VkPhysicalDeviceMemoryProperties memory_properties = {}; + vkGetPhysicalDeviceMemoryProperties(physical_device, &memory_properties); + for (uint32_t i = 0; i < memory_properties.memoryTypeCount; ++i) { + const bool type_matches = (type_bits & (1u << i)) != 0; + const bool has_flags = (memory_properties.memoryTypes[i].propertyFlags + & required) + == required; + if (type_matches && has_flags) { + memory_type_index = i; + return true; + } + } + return false; +} + +bool +find_memory_type_with_fallback(VkPhysicalDevice physical_device, + uint32_t type_bits, + VkMemoryPropertyFlags preferred, + uint32_t& memory_type_index) +{ + if (find_memory_type(physical_device, type_bits, preferred, + memory_type_index)) { + return true; + } + return find_memory_type(physical_device, type_bits, 0, memory_type_index); +} + +void +destroy_texture(VulkanState& vk_state, VulkanTexture& texture) +{ + destroy_texture_now(vk_state, texture); +} + +void +retire_texture(VulkanState& vk_state, VulkanTexture& texture) +{ + if (!texture_has_allocated_resources(texture) + || vk_state.device == VK_NULL_HANDLE) { + destroy_texture_now(vk_state, texture); + return; + } + + RetiredVulkanTexture retired = {}; + retired.texture = std::move(texture); + retired.retire_after_main_submit_serial = vk_state.next_main_submit_serial; + if (vk_state.verbose_logging) { + print("imiv: Vulkan texture retire '{}' after main submit {}\n", + retired.texture.debug_label, + retired.retire_after_main_submit_serial); + } + vk_state.retired_textures.emplace_back(std::move(retired)); +} + +void +drain_retired_textures(VulkanState& vk_state, bool force) +{ + if (vk_state.retired_textures.empty()) + return; + + size_t write_index = 0; + for (size_t i = 0, e = vk_state.retired_textures.size(); i < e; ++i) { + RetiredVulkanTexture& retired = vk_state.retired_textures[i]; + const bool ready = force + || vk_state.completed_main_submit_serial + >= retired.retire_after_main_submit_serial; + if (!ready) { + if (write_index != i) + vk_state.retired_textures[write_index] = std::move(retired); + ++write_index; + continue; + } + if (vk_state.verbose_logging) { + print("imiv: Vulkan texture retire-drain '{}' completed={} " + "target={} force={}\n", + retired.texture.debug_label, + vk_state.completed_main_submit_serial, + retired.retire_after_main_submit_serial, force ? 1 : 0); + } + destroy_texture_now(vk_state, retired.texture); + } + vk_state.retired_textures.resize(write_index); +} + +bool +create_texture(VulkanState& vk_state, const LoadedImage& image, + VulkanTexture& texture, std::string& error_message) +{ + destroy_texture(vk_state, texture); + texture.debug_label = image.path; + + if (!vk_state.compute_upload_ready) { + error_message = "compute upload path is not initialized"; + return false; + } + if (image.width <= 0 || image.height <= 0 || image.pixels.empty()) { + error_message = "invalid image data for texture upload"; + return false; + } + if (vk_state.max_image_dimension_2d > 0 + && (static_cast(image.width) > vk_state.max_image_dimension_2d + || static_cast(image.height) + > vk_state.max_image_dimension_2d)) { + error_message = Strutil::fmt::format( + "image dimensions {}x{} exceed device maxImageDimension2D {}", + image.width, image.height, vk_state.max_image_dimension_2d); + return false; + } + + UploadDataType upload_type = image.type; + size_t channel_bytes = image.channel_bytes; + size_t row_pitch_bytes = image.row_pitch_bytes; + const int channel_count = std::max(0, image.nchannels); + if (channel_count <= 0) { + error_message = "invalid source channel count"; + return false; + } + + std::vector converted_pixels; + const unsigned char* upload_ptr = image.pixels.data(); + size_t upload_bytes = image.pixels.size(); + bool use_fp64_pipeline = false; + + if (upload_type == UploadDataType::Double) { + if (vk_state.compute_pipeline_fp64 != VK_NULL_HANDLE) { + use_fp64_pipeline = true; + if (vk_state.verbose_logging) { + print("imiv: using fp64 compute upload path for '{}'\n", + image.path); + } + } else { + const size_t value_count = image.pixels.size() / sizeof(double); + converted_pixels.resize(value_count * sizeof(float)); + const double* src = reinterpret_cast( + image.pixels.data()); + float* dst = reinterpret_cast(converted_pixels.data()); + for (size_t i = 0; i < value_count; ++i) + dst[i] = static_cast(src[i]); + upload_type = UploadDataType::Float; + channel_bytes = sizeof(float); + row_pitch_bytes = static_cast(image.width) + * static_cast(channel_count) + * channel_bytes; + upload_ptr = converted_pixels.data(); + upload_bytes = converted_pixels.size(); + print(stderr, "imiv: fp64 compute pipeline unavailable; converting " + "double input to float on CPU\n"); + } + } + + size_t pixel_stride_bytes = 0; + if (converted_pixels.empty()) { + LoadedImageLayout image_layout; + if (!describe_loaded_image_layout(image, image_layout, error_message)) { + if (error_message == "invalid source row pitch") + error_message = "invalid source stride for compute upload"; + return false; + } + pixel_stride_bytes = image_layout.pixel_stride_bytes; + } else { + pixel_stride_bytes = channel_bytes * static_cast(channel_count); + if (pixel_stride_bytes == 0 || row_pitch_bytes == 0 + || row_pitch_bytes + < static_cast(image.width) * pixel_stride_bytes) { + error_message = "invalid source stride for compute upload"; + return false; + } + } + + RowStripeUploadPlan stripe_plan; + if (!build_row_stripe_upload_plan( + row_pitch_bytes, pixel_stride_bytes, image.height, + std::max(1, vk_state.max_storage_buffer_range), + std::max(1, vk_state.min_storage_buffer_offset_alignment), + stripe_plan, error_message)) { + return false; + } + const VkDeviceSize upload_size_aligned = static_cast( + stripe_plan.padded_upload_bytes); + log_upload_stage(vk_state, texture, "create_begin", + Strutil::fmt::format( + "{}x{} channels={} type={} row_pitch={} stripes={} " + "stripe_rows={} aligned_row_pitch={} upload_bytes={} " + "padded_upload_bytes={}", + image.width, image.height, channel_count, + upload_data_type_name(upload_type), row_pitch_bytes, + stripe_plan.stripe_count, stripe_plan.stripe_rows, + stripe_plan.aligned_row_pitch_bytes, upload_bytes, + stripe_plan.padded_upload_bytes)); + + VkBuffer staging_buffer = VK_NULL_HANDLE; + VkDeviceMemory staging_memory = VK_NULL_HANDLE; + VkBuffer source_buffer = VK_NULL_HANDLE; + VkDeviceMemory source_memory = VK_NULL_HANDLE; + VkDescriptorSet compute_set = VK_NULL_HANDLE; + VkCommandBuffer upload_command = VK_NULL_HANDLE; + bool ok = false; + + do { + VkResult err = VK_SUCCESS; + + VkImageCreateInfo source_ci = {}; + source_ci.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + source_ci.imageType = VK_IMAGE_TYPE_2D; + source_ci.format = vk_state.compute_output_format; + source_ci.extent.width = static_cast(image.width); + source_ci.extent.height = static_cast(image.height); + source_ci.extent.depth = 1; + source_ci.mipLevels = 1; + source_ci.arrayLayers = 1; + source_ci.samples = VK_SAMPLE_COUNT_1_BIT; + source_ci.tiling = VK_IMAGE_TILING_OPTIMAL; + source_ci.usage = VK_IMAGE_USAGE_STORAGE_BIT + | VK_IMAGE_USAGE_SAMPLED_BIT; + source_ci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + source_ci.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + err = vkCreateImage(vk_state.device, &source_ci, vk_state.allocator, + &texture.source_image); + if (err != VK_SUCCESS) { + error_message = "vkCreateImage failed for source image"; + break; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_IMAGE, texture.source_image, + "imiv.viewer.source_image"); + + if (!allocate_and_bind_image_memory( + vk_state, texture.source_image, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, true, + texture.source_memory, + "no compatible memory type for source image", + "vkAllocateMemory failed for source image", + "vkBindImageMemory failed for source image", + "imiv.viewer.source_image.memory", error_message)) { + break; + } + + VkImageViewCreateInfo source_view_ci = {}; + source_view_ci.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + source_view_ci.image = texture.source_image; + source_view_ci.viewType = VK_IMAGE_VIEW_TYPE_2D; + source_view_ci.format = vk_state.compute_output_format; + source_view_ci.components.r = VK_COMPONENT_SWIZZLE_IDENTITY; + source_view_ci.components.g = VK_COMPONENT_SWIZZLE_IDENTITY; + source_view_ci.components.b = VK_COMPONENT_SWIZZLE_IDENTITY; + source_view_ci.components.a = VK_COMPONENT_SWIZZLE_IDENTITY; + source_view_ci.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + source_view_ci.subresourceRange.baseMipLevel = 0; + source_view_ci.subresourceRange.levelCount = 1; + source_view_ci.subresourceRange.baseArrayLayer = 0; + source_view_ci.subresourceRange.layerCount = 1; + if (!create_image_view_resource( + vk_state, source_view_ci, texture.source_view, + "vkCreateImageView failed for source image", + "imiv.viewer.source_view", error_message)) { + break; + } + + VkImageCreateInfo preview_ci = source_ci; + preview_ci.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT + | VK_IMAGE_USAGE_SAMPLED_BIT; + err = vkCreateImage(vk_state.device, &preview_ci, vk_state.allocator, + &texture.image); + if (err != VK_SUCCESS) { + error_message = "vkCreateImage failed for preview image"; + break; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_IMAGE, texture.image, + "imiv.viewer.preview_image"); + + if (!allocate_and_bind_image_memory( + vk_state, texture.image, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, + true, texture.memory, + "no compatible memory type for preview image", + "vkAllocateMemory failed for preview image", + "vkBindImageMemory failed for preview image", + "imiv.viewer.preview_image.memory", error_message)) { + break; + } + + VkImageViewCreateInfo preview_view_ci = source_view_ci; + preview_view_ci.image = texture.image; + if (!create_image_view_resource( + vk_state, preview_view_ci, texture.view, + "vkCreateImageView failed for preview image", + "imiv.viewer.preview_view", error_message)) { + break; + } + + VkFramebufferCreateInfo fb_ci = {}; + fb_ci.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fb_ci.renderPass = vk_state.preview_render_pass; + fb_ci.attachmentCount = 1; + fb_ci.pAttachments = &texture.view; + fb_ci.width = static_cast(image.width); + fb_ci.height = static_cast(image.height); + fb_ci.layers = 1; + err = vkCreateFramebuffer(vk_state.device, &fb_ci, vk_state.allocator, + &texture.preview_framebuffer); + if (err != VK_SUCCESS) { + error_message = "vkCreateFramebuffer failed for preview image"; + break; + } + set_vk_object_name(vk_state, VK_OBJECT_TYPE_FRAMEBUFFER, + texture.preview_framebuffer, + "imiv.viewer.preview_framebuffer"); + + if (!create_buffer_with_memory_resource( + vk_state, upload_size_aligned, + VK_BUFFER_USAGE_TRANSFER_DST_BIT + | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, true, source_buffer, + source_memory, "vkCreateBuffer failed for source buffer", + "no compatible memory type for source buffer", + "vkAllocateMemory failed for source buffer", + "vkBindBufferMemory failed for source buffer", + "imiv.viewer.upload.source_buffer", + "imiv.viewer.upload.source_memory", error_message)) { + break; + } + + if (!create_buffer_with_memory_resource( + vk_state, upload_size_aligned, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT + | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + false, staging_buffer, staging_memory, + "vkCreateBuffer failed for staging buffer", + "no host-visible memory type for staging buffer", + "vkAllocateMemory failed for staging buffer", + "vkBindBufferMemory failed for staging buffer", + "imiv.viewer.upload.staging_buffer", + "imiv.viewer.upload.staging_memory", error_message)) { + break; + } + + void* mapped = nullptr; + if (!map_memory_resource(vk_state, staging_memory, upload_size_aligned, + mapped, + "vkMapMemory failed for staging buffer", + error_message)) { + break; + } + if (!copy_rows_to_padded_buffer( + upload_ptr, upload_bytes, row_pitch_bytes, image.height, + stripe_plan.aligned_row_pitch_bytes, + reinterpret_cast(mapped), + static_cast(upload_size_aligned), error_message)) { + vkUnmapMemory(vk_state.device, staging_memory); + break; + } + vkUnmapMemory(vk_state.device, staging_memory); + + if (!allocate_descriptor_set_resource( + vk_state, vk_state.compute_descriptor_pool, + vk_state.compute_descriptor_set_layout, compute_set, + "vkAllocateDescriptorSets failed for upload compute", + error_message)) { + break; + } + + if (!ensure_texture_upload_submit_resources(vk_state, texture, + error_message)) { + break; + } + + bool upload_fence_signaled = false; + if (!nonblocking_fence_status(vk_state.device, + texture.upload_submit_fence, + "upload async submit", + upload_fence_signaled, error_message)) { + break; + } + if (!upload_fence_signaled) { + error_message = "upload async submit is still in flight during " + "texture initialization"; + break; + } + err = vkResetCommandPool(vk_state.device, texture.upload_command_pool, + 0); + if (err != VK_SUCCESS) { + error_message = "vkResetCommandPool failed for upload async submit"; + break; + } + + upload_command = texture.upload_command_buffer; + VkCommandBufferBeginInfo begin = {}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + err = vkBeginCommandBuffer(upload_command, &begin); + if (err != VK_SUCCESS) { + error_message = "vkBeginCommandBuffer failed for upload update"; + break; + } + + VkDescriptorBufferInfo source_buffer_info = {}; + source_buffer_info.buffer = source_buffer; + source_buffer_info.offset = 0; + source_buffer_info.range = stripe_plan.descriptor_range_bytes; + + VkDescriptorImageInfo output_image_info = {}; + output_image_info.imageView = texture.source_view; + output_image_info.imageLayout = VK_IMAGE_LAYOUT_GENERAL; + output_image_info.sampler = VK_NULL_HANDLE; + + VkWriteDescriptorSet writes[2] + = { make_buffer_descriptor_write( + compute_set, 0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, + &source_buffer_info), + make_image_descriptor_write(compute_set, 1, + VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + &output_image_info) }; + vkUpdateDescriptorSets(vk_state.device, 2, writes, 0, nullptr); + + VkFormatProperties output_props = {}; + vkGetPhysicalDeviceFormatProperties(vk_state.physical_device, + vk_state.compute_output_format, + &output_props); + const bool has_linear_filter + = (output_props.optimalTilingFeatures + & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT) + != 0; + const VkFilter filter = has_linear_filter ? VK_FILTER_LINEAR + : VK_FILTER_NEAREST; + const VkSamplerCreateInfo sampler_ci = make_clamped_sampler_create_info( + filter, filter, VK_SAMPLER_MIPMAP_MODE_LINEAR, 0.0f, 1000.0f); + if (!create_sampler_resource(vk_state, sampler_ci, texture.sampler, + "vkCreateSampler failed", + "imiv.viewer.sampler", error_message)) { + break; + } + + VkSamplerCreateInfo pixelview_sampler_ci = sampler_ci; + VkSamplerCreateInfo nearest_mag_sampler_ci = sampler_ci; + nearest_mag_sampler_ci.magFilter = VK_FILTER_NEAREST; + pixelview_sampler_ci.magFilter = VK_FILTER_NEAREST; + pixelview_sampler_ci.minFilter = VK_FILTER_NEAREST; + pixelview_sampler_ci.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + if (!create_sampler_resource( + vk_state, nearest_mag_sampler_ci, texture.nearest_mag_sampler, + "vkCreateSampler failed for nearest-mag view", + "imiv.viewer.nearest_mag_sampler", error_message)) { + break; + } + if (!create_sampler_resource(vk_state, pixelview_sampler_ci, + texture.pixelview_sampler, + "vkCreateSampler failed for pixel closeup", + "imiv.viewer.pixelview_sampler", + error_message)) { + break; + } + + if (!allocate_descriptor_set_resource( + vk_state, vk_state.preview_descriptor_pool, + vk_state.preview_descriptor_set_layout, + texture.preview_source_set, + "vkAllocateDescriptorSets failed for preview source set", + error_message)) { + break; + } + VkDescriptorImageInfo preview_source_image = {}; + preview_source_image.sampler = texture.sampler; + preview_source_image.imageView = texture.source_view; + preview_source_image.imageLayout + = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkWriteDescriptorSet preview_write = make_image_descriptor_write( + texture.preview_source_set, 0, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &preview_source_image); + vkUpdateDescriptorSets(vk_state.device, 1, &preview_write, 0, nullptr); + + VkBufferCopy copy_region = {}; + copy_region.srcOffset = 0; + copy_region.dstOffset = 0; + copy_region.size = upload_size_aligned; + vkCmdCopyBuffer(upload_command, staging_buffer, source_buffer, 1, + ©_region); + + VkBufferMemoryBarrier source_to_compute = {}; + source_to_compute.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + source_to_compute.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + source_to_compute.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + source_to_compute.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + source_to_compute.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + source_to_compute.buffer = source_buffer; + source_to_compute.offset = 0; + source_to_compute.size = upload_size_aligned; + + VkImageMemoryBarrier image_to_general = make_color_image_memory_barrier( + texture.source_image, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_GENERAL, 0, VK_ACCESS_SHADER_WRITE_BIT); + + vkCmdPipelineBarrier(upload_command, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, + nullptr, 1, &source_to_compute, 1, + &image_to_general); + + vkCmdBindPipeline(upload_command, VK_PIPELINE_BIND_POINT_COMPUTE, + use_fp64_pipeline ? vk_state.compute_pipeline_fp64 + : vk_state.compute_pipeline); + for (uint32_t stripe_index = 0; stripe_index < stripe_plan.stripe_count; + ++stripe_index) { + const uint32_t stripe_y = stripe_index * stripe_plan.stripe_rows; + const uint32_t remaining_rows = static_cast(image.height) + - stripe_y; + const uint32_t stripe_height = std::min(stripe_plan.stripe_rows, + remaining_rows); + const uint32_t dynamic_offset = static_cast( + stripe_plan.descriptor_range_bytes + * static_cast(stripe_index)); + + vkCmdBindDescriptorSets(upload_command, + VK_PIPELINE_BIND_POINT_COMPUTE, + vk_state.compute_pipeline_layout, 0, 1, + &compute_set, 1, &dynamic_offset); + + UploadComputePushConstants push = {}; + push.width = static_cast(image.width); + push.height = stripe_height; + push.row_pitch_bytes = static_cast( + stripe_plan.aligned_row_pitch_bytes); + push.pixel_stride = static_cast(pixel_stride_bytes); + push.channel_count = static_cast(channel_count); + push.data_type = static_cast(upload_type); + push.dst_x = 0; + push.dst_y = stripe_y; + vkCmdPushConstants(upload_command, vk_state.compute_pipeline_layout, + VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(push), + &push); + + const uint32_t group_x = (push.width + 15u) / 16u; + const uint32_t group_y = (push.height + 15u) / 16u; + vkCmdDispatch(upload_command, group_x, group_y, 1); + } + + VkImageMemoryBarrier to_shader = make_color_image_memory_barrier( + texture.source_image, VK_IMAGE_LAYOUT_GENERAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT); + vkCmdPipelineBarrier(upload_command, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, + nullptr, 0, nullptr, 1, &to_shader); + + err = vkEndCommandBuffer(upload_command); + if (err != VK_SUCCESS) { + error_message = "vkEndCommandBuffer failed for upload update"; + break; + } + err = vkResetFences(vk_state.device, 1, &texture.upload_submit_fence); + if (err != VK_SUCCESS) { + error_message = "vkResetFences failed for upload async submit"; + break; + } + + VkSubmitInfo submit = {}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &upload_command; + log_upload_stage(vk_state, texture, "submit_begin", + Strutil::fmt::format( + "command_buffer={} fence={}", + vk_handle_to_u64(upload_command), + vk_handle_to_u64(texture.upload_submit_fence))); + err = vkQueueSubmit(vk_state.queue, 1, &submit, + texture.upload_submit_fence); + if (err != VK_SUCCESS) { + log_upload_stage(vk_state, texture, "submit_error", + Strutil::fmt::format("vk_result={}", + static_cast(err))); + error_message = "vkQueueSubmit failed for upload update"; + break; + } + log_upload_stage(vk_state, texture, "submit_complete"); + upload_command = VK_NULL_HANDLE; + + texture.upload_staging_buffer = staging_buffer; + texture.upload_staging_memory = staging_memory; + texture.upload_source_buffer = source_buffer; + texture.upload_source_memory = source_memory; + texture.upload_compute_set = compute_set; + texture.upload_submit_pending = true; + staging_buffer = VK_NULL_HANDLE; + staging_memory = VK_NULL_HANDLE; + source_buffer = VK_NULL_HANDLE; + source_memory = VK_NULL_HANDLE; + compute_set = VK_NULL_HANDLE; + + texture.set = ImGui_ImplVulkan_AddTexture( + texture.sampler, texture.view, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + if (texture.set == VK_NULL_HANDLE) { + error_message = "ImGui_ImplVulkan_AddTexture failed"; + break; + } + texture.nearest_mag_set = ImGui_ImplVulkan_AddTexture( + texture.nearest_mag_sampler, texture.view, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + if (texture.nearest_mag_set == VK_NULL_HANDLE) { + error_message + = "ImGui_ImplVulkan_AddTexture failed for nearest-mag " + "view"; + break; + } + texture.pixelview_set = ImGui_ImplVulkan_AddTexture( + texture.pixelview_sampler, texture.view, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + if (texture.pixelview_set == VK_NULL_HANDLE) { + error_message + = "ImGui_ImplVulkan_AddTexture failed for pixel closeup"; + break; + } + + texture.width = image.width; + texture.height = image.height; + texture.source_ready = false; + texture.preview_initialized = false; + texture.preview_dirty = true; + texture.preview_params_valid = false; + ok = true; + + } while (false); + + if (compute_set != VK_NULL_HANDLE) + vkFreeDescriptorSets(vk_state.device, vk_state.compute_descriptor_pool, + 1, &compute_set); + if (source_buffer != VK_NULL_HANDLE) + vkDestroyBuffer(vk_state.device, source_buffer, vk_state.allocator); + if (source_memory != VK_NULL_HANDLE) + vkFreeMemory(vk_state.device, source_memory, vk_state.allocator); + if (staging_buffer != VK_NULL_HANDLE) + vkDestroyBuffer(vk_state.device, staging_buffer, vk_state.allocator); + if (staging_memory != VK_NULL_HANDLE) + vkFreeMemory(vk_state.device, staging_memory, vk_state.allocator); + if (!ok) + destroy_texture(vk_state, texture); + return ok; +} + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_texture_internal.h b/src/imiv/imiv_vulkan_texture_internal.h new file mode 100644 index 0000000000..4fcd4e21ae --- /dev/null +++ b/src/imiv/imiv_vulkan_texture_internal.h @@ -0,0 +1,26 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_vulkan_types.h" + +namespace Imiv { + +#if defined(IMIV_WITH_VULKAN) + +void +destroy_texture_upload_submit_resources(VulkanState& vk_state, + VulkanTexture& texture); +bool +poll_texture_upload_submission(VulkanState& vk_state, VulkanTexture& texture, + bool wait_for_completion, + std::string& error_message); +void +destroy_texture_preview_submit_resources(VulkanState& vk_state, + VulkanTexture& texture); + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_vulkan_types.h b/src/imiv/imiv_vulkan_types.h new file mode 100644 index 0000000000..1084639035 --- /dev/null +++ b/src/imiv/imiv_vulkan_types.h @@ -0,0 +1,342 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_types.h" + +#include + +#include + +namespace Imiv { + +#if IMIV_WITH_VULKAN + +struct PlaceholderUiState; +struct OcioShaderRuntime; + +struct VulkanTexture { + VkImage source_image = VK_NULL_HANDLE; + VkImageView source_view = VK_NULL_HANDLE; + VkDeviceMemory source_memory = VK_NULL_HANDLE; + + VkImage image = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + + VkFramebuffer preview_framebuffer = VK_NULL_HANDLE; + VkDescriptorSet preview_source_set = VK_NULL_HANDLE; + VkSampler sampler = VK_NULL_HANDLE; + VkSampler nearest_mag_sampler = VK_NULL_HANDLE; + VkSampler pixelview_sampler = VK_NULL_HANDLE; + VkDescriptorSet set = VK_NULL_HANDLE; + VkDescriptorSet nearest_mag_set = VK_NULL_HANDLE; + VkDescriptorSet pixelview_set = VK_NULL_HANDLE; + VkBuffer upload_staging_buffer = VK_NULL_HANDLE; + VkDeviceMemory upload_staging_memory = VK_NULL_HANDLE; + VkBuffer upload_source_buffer = VK_NULL_HANDLE; + VkDeviceMemory upload_source_memory = VK_NULL_HANDLE; + VkDescriptorSet upload_compute_set = VK_NULL_HANDLE; + VkCommandPool upload_command_pool = VK_NULL_HANDLE; + VkCommandBuffer upload_command_buffer = VK_NULL_HANDLE; + VkFence upload_submit_fence = VK_NULL_HANDLE; + VkCommandPool preview_command_pool = VK_NULL_HANDLE; + VkCommandBuffer preview_command_buffer = VK_NULL_HANDLE; + VkFence preview_submit_fence = VK_NULL_HANDLE; + std::string debug_label; + int width = 0; + int height = 0; + bool source_ready = false; + bool upload_submit_pending = false; + bool preview_initialized = false; + bool preview_submit_pending = false; + bool preview_dirty = false; + bool preview_params_valid = false; + PreviewControls last_preview_controls = {}; + PreviewControls preview_submit_controls = {}; + + VulkanTexture() = default; + VulkanTexture(const VulkanTexture&) = delete; + VulkanTexture& operator=(const VulkanTexture&) = delete; + + VulkanTexture(VulkanTexture&& other) noexcept { *this = std::move(other); } + + VulkanTexture& operator=(VulkanTexture&& other) noexcept + { + if (this == &other) + return *this; + source_image = std::exchange(other.source_image, VK_NULL_HANDLE); + source_view = std::exchange(other.source_view, VK_NULL_HANDLE); + source_memory = std::exchange(other.source_memory, VK_NULL_HANDLE); + image = std::exchange(other.image, VK_NULL_HANDLE); + view = std::exchange(other.view, VK_NULL_HANDLE); + memory = std::exchange(other.memory, VK_NULL_HANDLE); + preview_framebuffer = std::exchange(other.preview_framebuffer, + VK_NULL_HANDLE); + preview_source_set = std::exchange(other.preview_source_set, + VK_NULL_HANDLE); + sampler = std::exchange(other.sampler, VK_NULL_HANDLE); + nearest_mag_sampler = std::exchange(other.nearest_mag_sampler, + VK_NULL_HANDLE); + pixelview_sampler = std::exchange(other.pixelview_sampler, + VK_NULL_HANDLE); + set = std::exchange(other.set, VK_NULL_HANDLE); + nearest_mag_set = std::exchange(other.nearest_mag_set, VK_NULL_HANDLE); + pixelview_set = std::exchange(other.pixelview_set, VK_NULL_HANDLE); + upload_staging_buffer = std::exchange(other.upload_staging_buffer, + VK_NULL_HANDLE); + upload_staging_memory = std::exchange(other.upload_staging_memory, + VK_NULL_HANDLE); + upload_source_buffer = std::exchange(other.upload_source_buffer, + VK_NULL_HANDLE); + upload_source_memory = std::exchange(other.upload_source_memory, + VK_NULL_HANDLE); + upload_compute_set = std::exchange(other.upload_compute_set, + VK_NULL_HANDLE); + upload_command_pool = std::exchange(other.upload_command_pool, + VK_NULL_HANDLE); + upload_command_buffer = std::exchange(other.upload_command_buffer, + VK_NULL_HANDLE); + upload_submit_fence = std::exchange(other.upload_submit_fence, + VK_NULL_HANDLE); + preview_command_pool = std::exchange(other.preview_command_pool, + VK_NULL_HANDLE); + preview_command_buffer = std::exchange(other.preview_command_buffer, + VK_NULL_HANDLE); + preview_submit_fence = std::exchange(other.preview_submit_fence, + VK_NULL_HANDLE); + debug_label = std::move(other.debug_label); + width = std::exchange(other.width, 0); + height = std::exchange(other.height, 0); + source_ready = std::exchange(other.source_ready, false); + upload_submit_pending = std::exchange(other.upload_submit_pending, + false); + preview_initialized = std::exchange(other.preview_initialized, false); + preview_submit_pending = std::exchange(other.preview_submit_pending, + false); + preview_dirty = std::exchange(other.preview_dirty, false); + preview_params_valid = std::exchange(other.preview_params_valid, false); + last_preview_controls = std::exchange(other.last_preview_controls, {}); + preview_submit_controls = std::exchange(other.preview_submit_controls, + {}); + return *this; + } +}; + +struct RetiredVulkanTexture { + VulkanTexture texture; + uint64_t retire_after_main_submit_serial = 0; +}; + +struct UploadComputePushConstants { + uint32_t width = 0; + uint32_t height = 0; + uint32_t row_pitch_bytes = 0; + uint32_t pixel_stride = 0; + uint32_t channel_count = 0; + uint32_t data_type = 0; + uint32_t dst_x = 0; + uint32_t dst_y = 0; +}; + +struct PreviewPushConstants { + float exposure = 0.0f; + float gamma = 1.0f; + float offset = 0.0f; + int32_t color_mode = 0; + int32_t channel = 0; + int32_t use_ocio = 0; + int32_t orientation = 1; +}; + +struct OcioVulkanTexture { + VkImage image = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkSampler sampler = VK_NULL_HANDLE; + uint32_t binding = 0; + int width = 0; + int height = 0; + int depth = 0; +}; + +struct OcioVulkanState { + OcioShaderRuntime* runtime = nullptr; + VkDescriptorPool descriptor_pool = VK_NULL_HANDLE; + VkDescriptorSetLayout descriptor_set_layout = VK_NULL_HANDLE; + VkDescriptorSet descriptor_set = VK_NULL_HANDLE; + VkPipelineLayout pipeline_layout = VK_NULL_HANDLE; + VkPipeline pipeline = VK_NULL_HANDLE; + VkBuffer uniform_buffer = VK_NULL_HANDLE; + VkDeviceMemory uniform_memory = VK_NULL_HANDLE; + void* uniform_mapped = nullptr; + std::vector textures; + size_t uniform_buffer_size = 0; + std::string shader_cache_id; + bool ready = false; +}; + +struct VulkanState { + VkAllocationCallbacks* allocator = nullptr; + VkInstance instance = VK_NULL_HANDLE; + VkDebugUtilsMessengerEXT debug_messenger = VK_NULL_HANDLE; + uint32_t api_version = VK_API_VERSION_1_0; + int framebuffer_width = 0; + int framebuffer_height = 0; + VkPhysicalDevice physical_device = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + uint32_t queue_family = static_cast(-1); + VkQueueFamilyProperties queue_family_properties = {}; + VkQueue queue = VK_NULL_HANDLE; + VkPipelineCache pipeline_cache = VK_NULL_HANDLE; + VkDescriptorPool descriptor_pool = VK_NULL_HANDLE; + VkSurfaceKHR surface = VK_NULL_HANDLE; + ImGui_ImplVulkanH_Window window_data; + uint32_t min_image_count = 2; + bool swapchain_rebuild = false; + bool validation_layer_enabled = false; + bool debug_utils_enabled = false; + bool verbose_logging = false; + bool verbose_validation_output = false; + bool log_imgui_texture_updates = false; + bool queue_requires_full_image_copies = false; + bool warned_about_full_imgui_uploads = false; + bool compute_upload_ready = false; + bool compute_supports_float64 = false; + VkFormat compute_output_format = VK_FORMAT_UNDEFINED; + uint32_t max_storage_buffer_range = 0; + uint32_t min_storage_buffer_offset_alignment = 1; + VkDescriptorPool compute_descriptor_pool = VK_NULL_HANDLE; + VkDescriptorSetLayout compute_descriptor_set_layout = VK_NULL_HANDLE; + VkPipelineLayout compute_pipeline_layout = VK_NULL_HANDLE; + VkPipeline compute_pipeline = VK_NULL_HANDLE; + VkPipeline compute_pipeline_fp64 = VK_NULL_HANDLE; + + VkDescriptorPool preview_descriptor_pool = VK_NULL_HANDLE; + VkDescriptorSetLayout preview_descriptor_set_layout = VK_NULL_HANDLE; + VkPipelineLayout preview_pipeline_layout = VK_NULL_HANDLE; + VkPipeline preview_pipeline = VK_NULL_HANDLE; + VkRenderPass preview_render_pass = VK_NULL_HANDLE; + OcioVulkanState ocio; + VkCommandPool immediate_command_pool = VK_NULL_HANDLE; + VkCommandBuffer immediate_command_buffer = VK_NULL_HANDLE; + VkFence immediate_submit_fence = VK_NULL_HANDLE; + PFN_vkSetDebugUtilsObjectNameEXT set_debug_object_name_fn = nullptr; + uint32_t max_image_dimension_2d = 0; + uint64_t next_main_submit_serial = 1; + uint64_t completed_main_submit_serial = 0; + std::vector window_frame_submit_serials; + std::vector retired_textures; +}; + +void +check_vk_result(VkResult err); +bool +find_memory_type(VkPhysicalDevice physical_device, uint32_t type_bits, + VkMemoryPropertyFlags required, uint32_t& memory_type_index); +bool +find_memory_type_with_fallback(VkPhysicalDevice physical_device, + uint32_t type_bits, + VkMemoryPropertyFlags preferred, + uint32_t& memory_type_index); + +template +inline uint64_t +vk_handle_to_u64(HandleT handle) +{ + if constexpr (std::is_pointer::value) { + return static_cast(reinterpret_cast(handle)); + } else { + return static_cast(handle); + } +} + +template +inline void +set_vk_object_name(VulkanState& vk_state, VkObjectType object_type, + HandleT handle, const char* name) +{ + if (vk_state.set_debug_object_name_fn == nullptr || handle == VK_NULL_HANDLE + || name == nullptr || name[0] == '\0') { + return; + } + + VkDebugUtilsObjectNameInfoEXT info = {}; + info.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT; + info.objectType = object_type; + info.objectHandle = vk_handle_to_u64(handle); + info.pObjectName = name; + vk_state.set_debug_object_name_fn(vk_state.device, &info); +} + +void +destroy_texture(VulkanState& vk_state, VulkanTexture& texture); +void +retire_texture(VulkanState& vk_state, VulkanTexture& texture); +void +drain_retired_textures(VulkanState& vk_state, bool force); +bool +create_texture(VulkanState& vk_state, const LoadedImage& image, + VulkanTexture& texture, std::string& error_message); +bool +quiesce_texture_preview_submission(VulkanState& vk_state, + VulkanTexture& texture, + std::string& error_message); +bool +update_preview_texture(VulkanState& vk_state, VulkanTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message); +bool +ensure_ocio_preview_resources(VulkanState& vk_state, VulkanTexture& texture, + const LoadedImage* image, + const PlaceholderUiState& ui_state, + const PreviewControls& controls, + std::string& error_message); +void +destroy_ocio_preview_resources(VulkanState& vk_state); +void +name_window_frame_objects(VulkanState& vk_state); +bool +setup_vulkan_instance(VulkanState& vk_state, + ImVector& instance_extensions, + std::string& error_message); +bool +setup_vulkan_device(VulkanState& vk_state, std::string& error_message); +bool +setup_vulkan_window(VulkanState& vk_state, int width, int height, + std::string& error_message); +void +destroy_vulkan_surface(VulkanState& vk_state); +void +cleanup_vulkan_window(VulkanState& vk_state); +void +cleanup_vulkan(VulkanState& vk_state); +bool +imiv_vulkan_screen_capture(ImGuiID viewport_id, int x, int y, int w, int h, + unsigned int* pixels, void* user_data); +void +apply_imgui_texture_update_workarounds(VulkanState& vk_state, + ImDrawData* draw_data); +void +destroy_immediate_submit_resources(VulkanState& vk_state); +bool +begin_immediate_submit(VulkanState& vk_state, VkCommandBuffer& out_command, + std::string& error_message); +bool +end_immediate_submit(VulkanState& vk_state, VkCommandBuffer command_buffer, + std::string& error_message); +void +frame_render(VulkanState& vk_state, ImDrawData* draw_data); +void +frame_present(VulkanState& vk_state); +bool +capture_swapchain_region_rgba8(VulkanState& vk_state, int x, int y, int w, + int h, unsigned int* pixels); + +#endif + +} // namespace Imiv diff --git a/src/imiv/imiv_workspace_ui.cpp b/src/imiv/imiv_workspace_ui.cpp new file mode 100644 index 0000000000..780a94805e --- /dev/null +++ b/src/imiv/imiv_workspace_ui.cpp @@ -0,0 +1,766 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "imiv_workspace_ui.h" + +#include "imiv_actions.h" +#include "imiv_parse.h" +#include "imiv_test_engine.h" +#include "imiv_ui_metrics.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +using namespace OIIO; + +namespace Imiv { + +namespace { + + constexpr const char* k_image_window_title = "Image"; + constexpr const char* k_image_list_window_title = "Image List"; + +} // namespace + +void +ensure_image_list_default_layout(MultiViewWorkspace& workspace, + ImGuiID dockspace_id) +{ + if (!workspace.show_image_list_window + || workspace.image_list_layout_initialized) { + return; + } + + if (ImGui::FindWindowSettingsByID(ImHashStr(k_image_list_window_title)) + != nullptr) { + workspace.image_list_layout_initialized = true; + return; + } + + ImGuiDockNode* dockspace_node = ImGui::DockBuilderGetNode(dockspace_id); + if (dockspace_node == nullptr || dockspace_node->Size.x <= 0.0f) + return; + + const float ratio = std::clamp(UiMetrics::ImageList::kDockTargetWidth + / dockspace_node->Size.x, + UiMetrics::ImageList::kDockMinRatio, + UiMetrics::ImageList::kDockMaxRatio); + ImGuiID image_list_dock_id = 0; + ImGuiID image_view_dock_id = dockspace_id; + ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Right, ratio, + &image_list_dock_id, &image_view_dock_id); + if (image_list_dock_id == 0 || image_view_dock_id == 0) + return; + + workspace.image_view_dock_id = image_view_dock_id; + workspace.image_list_dock_id = image_list_dock_id; + workspace.image_list_force_dock = true; + ImGui::DockBuilderDockWindow(k_image_window_title, image_view_dock_id); + ImGui::DockBuilderDockWindow(k_image_list_window_title, image_list_dock_id); + ImGui::DockBuilderFinish(dockspace_id); + workspace.image_list_layout_initialized = true; +} + + + +void +reset_window_layouts(MultiViewWorkspace& workspace, + PlaceholderUiState& ui_state, ImGuiID dockspace_id) +{ + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::ClearIniSettings(); + ImGui::DockBuilderRemoveNode(dockspace_id); + ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace); + if (viewport != nullptr) + ImGui::DockBuilderSetNodeSize(dockspace_id, viewport->WorkSize); + ImGui::DockBuilderFinish(dockspace_id); + + ui_state.image_window_force_dock = true; + workspace.image_view_dock_id = dockspace_id; + workspace.image_list_dock_id = 0; + workspace.image_list_layout_initialized = false; + workspace.image_list_force_dock = workspace.show_image_list_window; + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr) { + view->force_dock = true; + view->request_focus = (view->id == workspace.active_view_id); + } + } + ImGui::GetIO().WantSaveIniSettings = true; +} + + +} // namespace Imiv + +namespace Imiv { +namespace { + + enum class PendingImageListActionKind { + None = 0, + OpenInActiveView, + OpenInNewView, + CloseInActiveView, + CloseInAllViews, + RemoveFromSession + }; + + struct PendingImageListAction { + PendingImageListActionKind kind = PendingImageListActionKind::None; + std::string path; + }; + + std::string normalize_image_list_path(const std::string& path) + { + if (path.empty()) + return std::string(); + std::filesystem::path fs_path(path); + std::error_code ec; + if (!fs_path.is_absolute()) { + const std::filesystem::path abs = std::filesystem::absolute(fs_path, + ec); + if (!ec) + fs_path = abs; + } + return fs_path.lexically_normal().string(); + } + + bool debug_image_list_windows_enabled() + { + static int cached_value = -1; + if (cached_value < 0) + cached_value = env_flag_is_truthy("IMIV_DEBUG_IMAGE_LIST_WINDOWS") + ? 1 + : 0; + return cached_value != 0; + } + + bool image_list_tooltips_disabled() + { + static int cached_value = -1; + if (cached_value < 0) { + if (env_flag_is_truthy("IMIV_DISABLE_IMAGE_LIST_TOOLTIPS")) { + cached_value = 1; + } else { + std::string ignored; + cached_value = (read_env_value("WSL_INTEROP", ignored) + || read_env_value("WSL_DISTRO_NAME", ignored)) + ? 1 + : 0; + } + } + return cached_value != 0; + } + + bool image_view_is_showing_path(const ViewerState& viewer, + const std::string& normalized_path) + { + return !viewer.image.path.empty() + && normalize_image_list_path(viewer.image.path) + == normalized_path; + } + + int image_list_open_view_count(const MultiViewWorkspace& workspace, + const std::string& normalized_path) + { + int count = 0; + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr + && image_view_is_showing_path(view->viewer, normalized_path)) { + ++count; + } + } + return count; + } + + std::vector + image_list_open_view_ids_for_path(const MultiViewWorkspace& workspace, + const std::string& normalized_path) + { + std::vector view_ids; + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr + && image_view_is_showing_path(view->viewer, normalized_path)) { + view_ids.push_back(view->id); + } + } + return view_ids; + } + + std::string image_list_open_view_badge(const MultiViewWorkspace& workspace, + const std::string& normalized_path) + { + const std::vector view_ids + = image_list_open_view_ids_for_path(workspace, normalized_path); + if (view_ids.empty()) + return std::string(); + std::string joined; + for (size_t i = 0; i < view_ids.size(); ++i) { + if (i > 0) + joined += ","; + joined += Strutil::to_string(view_ids[i]); + } + return Strutil::fmt::format(" [v:{}]", joined); + } + + bool activate_image_list_path(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, + const std::string& path) + { + if (!load_viewer_image(renderer_state, active_view, library, &ui_state, + path, ui_state.subimage_index, + ui_state.miplevel_index)) { + return false; + } + sync_workspace_library_state(workspace, library); + return true; + } + + bool open_image_list_path_in_new_view(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, + const std::string& path) + { + ImageViewWindow& new_view = append_image_view(workspace); + new_view.viewer.loaded_image_paths = library.loaded_image_paths; + new_view.viewer.recent_images = library.recent_images; + new_view.viewer.sort_mode = library.sort_mode; + new_view.viewer.sort_reverse = library.sort_reverse; + new_view.viewer.recipe = active_view.recipe; + new_view.request_focus = true; + if (!load_viewer_image(renderer_state, new_view.viewer, library, + &ui_state, path, ui_state.subimage_index, + ui_state.miplevel_index)) { + return false; + } + workspace.active_view_id = new_view.id; + sync_workspace_library_state(workspace, library); + return true; + } + + void adjust_viewer_indices_after_remove(ViewerState& viewer, + int remove_index) + { + if (viewer.current_path_index == remove_index) { + viewer.current_path_index = -1; + } else if (viewer.current_path_index > remove_index) { + --viewer.current_path_index; + } + + if (viewer.last_path_index == remove_index) { + viewer.last_path_index = -1; + } else if (viewer.last_path_index > remove_index) { + --viewer.last_path_index; + } + } + + bool close_image_list_path_in_active_view(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, + const std::string& path) + { + const std::string normalized_path = normalize_image_list_path(path); + if (!image_view_is_showing_path(active_view, normalized_path)) + return false; + close_current_image_action(renderer_state, active_view, library, + ui_state); + sync_workspace_library_state(workspace, library); + active_view.status_message = Strutil::fmt::format("Closed {}", path); + active_view.last_error.clear(); + return true; + } + + bool close_image_list_path_in_all_views(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, + const std::string& path) + { + const std::string normalized_path = normalize_image_list_path(path); + bool closed_any = false; + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view == nullptr + || !image_view_is_showing_path(view->viewer, normalized_path)) { + continue; + } + close_current_image_action(renderer_state, view->viewer, library, + ui_state); + closed_any = true; + } + if (closed_any) { + active_view.status_message + = Strutil::fmt::format("Closed {} in all views", path); + active_view.last_error.clear(); + } + return closed_any; + } + + bool remove_image_list_path_from_session(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, + const std::string& path) + { + const bool was_image_list_visible = workspace.show_image_list_window; + const std::string normalized_path = normalize_image_list_path(path); + const auto it = std::find(library.loaded_image_paths.begin(), + library.loaded_image_paths.end(), + normalized_path); + if (it == library.loaded_image_paths.end()) + return false; + + const int remove_index = static_cast( + std::distance(library.loaded_image_paths.begin(), it)); + std::vector affected_views; + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view == nullptr + || !image_view_is_showing_path(view->viewer, normalized_path)) { + continue; + } + affected_views.push_back(view.get()); + close_current_image_action(renderer_state, view->viewer, library, + ui_state); + } + + library.loaded_image_paths.erase(it); + std::string replacement_path; + if (!library.loaded_image_paths.empty()) { + const int replacement_index + = std::min(remove_index, + static_cast(library.loaded_image_paths.size()) + - 1); + replacement_path = library.loaded_image_paths[static_cast( + replacement_index)]; + } + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view == nullptr) + continue; + adjust_viewer_indices_after_remove(view->viewer, remove_index); + view->viewer.loaded_image_paths = library.loaded_image_paths; + view->viewer.recent_images = library.recent_images; + view->viewer.sort_mode = library.sort_mode; + view->viewer.sort_reverse = library.sort_reverse; + } + if (!replacement_path.empty()) { + for (ImageViewWindow* view : affected_views) { + if (view == nullptr) + continue; + load_viewer_image(renderer_state, view->viewer, library, + &ui_state, replacement_path, 0, 0); + } + } + bool any_view_loaded = false; + for (const std::unique_ptr& view : + workspace.view_windows) { + if (view != nullptr && !view->viewer.image.path.empty()) { + any_view_loaded = true; + break; + } + } + if (!replacement_path.empty() && !any_view_loaded) { + ViewerState& primary_view + = ensure_primary_image_view(workspace).viewer; + load_viewer_image(renderer_state, primary_view, library, &ui_state, + replacement_path, 0, 0); + } + sync_workspace_library_state(workspace, library); + update_image_list_visibility_policy(workspace, library); + if (was_image_list_visible && !library.loaded_image_paths.empty()) { + workspace.show_image_list_window = true; + } + active_view.status_message + = Strutil::fmt::format("Removed {} from session", path); + active_view.last_error.clear(); + return true; + } + + void apply_test_engine_image_list_overrides(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state) + { + const int apply_frame + = env_int_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_APPLY_FRAME", + -1); + if (apply_frame < 0 || ImGui::GetFrameCount() != apply_frame) + return; + + const int select_index + = env_int_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_SELECT_INDEX", + -1); + if (select_index >= 0 + && select_index + < static_cast(library.loaded_image_paths.size())) { + activate_image_list_path(workspace, library, active_view, ui_state, + renderer_state, + library.loaded_image_paths[select_index]); + } + + const int open_new_view_index = env_int_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_OPEN_NEW_VIEW_INDEX", -1); + if (open_new_view_index >= 0 + && open_new_view_index + < static_cast(library.loaded_image_paths.size())) { + open_image_list_path_in_new_view( + workspace, library, active_view, ui_state, renderer_state, + library.loaded_image_paths[open_new_view_index]); + } + + const int close_active_index = env_int_value( + "IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_CLOSE_ACTIVE_INDEX", -1); + if (close_active_index >= 0 + && close_active_index + < static_cast(library.loaded_image_paths.size())) { + close_image_list_path_in_active_view( + workspace, library, active_view, ui_state, renderer_state, + library.loaded_image_paths[close_active_index]); + } + + const int remove_index + = env_int_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_REMOVE_INDEX", + -1); + if (remove_index >= 0 + && remove_index + < static_cast(library.loaded_image_paths.size())) { + remove_image_list_path_from_session( + workspace, library, active_view, ui_state, renderer_state, + library.loaded_image_paths[remove_index]); + } + } + + void request_image_list_action(PendingImageListAction& pending_action, + PendingImageListActionKind kind, + const std::string& path) + { + pending_action.kind = kind; + pending_action.path = path; + } + + void append_image_list_item_rect(MultiViewWorkspace& workspace) + { + const ImVec2 item_min = ImGui::GetItemRectMin(); + const ImVec2 item_max = ImGui::GetItemRectMax(); + workspace.image_list_item_rects.emplace_back(item_min.x, item_min.y, + item_max.x, item_max.y); + } + + void maybe_show_image_list_item_tooltip(const std::string& filename, + const std::string& path, + bool& showed_tooltip_this_frame, + std::string& last_logged_path) + { + if (!ImGui::IsItemHovered() || filename == path) + return; + + showed_tooltip_this_frame = true; + if (debug_image_list_windows_enabled() && last_logged_path != path) { + print("imiv: Image List tooltip {} for '{}'\n", + image_list_tooltips_disabled() ? "suppressed" : "requested", + path); + last_logged_path = path; + } + if (!image_list_tooltips_disabled()) + ImGui::SetTooltip("%s", path.c_str()); + } + + void draw_image_list_row(MultiViewWorkspace& workspace, + const ImageLibraryState& library, + const ViewerState& active_view, size_t index, + PendingImageListAction& pending_action, + bool& showed_tooltip_this_frame, + std::string& last_logged_tooltip_path) + { + const std::string& path = library.loaded_image_paths[index]; + const std::string normalized_path = normalize_image_list_path(path); + const std::filesystem::path fs_path(path); + const std::string filename = fs_path.filename().empty() + ? path + : fs_path.filename().string(); + const bool selected = active_view.current_path_index + == static_cast(index); + const bool active_view_image + = image_view_is_showing_path(active_view, normalized_path); + const int open_count = image_list_open_view_count(workspace, + normalized_path); + const std::string open_badge + = image_list_open_view_badge(workspace, normalized_path); + const std::string item_label + = Strutil::fmt::format("{} {}. {}{}###imiv_image_list_item_{}", + active_view_image ? ">" : " ", + static_cast(index + 1), filename, + open_badge, static_cast(index)); + const std::string test_label + = Strutil::fmt::format("image_list_item_{}", + static_cast(index)); + const std::string close_button_id + = Strutil::fmt::format("x##imiv_image_list_close_{}", + static_cast(index)); + const float row_width = ImGui::GetContentRegionAvail().x; + const float close_width = ImGui::CalcTextSize("x").x + + ImGui::GetStyle().FramePadding.x * 2.0f; + const float label_width = std::max( + 1.0f, row_width - (close_width + ImGui::GetStyle().ItemSpacing.x)); + ImGui::Selectable(item_label.c_str(), selected, + ImGuiSelectableFlags_AllowDoubleClick, + ImVec2(label_width, 0.0f)); + register_test_engine_item_label(test_label.c_str(), false); + register_layout_dump_synthetic_item("selectable", test_label.c_str()); + append_image_list_item_rect(workspace); + maybe_show_image_list_item_tooltip(filename, path, + showed_tooltip_this_frame, + last_logged_tooltip_path); + if (ImGui::IsItemClicked()) { + request_image_list_action( + pending_action, PendingImageListActionKind::OpenInActiveView, + path); + } + if (ImGui::IsItemHovered() + && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + request_image_list_action(pending_action, + PendingImageListActionKind::OpenInNewView, + path); + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Open in active view")) { + request_image_list_action( + pending_action, + PendingImageListActionKind::OpenInActiveView, path); + } + if (ImGui::MenuItem("Open in new view")) { + request_image_list_action( + pending_action, PendingImageListActionKind::OpenInNewView, + path); + } + if (ImGui::MenuItem("Close in active view", nullptr, false, + active_view_image)) { + request_image_list_action( + pending_action, + PendingImageListActionKind::CloseInActiveView, path); + } + if (ImGui::MenuItem("Close in all views", nullptr, false, + open_count > 0)) { + request_image_list_action( + pending_action, PendingImageListActionKind::CloseInAllViews, + path); + } + if (ImGui::MenuItem("Remove from session")) { + request_image_list_action( + pending_action, + PendingImageListActionKind::RemoveFromSession, path); + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + if (ImGui::SmallButton(close_button_id.c_str())) { + request_image_list_action( + pending_action, PendingImageListActionKind::RemoveFromSession, + path); + } + } + + void draw_image_list_items(MultiViewWorkspace& workspace, + const ImageLibraryState& library, + const ViewerState& active_view, + PendingImageListAction& pending_action) + { + static std::string s_last_logged_tooltip_path; + bool showed_tooltip_this_frame = false; + if (ImGui::BeginChild("##image_list_items", ImVec2(0.0f, 0.0f), false, + ImGuiWindowFlags_HorizontalScrollbar)) { + for (size_t i = 0, e = library.loaded_image_paths.size(); i < e; + ++i) { + draw_image_list_row(workspace, library, active_view, i, + pending_action, showed_tooltip_this_frame, + s_last_logged_tooltip_path); + } + } + ImGui::EndChild(); + + if (!showed_tooltip_this_frame) + s_last_logged_tooltip_path.clear(); + } + + void execute_pending_image_list_action(MultiViewWorkspace& workspace, + ImageLibraryState& library, + ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, + const PendingImageListAction& action) + { + if (action.path.empty()) + return; + + switch (action.kind) { + case PendingImageListActionKind::OpenInActiveView: + activate_image_list_path(workspace, library, active_view, ui_state, + renderer_state, action.path); + break; + case PendingImageListActionKind::OpenInNewView: + open_image_list_path_in_new_view(workspace, library, active_view, + ui_state, renderer_state, + action.path); + break; + case PendingImageListActionKind::CloseInActiveView: + close_image_list_path_in_active_view(workspace, library, + active_view, ui_state, + renderer_state, action.path); + break; + case PendingImageListActionKind::CloseInAllViews: + close_image_list_path_in_all_views(workspace, library, active_view, + ui_state, renderer_state, + action.path); + break; + case PendingImageListActionKind::RemoveFromSession: + remove_image_list_path_from_session(workspace, library, active_view, + ui_state, renderer_state, + action.path); + break; + case PendingImageListActionKind::None: break; + } + } + +} // namespace + +void +update_image_list_visibility_policy(MultiViewWorkspace& workspace, + const ImageLibraryState& library) +{ + const int image_count = static_cast(library.loaded_image_paths.size()); + if (image_count <= 0) { + workspace.show_image_list_window = false; + workspace.image_list_force_dock = false; + workspace.last_library_image_count = image_count; + return; + } + + if (image_count > 1 && image_count != workspace.last_library_image_count) { + workspace.show_image_list_window = true; + workspace.image_list_force_dock = true; + } + workspace.last_library_image_count = image_count; +} + +void +apply_test_engine_image_list_visibility_override(MultiViewWorkspace& workspace) +{ + const int apply_frame + = env_int_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_APPLY_FRAME", -1); + if (apply_frame < 0 || ImGui::GetFrameCount() != apply_frame) + return; + + std::string show_image_list_value; + if (read_env_value("IMIV_IMGUI_TEST_ENGINE_IMAGE_LIST_VISIBLE", + show_image_list_value)) { + bool visible = false; + if (parse_bool_string(show_image_list_value, visible)) { + workspace.show_image_list_window = visible; + if (visible) + workspace.image_list_force_dock = true; + } + } +} + +std::vector +image_list_open_view_ids(const MultiViewWorkspace& workspace, + const std::string& path) +{ + return image_list_open_view_ids_for_path(workspace, + normalize_image_list_path(path)); +} + +void +draw_image_list_window(MultiViewWorkspace& workspace, + ImageLibraryState& library, ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, bool reset_layout) +{ + workspace.image_list_was_drawn = false; + workspace.image_list_item_rects.clear(); + + if (!workspace.show_image_list_window) + return; + + if (workspace.image_list_request_focus) { + ImGui::SetNextWindowFocus(); + workspace.image_list_request_focus = false; + } + if (workspace.image_list_dock_id != 0) { + ImGui::SetNextWindowDockID(workspace.image_list_dock_id, + workspace.image_list_force_dock + ? ImGuiCond_Always + : ImGuiCond_FirstUseEver); + } else { + const ImGuiViewport* main_viewport = ImGui::GetMainViewport(); + if (main_viewport != nullptr) { + ImGui::SetNextWindowPos( + ImVec2(main_viewport->WorkPos.x + + UiMetrics::ImageList::kFloatingOffset.x, + main_viewport->WorkPos.y + + UiMetrics::ImageList::kFloatingOffset.y), + reset_layout ? ImGuiCond_Always : ImGuiCond_FirstUseEver); + } + } + ImGui::SetNextWindowSize(UiMetrics::ImageList::kDefaultWindowSize, + reset_layout ? ImGuiCond_Always + : ImGuiCond_FirstUseEver); + if (!ImGui::Begin(k_image_list_window_title, + &workspace.show_image_list_window)) { + workspace.image_list_was_drawn = true; + workspace.image_list_is_docked = ImGui::IsWindowDocked(); + workspace.image_list_pos = ImGui::GetWindowPos(); + workspace.image_list_size = ImGui::GetWindowSize(); + ImGui::End(); + return; + } + + workspace.image_list_was_drawn = true; + workspace.image_list_force_dock = !ImGui::IsWindowDocked(); + workspace.image_list_is_docked = ImGui::IsWindowDocked(); + workspace.image_list_pos = ImGui::GetWindowPos(); + workspace.image_list_size = ImGui::GetWindowSize(); + + ImGui::Text("Loaded images: %d", + static_cast(library.loaded_image_paths.size())); + ImGui::Separator(); + if (library.loaded_image_paths.empty()) { + ImGui::TextUnformatted("No images loaded."); + ImGui::End(); + return; + } + + apply_test_engine_image_list_overrides(workspace, library, active_view, + ui_state, renderer_state); + + PendingImageListAction pending_action; + draw_image_list_items(workspace, library, active_view, pending_action); + ImGui::End(); + + execute_pending_image_list_action(workspace, library, active_view, ui_state, + renderer_state, pending_action); +} + +} // namespace Imiv diff --git a/src/imiv/imiv_workspace_ui.h b/src/imiv/imiv_workspace_ui.h new file mode 100644 index 0000000000..aa3fd462f1 --- /dev/null +++ b/src/imiv/imiv_workspace_ui.h @@ -0,0 +1,36 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include "imiv_viewer.h" + +#include +#include + +#include + +namespace Imiv { + +void +ensure_image_list_default_layout(MultiViewWorkspace& workspace, + ImGuiID dockspace_id); +void +update_image_list_visibility_policy(MultiViewWorkspace& workspace, + const ImageLibraryState& library); +void +apply_test_engine_image_list_visibility_override(MultiViewWorkspace& workspace); +std::vector +image_list_open_view_ids(const MultiViewWorkspace& workspace, + const std::string& path); +void +draw_image_list_window(MultiViewWorkspace& workspace, + ImageLibraryState& library, ViewerState& active_view, + PlaceholderUiState& ui_state, + RendererState& renderer_state, bool reset_layout); +void +reset_window_layouts(MultiViewWorkspace& workspace, + PlaceholderUiState& ui_state, ImGuiID dockspace_id); + +} // namespace Imiv diff --git a/src/imiv/multi_backend.md b/src/imiv/multi_backend.md new file mode 100644 index 0000000000..04e8942942 --- /dev/null +++ b/src/imiv/multi_backend.md @@ -0,0 +1,315 @@ +# imiv Multi-Backend Design + +## Goal + +Support multiple renderer backends in one `imiv` binary on the same platform, +with launch-time selection from CLI or saved preferences. + +This document describes the current implementation, not a future-only plan. + + +## Current State + +`imiv` now supports: + +- one binary with some or all of: + - `Vulkan` + - `Metal` + - `OpenGL` +- runtime backend request via: + - `--backend` + - saved `renderer_backend` preference + - platform/default fallback +- backend inspection via: + - `--list-backends` +- persistent backend preference in `imiv.inf` +- Preferences UI backend selector with restart-required semantics + +At the time of writing: + +- Vulkan remains the reference renderer for renderer-side feature work +- the shared macOS backend verifier is green on: + - `Vulkan` + - `Metal` + - `OpenGL` + +This design does **not** attempt live in-process backend switching. Backend +changes apply on the next launch. + + +## Build Model + +Backends are compiled independently through CMake: + +```text +-D OIIO_IMIV_ENABLE_VULKAN=AUTO|ON|OFF +-D OIIO_IMIV_ENABLE_METAL=AUTO|ON|OFF +-D OIIO_IMIV_ENABLE_OPENGL=AUTO|ON|OFF +-D OIIO_IMIV_DEFAULT_RENDERER=auto|vulkan|metal|opengl +``` + +Generated build metadata lives in: + +- [imiv_build_config.h.in](/mnt/f/gh/openimageio/src/imiv/imiv_build_config.h.in) + +Generated build-capability macros: + +- `IMIV_WITH_VULKAN` +- `IMIV_WITH_METAL` +- `IMIV_WITH_OPENGL` +- `IMIV_BUILD_DEFAULT_BACKEND_KIND` + +Compatibility note: + +- `OIIO_IMIV_RENDERER` still exists as a deprecated compatibility alias and is + treated as `OIIO_IMIV_DEFAULT_RENDERER` + + +Embedded assets +--------------- + +Current multi-backend builds package two kinds of embedded runtime assets: + +- fonts: + - controlled by `OIIO_IMIV_EMBED_FONTS` + - default `ON` + - embeds `DroidSans.ttf` and `DroidSansMono.ttf` +- Vulkan static shaders: + - embedded automatically when the Vulkan backend is compiled + - covers the fixed upload/preview SPIR-V shaders from `src/imiv/shaders/` + +This keeps one binary self-contained for the most common UI and static Vulkan +renderer assets. It does **not** eliminate all runtime shader work: + +- Vulkan OCIO shaders are still generated at runtime from the active OCIO + configuration. +- OpenGL still compiles GLSL at runtime. +- Metal still compiles MSL source at runtime. + + +## Runtime Model + +The runtime backend metadata layer lives in: + +- [imiv_backend.h](/mnt/f/gh/openimageio/src/imiv/imiv_backend.h) +- [imiv_renderer.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer.cpp) + +Core types: + +- `BackendKind` +- `BackendInfo` +- `BackendRuntimeInfo` + +The runtime model answers: + +- which backends are compiled in +- which compiled backends are currently available +- why a compiled backend is unavailable +- which backend is the build default +- which backend is the platform default +- which backend was requested +- which backend is resolved for the current launch + +Launch-time selection precedence: + +1. CLI `--backend` +2. saved `renderer_backend` preference +3. configured default renderer, if runtime-available +4. platform-default compiled backend, if runtime-available +5. first runtime-available compiled backend fallback + +Runtime availability is probed through the renderer/backend seam and cached in +the backend registry. The current implementation exposes per-backend +availability and unavailability reasons to: + +- `--list-backends` +- Preferences +- test-engine state JSON + + +## CLI + +Implemented CLI options: + +- `--backend auto|vulkan|metal|opengl` +- `--list-backends` + +Entry point: + +- [imiv_main.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_main.cpp) + +Behavior: + +- `--backend` overrides the saved preference for that launch only +- `--list-backends` prints compiled backend support, runtime availability, and + any unavailability reason, then exits + + +## Preferences UX + +Implemented in: + +- [imiv_aux_windows.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_aux_windows.cpp) + +Behavior: + +- backend selection is shown as equal-width buttons +- runtime-available compiled backend choices are selectable +- compiled but runtime-unavailable backends are shown disabled with a reason +- changing the requested backend updates the next-launch backend +- the current process keeps using the already-active backend +- the UI shows a restart-required note when the next launch would differ +- invalid or unavailable persisted backend requests are reset to `Auto` when + Preferences closes + +Persistence: + +- `renderer_backend=auto|vulkan|metal|opengl` +- stored in `imiv.inf` +- loaded/saved through: + - [imiv_viewer.h](/mnt/f/gh/openimageio/src/imiv/imiv_viewer.h) + - [imiv_viewer.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_viewer.cpp) + + +## Platform Policy + +Current runtime default resolution in [imiv_renderer.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer.cpp): + +### Windows + +Preferred order: + +- `Vulkan` +- `OpenGL` +- `Metal` + +Typical compiled set: + +- `Vulkan` +- `OpenGL` + +### Linux / WSL + +Preferred order: + +- `Vulkan` +- `OpenGL` +- `Metal` + +Typical compiled set: + +- `Vulkan` +- `OpenGL` + +### macOS + +Preferred order: + +- `Metal` +- `Vulkan` +- `OpenGL` + +Typical compiled set: + +- `Metal` +- `OpenGL` +- optional `Vulkan` via MoltenVK / Vulkan loader availability + + +## Renderer Boundary + +The multi-backend architecture still depends on the renderer seam and backend +split introduced earlier. + +Shared renderer interfaces: + +- [imiv_renderer.h](/mnt/f/gh/openimageio/src/imiv/imiv_renderer.h) +- [imiv_renderer_backend.h](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_backend.h) +- [imiv_renderer.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer.cpp) + +Backend implementations: + +- Vulkan: + - [imiv_renderer_vulkan.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_vulkan.cpp) + - [imiv_vulkan_setup.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_setup.cpp) + - [imiv_vulkan_runtime.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_runtime.cpp) + - [imiv_vulkan_texture.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_texture.cpp) + - [imiv_vulkan_preview.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_preview.cpp) + - [imiv_vulkan_ocio.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_vulkan_ocio.cpp) +- Metal: + - [imiv_renderer_metal.mm](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_metal.mm) +- OpenGL: + - [imiv_renderer_opengl.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_renderer_opengl.cpp) + +Platform layer: + +- [imiv_platform_glfw.h](/mnt/f/gh/openimageio/src/imiv/imiv_platform_glfw.h) +- [imiv_platform_glfw.cpp](/mnt/f/gh/openimageio/src/imiv/imiv_platform_glfw.cpp) + + +## Validation + +Canonical shared verifier: + +- [imiv_backend_verify.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_backend_verify.py) + +Shared suite: + +- `smoke` +- `rgb` +- `ux` +- `sampling` +- `ocio_missing` +- `ocio_config_source` +- `ocio_live` +- `ocio_live_display` + +Related focused regressions: + +- [imiv_rgb_input_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_rgb_input_regression.py) +- [imiv_sampling_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_sampling_regression.py) +- [imiv_backend_preferences_regression.py](/mnt/f/gh/openimageio/src/imiv/tools/imiv_backend_preferences_regression.py) + +Optional shared per-backend CTest entries: + +- `imiv_backend_verify_vulkan` +- `imiv_backend_verify_opengl` +- `imiv_backend_verify_metal` + +Enable with: + +- `OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST=ON` + + +## What Is Done + +Completed slices: + +- generated build-capability metadata +- runtime backend metadata API +- CLI backend request and listing +- persisted backend preference +- multi-backend CMake enable switches +- Preferences backend selector +- restart-required behavior +- shared backend verifier +- current macOS green shared-suite coverage on all three backends + + +## Remaining Work + +Reasonable next steps, if needed: + +1. expose richer runtime availability/failure reasons in the backend metadata +2. decide how far to take backend-specific CI coverage +3. decide whether distributed macOS Vulkan builds should bundle MoltenVK +4. keep backend docs and verification coverage aligned as behavior changes + + +## Non-Goals + +These are still intentionally out of scope: + +- live in-process renderer switching +- automatic app restart after backend change +- collapsing renderer-specific code back into shared UI/viewer layers diff --git a/src/imiv/multi_backend_progress.md b/src/imiv/multi_backend_progress.md new file mode 100644 index 0000000000..dfd37cbba0 --- /dev/null +++ b/src/imiv/multi_backend_progress.md @@ -0,0 +1,217 @@ +# imiv Multi-Backend Progress + +Last updated: 2026-03-19 + +## Current status + +Work already landed in the tree: + +- Multi-backend design note: + - `src/imiv/multi_backend.md` +- Build-time backend capabilities: + - `src/imiv/imiv_build_config.h.in` +- Runtime backend metadata and selection: + - `src/imiv/imiv_backend.h` + - `src/imiv/imiv_renderer.cpp` +- Renderer seam moved to runtime backend dispatch: + - `src/imiv/imiv_renderer.h` + - `src/imiv/imiv_renderer.cpp` + - `src/imiv/imiv_renderer_backend.h` + - `src/imiv/imiv_renderer_vulkan.cpp` + - `src/imiv/imiv_renderer_opengl.cpp` + - `src/imiv/imiv_renderer_metal.mm` +- GLFW platform init now selects backend at runtime: + - `src/imiv/imiv_platform_glfw.h` + - `src/imiv/imiv_platform_glfw.cpp` +- App startup honors: + - CLI `--backend` + - persisted `renderer_backend` + - runtime fallback if requested backend is not compiled +- `imiv --list-backends` reports compiled/default backends. + +## This session's additional work + +Backend override plumbing for tests is implemented, and initial runtime +validation is complete on Linux/WSL. + +Changed in this slice: + +- `src/imiv/tools/imiv_gui_test_run.py` + - added `--backend` + - passes `--backend ...` through to `imiv` +- `src/imiv/tools/imiv_backend_verify.py` + - now passes backend override through all smoke / UX / OCIO launches +- Regression scripts updated to accept `--backend`: + - `src/imiv/tools/imiv_ux_actions_regression.py` + - `src/imiv/tools/imiv_ocio_missing_fallback_regression.py` + - `src/imiv/tools/imiv_ocio_config_source_regression.py` + - `src/imiv/tools/imiv_ocio_live_update_regression.py` + - `src/imiv/tools/imiv_opengl_smoke_regression.py` + - `src/imiv/tools/imiv_metal_smoke_regression.py` + - `src/imiv/tools/imiv_metal_screenshot_regression.py` + - `src/imiv/tools/imiv_metal_sampling_regression.py` + - `src/imiv/tools/imiv_metal_orientation_regression.py` + - `src/imiv/tools/imiv_developer_menu_regression.py` + - `src/imiv/tools/imiv_area_probe_closeup_regression.py` + - `src/imiv/tools/imiv_auto_subimage_regression.py` +- `src/imiv/CMakeLists.txt` + - added `OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST` option + - default-backend GUI tests now pass explicit `--backend` + - added optional per-backend shared verification `ctest` entries: + - `imiv_backend_verify_vulkan` + - `imiv_backend_verify_opengl` + - `imiv_backend_verify_metal` +- Backend state is now present in viewer-state JSON: + - `src/imiv/imiv_frame.h` + - `src/imiv/imiv_frame.cpp` + - includes: + - `active` + - `requested` + - `next_launch` + - `restart_required` + - compiled/unavailable backend lists +- Added focused backend-selector regression: + - `src/imiv/tools/imiv_backend_preferences_regression.py` + - `src/imiv/CMakeLists.txt` + - verifies: + - selecting an alternate backend changes `requested` / `next_launch` + - restart-required semantics + - selecting active backend clears restart-required + - selecting `Auto` resolves back to the build default +- Preferences UI now exposes renderer selection: + - `src/imiv/imiv_aux_windows.cpp` + - `src/imiv/imiv_ui.h` + - `src/imiv/imiv_frame.cpp` + - dropdown: `Auto / compiled backends` + - shows current backend + - shows restart-required warning only when next-launch backend would differ + - shows `not built` for unavailable backends + +## Validation done + +Passed: + +- Python syntax check: + - `python3 -m py_compile ...` + - covered all touched Python scripts in this slice +- Reconfigure + rebuild: + - `cmake -S . -B build_u -D OIIO_IMIV_ENABLE_VULKAN=AUTO -D OIIO_IMIV_ENABLE_OPENGL=AUTO -D OIIO_IMIV_ENABLE_METAL=OFF -D OIIO_IMIV_DEFAULT_RENDERER=vulkan` + - `ninja -C build_u imiv oiiotool idiff` +- Backend listing: + - `build_u/bin/imiv --list-backends` + - current build reports Vulkan + OpenGL compiled +- Explicit Vulkan launch through the new backend override path: + - `imiv_gui_test_run.py --backend vulkan ...` +- Explicit OpenGL smoke through the new backend override path: + - `imiv_opengl_smoke_regression.py --backend opengl ...` +- Default-backend UX regression after the Preferences backend UI change: + - `ctest --test-dir build_u -V -R '^imiv_ux_actions_regression$'` +- Backend selector regression: + - `ctest --test-dir build_u -V -R '^imiv_backend_preferences_regression$'` +- Optional backend-wide `ctest` registration path: + - `cmake -S . -B build_u ... -D OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST=ON` + - `ctest --test-dir build_u -N | rg 'imiv_backend_verify|imiv_backend_preferences'` +- Backend-wide non-default runtime verification from one build: + - `ctest --test-dir build_u -V -R '^imiv_backend_verify_opengl$'` + +Still not validated in this build tree: + +- macOS Metal build with the new Preferences backend selector +- Windows multi-backend runtime with the explicit backend test path + +## Next commands after WSL restart + +From repo root: + +```bash +cmake -S . -B build_u \ + -D OIIO_IMIV_ENABLE_VULKAN=AUTO \ + -D OIIO_IMIV_ENABLE_OPENGL=AUTO \ + -D OIIO_IMIV_ENABLE_METAL=OFF \ + -D OIIO_IMIV_DEFAULT_RENDERER=vulkan +``` + +```bash +ninja -C build_u imiv oiiotool idiff +``` + +Quick explicit backend smoke: + +```bash +python3 src/imiv/tools/imiv_gui_test_run.py \ + --bin build_u/bin/imiv \ + --cwd build_u/bin \ + --backend vulkan \ + --open ASWF/logos/openimageio-stacked-gradient.png \ + --state-json-out build_u/imiv_captures/backend_probe_vulkan/state.json +``` + +```bash +python3 src/imiv/tools/imiv_opengl_smoke_regression.py \ + --bin build_u/bin/imiv \ + --cwd build_u/bin \ + --backend opengl \ + --env-script build_u/imiv_env.sh \ + --out-dir build_u/imiv_captures/backend_probe_opengl +``` + +Shared verifier from one multi-backend build: + +```bash +python3 src/imiv/tools/imiv_backend_verify.py \ + --backend vulkan \ + --build-dir build_u \ + --out-dir build_u/imiv_captures/verify_vulkan \ + --skip-configure \ + --skip-build +``` + +```bash +python3 src/imiv/tools/imiv_backend_verify.py \ + --backend opengl \ + --build-dir build_u \ + --out-dir build_u/imiv_captures/verify_opengl \ + --skip-configure \ + --skip-build +``` + +Optional: turn on the shared backend-wide `ctest` entries and list them: + +```bash +cmake -S . -B build_u \ + -D OIIO_IMIV_ENABLE_VULKAN=AUTO \ + -D OIIO_IMIV_ENABLE_OPENGL=AUTO \ + -D OIIO_IMIV_ENABLE_METAL=OFF \ + -D OIIO_IMIV_DEFAULT_RENDERER=vulkan \ + -D OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST=ON +``` + +Then list them: + +```bash +ctest --test-dir build_u -N | rg imiv_backend_verify +``` + +Proof that one Vulkan-default build can drive OpenGL through `ctest`: + +```bash +ctest --test-dir build_u -V -R '^imiv_backend_verify_opengl$' +``` + +Focused backend selector regression: + +```bash +ctest --test-dir build_u -V -R '^imiv_backend_preferences_regression$' +``` + +## Remaining planned work + +- Validate `OIIO_IMIV_ADD_BACKEND_VERIFY_CTEST=ON`. +- Validate the new Preferences backend selector on macOS and Windows. +- If that is clean, the next backend-switch slice is: + - decide whether `imiv_backend_preferences_regression` should also be folded + into `imiv_backend_verify.py` + - present more explicit availability status in Preferences + - decide whether to duplicate more individual `ctest` cases per enabled + backend, or keep the shared `imiv_backend_verify.py` path as the backend + matrix entrypoint diff --git a/src/imiv/shaders/imiv_preview.frag b/src/imiv/shaders/imiv_preview.frag new file mode 100644 index 0000000000..ea8fd6723d --- /dev/null +++ b/src/imiv/shaders/imiv_preview.frag @@ -0,0 +1,104 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#version 450 + +layout(location = 0) in vec2 uv_in; +layout(location = 0) out vec4 out_color; + +layout(set = 0, binding = 0) uniform sampler2D source_image; + +layout(push_constant) uniform PreviewPushConstants { + float exposure; + float gamma; + float offset; + int color_mode; + int channel; + int use_ocio; + int orientation; +} pc; + +vec2 display_to_source_uv(vec2 uv, int orientation) +{ + if (orientation == 2) + return vec2(1.0 - uv.x, uv.y); + if (orientation == 3) + return vec2(1.0 - uv.x, 1.0 - uv.y); + if (orientation == 4) + return vec2(uv.x, 1.0 - uv.y); + if (orientation == 5) + return vec2(uv.y, uv.x); + if (orientation == 6) + return vec2(uv.y, 1.0 - uv.x); + if (orientation == 7) + return vec2(1.0 - uv.y, 1.0 - uv.x); + if (orientation == 8) + return vec2(1.0 - uv.y, uv.x); + return uv; +} + +float selected_channel(vec4 c, int channel) +{ + if (channel == 1) + return c.r; + if (channel == 2) + return c.g; + if (channel == 3) + return c.b; + if (channel == 4) + return c.a; + return c.r; +} + +vec3 heatmap(float x) +{ + float t = clamp(x, 0.0, 1.0); + vec3 a = vec3(0.0, 0.0, 0.5); + vec3 b = vec3(0.0, 0.9, 1.0); + vec3 c = vec3(1.0, 1.0, 0.0); + vec3 d = vec3(1.0, 0.0, 0.0); + if (t < 0.33) + return mix(a, b, t / 0.33); + if (t < 0.66) + return mix(b, c, (t - 0.33) / 0.33); + return mix(c, d, (t - 0.66) / 0.34); +} + +void main() +{ + vec2 src_uv = display_to_source_uv(uv_in, pc.orientation); + vec4 c = texture(source_image, src_uv); + c.rgb += vec3(pc.offset); + + if (pc.color_mode == 1) { + c.a = 1.0; + } else if (pc.color_mode == 2) { + float v = selected_channel(c, pc.channel); + c = vec4(v, v, v, 1.0); + } else if (pc.color_mode == 3) { + float y = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + c = vec4(y, y, y, 1.0); + } else if (pc.color_mode == 4) { + float v = selected_channel(c, pc.channel); + c = vec4(heatmap(v), 1.0); + } + + if (pc.channel > 0 && pc.color_mode != 2 && pc.color_mode != 4) { + float v = selected_channel(c, pc.channel); + c = vec4(v, v, v, 1.0); + } + + float exposure_scale = exp2(pc.exposure); + c.rgb *= exposure_scale; + float g = max(pc.gamma, 0.01); + c.rgb = pow(max(c.rgb, vec3(0.0)), vec3(1.0 / g)); + + // OCIO pipeline is not connected yet; keep this branch explicit. +if (pc.use_ocio != 0) { + // TODO: apply the selected OCIO display/view transform here. + c.rgb = c.rgb; +} + + out_color = c; +} diff --git a/src/imiv/shaders/imiv_preview.vert b/src/imiv/shaders/imiv_preview.vert new file mode 100644 index 0000000000..a90e25ff5c --- /dev/null +++ b/src/imiv/shaders/imiv_preview.vert @@ -0,0 +1,21 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#version 450 + +layout(location = 0) out vec2 uv_out; + +void main() +{ + vec2 pos; + if (gl_VertexIndex == 0) + pos = vec2(-1.0, -1.0); + else if (gl_VertexIndex == 1) + pos = vec2(3.0, -1.0); + else + pos = vec2(-1.0, 3.0); + + uv_out = pos * 0.5 + 0.5; + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/src/imiv/shaders/imiv_upload_to_rgba.comp b/src/imiv/shaders/imiv_upload_to_rgba.comp new file mode 100644 index 0000000000..725183f691 --- /dev/null +++ b/src/imiv/shaders/imiv_upload_to_rgba.comp @@ -0,0 +1,128 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#version 450 + +layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in; + +layout(set = 0, binding = 0, std430) readonly buffer SourceWords { + uint words[]; +} src_words; + +#if IMIV_OUTPUT_16F +layout(rgba16f, set = 0, binding = 1) writeonly uniform image2D dst_image; +#else +layout(rgba32f, set = 0, binding = 1) writeonly uniform image2D dst_image; +#endif + +layout(push_constant) uniform UploadPushConstants { + uint width; + uint height; + uint row_pitch_bytes; + uint pixel_stride_bytes; + uint channel_count; + uint data_type; + uint dst_x; + uint dst_y; +} pc; + +const uint IMIV_DATA_U8 = 0u; +const uint IMIV_DATA_U16 = 1u; +const uint IMIV_DATA_U32 = 2u; +const uint IMIV_DATA_F16 = 3u; +const uint IMIV_DATA_F32 = 4u; +const uint IMIV_DATA_F64 = 5u; + +uint read_byte(const uint byte_offset) +{ + const uint word = src_words.words[byte_offset >> 2u]; + const uint shift = (byte_offset & 3u) * 8u; + return (word >> shift) & 0xffu; +} + +uint read_u16(const uint byte_offset) +{ + return read_byte(byte_offset) + | (read_byte(byte_offset + 1u) << 8u); +} + +uint read_u32(const uint byte_offset) +{ + return read_byte(byte_offset) + | (read_byte(byte_offset + 1u) << 8u) + | (read_byte(byte_offset + 2u) << 16u) + | (read_byte(byte_offset + 3u) << 24u); +} + +float decode_channel(const uint pixel_offset, const uint channel_index) +{ + uint channel_bytes = 1u; + if (pc.data_type == IMIV_DATA_U16 || pc.data_type == IMIV_DATA_F16) + channel_bytes = 2u; + else if (pc.data_type == IMIV_DATA_U32 || pc.data_type == IMIV_DATA_F32) + channel_bytes = 4u; + else if (pc.data_type == IMIV_DATA_F64) + channel_bytes = 8u; + + const uint byte_offset = pixel_offset + channel_index * channel_bytes; + + if (pc.data_type == IMIV_DATA_U8) + return float(read_byte(byte_offset)) * (1.0 / 255.0); + if (pc.data_type == IMIV_DATA_U16) + return float(read_u16(byte_offset)) * (1.0 / 65535.0); + if (pc.data_type == IMIV_DATA_U32) + return float(read_u32(byte_offset)) * (1.0 / 4294967295.0); + if (pc.data_type == IMIV_DATA_F16) + return unpackHalf2x16(read_u16(byte_offset)).x; + if (pc.data_type == IMIV_DATA_F32) + return uintBitsToFloat(read_u32(byte_offset)); + +#if IMIV_ENABLE_FP64 + if (pc.data_type == IMIV_DATA_F64) { + const uvec2 dwords = uvec2(read_u32(byte_offset), + read_u32(byte_offset + 4u)); + const double dv = packDouble2x32(dwords); + return float(dv); + } +#endif + + return 0.0; +} + +vec4 decode_pixel(const uint pixel_offset) +{ + if (pc.channel_count == 0u) + return vec4(0.0, 0.0, 0.0, 1.0); + + if (pc.channel_count == 1u) { + const float g = decode_channel(pixel_offset, 0u); + return vec4(g, g, g, 1.0); + } + + if (pc.channel_count == 2u) { + const float g = decode_channel(pixel_offset, 0u); + const float a = decode_channel(pixel_offset, 1u); + return vec4(g, g, g, a); + } + + const float r = decode_channel(pixel_offset, 0u); + const float g = decode_channel(pixel_offset, 1u); + const float b = decode_channel(pixel_offset, 2u); + float a = 1.0; + if (pc.channel_count >= 4u) + a = decode_channel(pixel_offset, 3u); + return vec4(r, g, b, a); +} + +void main() +{ + const uvec2 gid = gl_GlobalInvocationID.xy; + if (gid.x >= pc.width || gid.y >= pc.height) + return; + + const uint pixel_offset = gid.y * pc.row_pitch_bytes + + gid.x * pc.pixel_stride_bytes; + const vec4 rgba = decode_pixel(pixel_offset); + imageStore(dst_image, ivec2(pc.dst_x + gid.x, pc.dst_y + gid.y), rgba); +} diff --git a/src/imiv/tools/imiv_area_probe_closeup_regression.py b/src/imiv/tools/imiv_area_probe_closeup_regression.py new file mode 100644 index 0000000000..85b2e79c5f --- /dev/null +++ b/src/imiv/tools/imiv_area_probe_closeup_regression.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Regression check for Pixel Closeup suppression during Area Sample drag.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists(): + return env + proc = subprocess.run( + ["bash", "-lc", f"source {shlex.quote(str(script_path))} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _area_probe_is_placeholder(state: dict) -> bool: + lines = state.get("area_probe_lines", []) + if not lines: + return False + for line in lines: + if line == "Area Probe:": + continue + if "-----" not in line: + return False + return True + + +def _run_runner( + cmd: list[str], repo_root: Path, env: dict[str, str], label: str +) -> int: + proc = subprocess.run( + cmd, cwd=str(repo_root), env=env, check=False, timeout=120 + ) + if proc.returncode != 0: + return _fail(f"{label}: runner exited with code {proc.returncode}") + return 0 + + +def main() -> int: + repo_root = _repo_root() + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = ( + repo_root / "build_u" / "imiv_captures" / "area_probe_closeup_regression" + ) + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image to open") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + image_path = Path(args.open).expanduser().resolve() + if not image_path.exists(): + return _fail(f"image not found: {image_path}") + + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + probe_state_path = out_dir / "pixel_closeup_probe_state.json" + screenshot_path = out_dir / "area_probe_closeup.png" + layout_path = out_dir / "area_probe_closeup.json" + state_path = out_dir / "area_probe_closeup_state.json" + svg_path = out_dir / "area_probe_closeup.svg" + + env = _load_env_from_script(Path(args.env_script).expanduser()) + env["IMIV_IMGUI_TEST_ENGINE_SHOW_PIXEL"] = "1" + + probe_cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + probe_cmd.extend(["--backend", args.backend]) + probe_cmd.extend( + [ + "--open", + str(image_path), + "--mouse-pos-image-rel", + "0.55", + "0.55", + "--state-json-out", + str(probe_state_path), + ] + ) + + status = _run_runner(probe_cmd, repo_root, env, "pixel closeup probe") + if status != 0: + return status + + if not probe_state_path.exists(): + return _fail(f"probe state output not found: {probe_state_path}") + + probe_state = json.loads(probe_state_path.read_text(encoding="utf-8")) + if not probe_state.get("probe_valid", False): + return _fail("pixel closeup probe did not become valid") + probe_pos = probe_state.get("probe_pos", []) + image_size = probe_state.get("image_size", []) + if len(probe_pos) != 2 or len(image_size) != 2: + return _fail("probe state dump missing probe_pos or image_size") + probe_x = int(probe_pos[0]) + probe_y = int(probe_pos[1]) + image_w = int(image_size[0]) + image_h = int(image_size[1]) + if not (0 <= probe_x < image_w and 0 <= probe_y < image_h): + return _fail(f"probe position out of bounds: ({probe_x}, {probe_y})") + if probe_x < max(1, image_w // 10) or probe_y < max(1, image_h // 10): + return _fail( + f"probe position unexpectedly near top-left: ({probe_x}, {probe_y})" + ) + + env["IMIV_IMGUI_TEST_ENGINE_SHOW_AREA"] = "1" + area_cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + area_cmd.extend(["--backend", args.backend]) + area_cmd.extend( + [ + "--open", + str(image_path), + "--mouse-pos-image-rel", + "0.55", + "0.55", + "--mouse-drag-hold", + "120", + "80", + "--mouse-drag-hold-button", + "0", + "--mouse-drag-hold-frames", + "2", + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--svg-out", + str(svg_path), + "--svg-items", + "--svg-labels", + "--state-json-out", + str(state_path), + ] + ) + + status = _run_runner(area_cmd, repo_root, env, "area probe closeup") + if status != 0: + return status + + if not layout_path.exists(): + return _fail(f"layout output not found: {layout_path}") + if not state_path.exists(): + return _fail(f"state output not found: {state_path}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + if not state.get("area_probe_drag_active", False): + return _fail("area probe drag was not active during held-drag capture") + + layout = json.loads(layout_path.read_text(encoding="utf-8")) + item_debug_labels = [] + for window in layout.get("windows", []): + for item in window.get("items", []): + debug = item.get("debug") + if isinstance(debug, str): + item_debug_labels.append(debug) + + if not _area_probe_is_placeholder(state): + return _fail("Area Probe should stay in placeholder mode until drag release") + if "text: Pixel Closeup overlay" in item_debug_labels: + return _fail("Pixel Closeup overlay was visible during held-drag capture") + + print(f"screenshot: {screenshot_path}") + print(f"layout: {layout_path}") + print(f"state: {state_path}") + print(f"svg: {svg_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_auto_subimage_regression.py b/src/imiv/tools/imiv_auto_subimage_regression.py new file mode 100644 index 0000000000..5e33ed06ba --- /dev/null +++ b/src/imiv/tools/imiv_auto_subimage_regression.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Regression check for iv-style hidden auto-subimage-from-zoom behavior.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + which = shutil.which("oiiotool") + return Path(which) if which else candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + loaded: dict[str, str] = {} + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + loaded[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + env.update(loaded) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _run_checked(cmd: list[str], *, cwd: Path) -> None: + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def _generate_subimage_fixture(oiiotool: Path, out_path: Path) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + tmp_specs = [ + ("sub0_4096.tif", "1,0,0", 4096), + ("sub1_2048.tif", "0,1,0", 2048), + ("sub2_1024.tif", "0,0,1", 1024), + ("sub3_0512.tif", "1,1,0", 512), + ] + tmp_paths: list[Path] = [] + for name, color, dim in tmp_specs: + path = out_path.parent / name + tmp_paths.append(path) + _run_checked( + [ + str(oiiotool), + "--pattern", + f"constant:color={color}", + f"{dim}x{dim}", + "3", + "-d", + "uint8", + "-o", + str(path), + ], + cwd=out_path.parent, + ) + + cmd = [str(oiiotool)] + cmd.extend(str(path) for path in tmp_paths) + cmd.extend(["--siappendall", "-o", str(out_path)]) + _run_checked(cmd, cwd=out_path.parent) + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + step = ET.SubElement(root, "step") + step.set("name", "baseline") + step.set("state", "true") + step.set("delay_frames", "3") + + step = ET.SubElement(root, "step") + step.set("name", "enable_auto") + step.set("key_chord", "shift+comma") + step.set("state", "true") + step.set("post_action_delay_frames", "2") + + step = ET.SubElement(root, "step") + step.set("name", "zoom_out") + step.set("key_chord", "ctrl+minus") + step.set("state", "true") + step.set("post_action_delay_frames", "3") + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _run_scenario( + repo_root: Path, + runner: Path, + exe: Path, + cwd: Path, + backend: str, + image_path: Path, + out_dir: Path, + scenario_path: Path, + env: dict[str, str], + trace: bool, +) -> dict[str, dict]: + runtime_dir = out_dir / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + config_home = out_dir / "cfg_scenario" + shutil.rmtree(config_home, ignore_errors=True) + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if backend: + cmd.extend(["--backend", backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--scenario", + str(scenario_path), + ] + ) + if trace: + cmd.append("--trace") + + case_env = dict(env) + case_env["IMIV_CONFIG_HOME"] = str(config_home) + + with (out_dir / "scenario.log").open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=case_env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + raise RuntimeError(f"scenario: runner exited with code {proc.returncode}") + + result: dict[str, dict] = {} + for step_name in ("baseline", "enable_auto", "zoom_out"): + state_path = runtime_dir / f"{step_name}.state.json" + if not state_path.exists(): + raise RuntimeError(f"scenario: missing state output for {step_name}") + with state_path.open("r", encoding="utf-8") as handle: + state = json.load(handle) + state["_state_path"] = str(state_path) + state["_log_path"] = str(out_dir / "scenario.log") + result[step_name] = state + return result + + +def _calc_expected_subimage_from_zoom( + current_subimage: int, nsubimages: int, zoom: float +) -> tuple[int, float]: + rel_subimage = math.trunc(math.log2(1.0 / max(1.0e-6, zoom))) + target = max(0, min(current_subimage + rel_subimage, nsubimages - 1)) + adjusted_zoom = zoom + if not (current_subimage == 0 and zoom > 1.0) and not ( + current_subimage == nsubimages - 1 and zoom < 1.0 + ): + adjusted_zoom *= math.pow(2.0, float(rel_subimage)) + return target, adjusted_zoom + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + default_runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out = repo_root / "build_u" / "imiv_captures" / "auto_subimage_regression" + default_image = default_out / "auto_subimages.tif" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable" + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out), help="Output directory") + ap.add_argument( + "--image", + default=str(default_image), + help="Generated multi-subimage TIFF output path", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + oiiotool = Path(args.oiiotool).expanduser().resolve() + if not oiiotool.exists(): + return _fail(f"oiiotool not found: {oiiotool}") + runner = default_runner.resolve() + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + image_path = Path(args.image).expanduser().resolve() + + try: + _generate_subimage_fixture(oiiotool, image_path) + except subprocess.SubprocessError as exc: + return _fail(f"failed to generate subimage fixture: {exc}") + + env = _load_env_from_script(Path(args.env_script).expanduser()) + + try: + scenario_path = out_dir / "auto_subimage.scenario.xml" + runtime_dir = out_dir / "runtime" + _write_scenario(scenario_path, _path_for_imiv_output(runtime_dir, cwd)) + scenario_states = _run_scenario( + repo_root, + runner, + exe, + cwd, + args.backend, + image_path, + out_dir, + scenario_path, + env, + args.trace, + ) + baseline = scenario_states["baseline"] + enabled = scenario_states["enable_auto"] + auto_zoom = scenario_states["zoom_out"] + except (RuntimeError, subprocess.SubprocessError) as exc: + return _fail(str(exc)) + + for name, state in ( + ("baseline", baseline), + ("enable_auto", enabled), + ("auto_zoom_out", auto_zoom), + ): + if not state.get("image_loaded", False): + return _fail(f"{name}: image not loaded") + + baseline_zoom = float(baseline["zoom"]) + if not (baseline_zoom > 0.0 and baseline_zoom < 1.0): + return _fail(f"baseline zoom expected fit-in-window range, got {baseline_zoom:.6f}") + if bool(baseline["auto_subimage"]): + return _fail("baseline unexpectedly started with auto_subimage enabled") + if int(baseline["subimage"]) != 0: + return _fail(f"baseline subimage expected 0, got {baseline['subimage']}") + + if not bool(enabled["auto_subimage"]): + return _fail("Shift+, did not enable auto_subimage") + if int(enabled["subimage"]) != 0: + return _fail(f"enable_auto changed subimage unexpectedly: {enabled['subimage']}") + + expected_subimage, expected_zoom = _calc_expected_subimage_from_zoom( + current_subimage=0, nsubimages=4, zoom=float(enabled["zoom"]) * 0.5 + ) + actual_subimage = int(auto_zoom["subimage"]) + actual_zoom = float(auto_zoom["zoom"]) + if not bool(auto_zoom["auto_subimage"]): + return _fail("auto_zoom_out did not keep auto_subimage enabled") + if actual_subimage != expected_subimage: + return _fail( + f"auto_zoom_out landed on wrong subimage: expected {expected_subimage}, got {actual_subimage}" + ) + if expected_subimage <= 0: + return _fail( + f"test fixture did not force a real auto-subimage switch: expected_subimage={expected_subimage}" + ) + if abs(actual_zoom - expected_zoom) > 0.05: + return _fail( + f"auto_zoom_out restored wrong zoom: expected {expected_zoom:.6f}, got {actual_zoom:.6f}" + ) + + print("baseline:", baseline["_state_path"]) + print("enable_auto:", enabled["_state_path"]) + print("auto_zoom_out:", auto_zoom["_state_path"]) + print("artifacts:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_backend_preferences_regression.py b/src/imiv/tools/imiv_backend_preferences_regression.py new file mode 100644 index 0000000000..3cb5faef95 --- /dev/null +++ b/src/imiv/tools/imiv_backend_preferences_regression.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Regression check for Preferences backend selection and restart semantics.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _write_prefs(config_home: Path) -> Path: + prefs_dir = config_home / "OpenImageIO" / "imiv" + prefs_dir.mkdir(parents=True, exist_ok=True) + prefs_path = prefs_dir / "imiv.inf" + prefs_path.write_text("[ImivApp][State]\nrenderer_backend=auto\n", encoding="utf-8") + return prefs_path + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario( + path: Path, + runtime_dir_rel: str, + *, + alternate_backend: str, + active_backend: str, +) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + _scenario_step( + root, + "open_preferences", + key_chord="ctrl+comma", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_alternate_backend", + renderer_backend=alternate_backend, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_active_backend", + renderer_backend=active_backend, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_auto_backend", + renderer_backend="auto", + state=True, + post_action_delay_frames=2, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _parse_list_backends(output: str) -> tuple[list[str], list[str], str, str, list[str]]: + built: list[str] = [] + available: list[str] = [] + build_default = "" + platform_default = "" + listed_order: list[str] = [] + line_re = re.compile(r"^\s+([A-Za-z0-9]+)\s+\(([^)]+)\)\s+:\s+(.*)$") + for raw_line in output.splitlines(): + match = line_re.match(raw_line) + if not match: + continue + _display_name, cli_name, description = match.groups() + cli_name = cli_name.strip().lower() + description = description.strip().lower() + listed_order.append(cli_name) + if "built" in description and "not built" not in description: + built.append(cli_name) + if "available" in description and "unavailable" not in description: + available.append(cli_name) + if "build default backend" in description: + build_default = cli_name + if "platform default" in description: + platform_default = cli_name + return built, available, build_default, platform_default, listed_order + + +def _resolve_auto_backend( + *, + available_backends: list[str], + build_default_backend: str, + platform_default_backend: str, + listed_order: list[str], +) -> str: + if build_default_backend and build_default_backend in available_backends: + return build_default_backend + if platform_default_backend and platform_default_backend in available_backends: + return platform_default_backend + for name in listed_order: + if name in available_backends: + return name + raise RuntimeError("no runtime-available backend found") + + +def _load_state(path: Path) -> dict: + if not path.exists(): + raise RuntimeError(f"state file not written: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def _assert_backend_state( + state: dict, + *, + label: str, + active_backend: str, + requested_backend: str, + next_launch_backend: str, + restart_required: bool, + compiled_backends: list[str], +) -> None: + backend = state.get("backend") + if not isinstance(backend, dict): + raise RuntimeError(f"{label}: backend state block missing") + + if backend.get("active") != active_backend: + raise RuntimeError( + f"{label}: active backend {backend.get('active')!r} != {active_backend!r}" + ) + if backend.get("requested") != requested_backend: + raise RuntimeError( + f"{label}: requested backend {backend.get('requested')!r} != {requested_backend!r}" + ) + if backend.get("next_launch") != next_launch_backend: + raise RuntimeError( + f"{label}: next_launch backend {backend.get('next_launch')!r} != {next_launch_backend!r}" + ) + if bool(backend.get("restart_required")) != restart_required: + raise RuntimeError( + f"{label}: restart_required {backend.get('restart_required')!r} != {restart_required!r}" + ) + + actual_compiled = backend.get("compiled") + if actual_compiled != compiled_backends: + raise RuntimeError( + f"{label}: compiled backends {actual_compiled!r} != {compiled_backends!r}" + ) + + +def main() -> int: + repo_root = _repo_root() + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = repo_root / "build_u" / "imiv_captures" / "backend_preferences_regression" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed to imiv", + ) + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + + run_cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + image_path = Path(args.open).expanduser().resolve() + if not image_path.exists(): + return _fail(f"image not found: {image_path}") + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + runtime_dir = out_dir / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + log_path = out_dir / "backend_preferences.log" + scenario_path = out_dir / "backend_preferences.scenario.xml" + + env = _load_env_from_script(Path(args.env_script).expanduser()) + + list_proc = subprocess.run( + [str(exe), "--list-backends"], + cwd=str(run_cwd), + env=env, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + if list_proc.returncode != 0: + return _fail(f"--list-backends exited with code {list_proc.returncode}") + ( + compiled_backends, + available_backends, + build_default_backend, + platform_default_backend, + listed_order, + ) = _parse_list_backends(list_proc.stdout) + if len(compiled_backends) < 2: + print("skip: backend preferences regression requires at least two compiled backends") + return 77 + if not build_default_backend: + return _fail("could not determine build default backend from --list-backends") + if len(available_backends) < 2: + print( + "skip: backend preferences regression requires at least two runtime-available backends" + ) + return 77 + auto_backend = _resolve_auto_backend( + available_backends=available_backends, + build_default_backend=build_default_backend, + platform_default_backend=platform_default_backend, + listed_order=listed_order, + ) + + active_backend = args.backend.strip().lower() if args.backend else auto_backend + if active_backend not in compiled_backends: + return _fail(f"requested active backend is not compiled: {active_backend}") + if active_backend not in available_backends: + return _fail(f"requested active backend is not runtime-available: {active_backend}") + + alternate_backend = next( + (name for name in available_backends if name != active_backend), + "", + ) + if not alternate_backend: + print("skip: backend preferences regression requires an alternate runtime-available backend") + return 77 + + config_home = out_dir / "config_home" + _write_prefs(config_home) + env["IMIV_CONFIG_HOME"] = str(config_home) + + runtime_dir_rel = os.path.relpath(runtime_dir, run_cwd) + _write_scenario( + scenario_path, + runtime_dir_rel, + alternate_backend=alternate_backend, + active_backend=active_backend, + ) + + cmd = [ + sys.executable, + str(repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py"), + "--bin", + str(exe), + "--cwd", + str(run_cwd), + "--scenario", + str(scenario_path), + "--open", + str(image_path), + ] + if args.backend: + cmd.extend(["--backend", active_backend]) + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=120, + ) + if proc.returncode != 0: + return _fail(f"scenario runner exited with code {proc.returncode}") + + open_state = _load_state(runtime_dir / "open_preferences.state.json") + alt_state = _load_state(runtime_dir / "select_alternate_backend.state.json") + active_state = _load_state(runtime_dir / "select_active_backend.state.json") + auto_state = _load_state(runtime_dir / "select_auto_backend.state.json") + + _assert_backend_state( + open_state, + label="open_preferences", + active_backend=active_backend, + requested_backend="auto", + next_launch_backend=auto_backend, + restart_required=(auto_backend != active_backend), + compiled_backends=compiled_backends, + ) + _assert_backend_state( + alt_state, + label="select_alternate_backend", + active_backend=active_backend, + requested_backend=alternate_backend, + next_launch_backend=alternate_backend, + restart_required=(alternate_backend != active_backend), + compiled_backends=compiled_backends, + ) + _assert_backend_state( + active_state, + label="select_active_backend", + active_backend=active_backend, + requested_backend=active_backend, + next_launch_backend=active_backend, + restart_required=False, + compiled_backends=compiled_backends, + ) + _assert_backend_state( + auto_state, + label="select_auto_backend", + active_backend=active_backend, + requested_backend="auto", + next_launch_backend=auto_backend, + restart_required=(auto_backend != active_backend), + compiled_backends=compiled_backends, + ) + + print("runtime:", runtime_dir) + print("log:", log_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_backend_verify.py b/src/imiv/tools/imiv_backend_verify.py new file mode 100644 index 0000000000..d2563f6ea2 --- /dev/null +++ b/src/imiv/tools/imiv_backend_verify.py @@ -0,0 +1,892 @@ +#!/usr/bin/env python3 +"""Configure, build, and run imiv backend regressions across platforms. + +Canonical usage from the repo root: + + python src/imiv/tools/imiv_backend_verify.py --backend vulkan --build-dir build_u + +If you invoke this from the repo root with `uv run`, use `--no-project`: + + uv run --no-project python src/imiv/tools/imiv_backend_verify.py --backend vulkan + +Without `--no-project`, uv may try to build/install the repository's Python +package first because this checkout has a `pyproject.toml`. +""" + +from __future__ import annotations + +import argparse +import os +import platform +import shlex +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Iterable + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_image(repo_root: Path) -> Path: + return repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + +def _is_windows() -> bool: + return os.name == "nt" + + +def _is_macos() -> bool: + return sys.platform == "darwin" + + +def _is_linux() -> bool: + return sys.platform.startswith("linux") + + +def _default_build_dir(repo_root: Path) -> Path: + if _is_linux() and (repo_root / "build_u").exists(): + return repo_root / "build_u" + return repo_root / "build" + + +def _default_backend() -> str: + return "metal" if _is_macos() else "vulkan" + + +def _supported_backends() -> tuple[str, ...]: + if _is_macos(): + return ("metal", "opengl", "vulkan") + return ("vulkan", "opengl") + + +def _default_config() -> str: + return "Debug" if _is_windows() else "" + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _format_elapsed(seconds: float) -> str: + return f"{seconds:.2f}s" + + +def _run_capture(cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> str: + proc = subprocess.run( + cmd, + cwd=str(cwd), + env=env, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + output = proc.stdout or "" + if proc.returncode != 0: + raise RuntimeError( + f"command failed ({proc.returncode}): {' '.join(cmd)}\n{output}" + ) + return output + + +def _run_logged( + cmd: list[str], + *, + cwd: Path, + log_path: Path, + env: dict[str, str] | None = None, + label: str | None = None, +) -> tuple[int, float]: + log_path.parent.mkdir(parents=True, exist_ok=True) + command_text = " ".join(shlex.quote(part) for part in cmd) + step_label = label or log_path.stem + start = time.monotonic() + with log_path.open("w", encoding="utf-8") as log_handle: + header = f"==> {step_label}: {command_text}\n" + sys.stdout.write(header) + log_handle.write(header) + proc = subprocess.Popen( + cmd, + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + assert proc.stdout is not None + for line in proc.stdout: + sys.stdout.write(line) + log_handle.write(line) + rc = proc.wait() + elapsed = time.monotonic() - start + footer = f"<== {step_label}: rc={rc} elapsed={_format_elapsed(elapsed)}\n" + sys.stdout.write(footer) + log_handle.write(footer) + return rc, elapsed + + +def _candidate_paths(build_dir: Path, config: str, stem: str) -> Iterable[Path]: + suffixes = [stem] + if _is_windows(): + suffixes = [f"{stem}.exe", stem] + for suffix in suffixes: + yield build_dir / "bin" / config / suffix if config else build_dir / "bin" / suffix + if config: + yield build_dir / config / suffix + yield build_dir / "bin" / suffix + yield build_dir / "src" / stem / config / suffix if config else build_dir / "src" / stem / suffix + yield build_dir / "src" / stem / suffix + if config: + yield build_dir / "Release" / suffix + yield build_dir / "Debug" / suffix + yield build_dir / "bin" / "Release" / suffix + yield build_dir / "bin" / "Debug" / suffix + + +def _find_program(build_dir: Path, config: str, stem: str) -> Path | None: + seen: set[Path] = set() + for candidate in _candidate_paths(build_dir, config, stem): + if candidate in seen: + continue + seen.add(candidate) + if candidate.exists(): + return candidate.resolve() + return None + + +def _discover_env_script(build_dir: Path, config: str) -> Path | None: + candidates = [build_dir / "imiv_env.sh"] + if config: + candidates.append(build_dir / config / "imiv_env.sh") + for candidate in candidates: + if candidate.exists(): + return candidate.resolve() + return None + + +def _load_env_from_script(script_path: Path | None) -> dict[str, str]: + env = dict(os.environ) + if script_path is None or not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _base_py_cmd() -> list[str]: + return [sys.executable] + + +def _generic_smoke_runner_cmd( + repo_root: Path, + backend: str, + exe: Path, + run_cwd: Path, + out_dir: Path, + image: Path, + *, + trace: bool, +) -> list[str]: + cmd = _base_py_cmd() + [ + str(repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py"), + "--bin", + str(exe), + "--cwd", + str(run_cwd), + "--backend", + backend, + "--open", + str(image), + "--screenshot-out", + str(out_dir / "smoke.png"), + "--layout-json-out", + str(out_dir / "smoke.layout.json"), + "--layout-items", + "--state-json-out", + str(out_dir / "smoke.state.json"), + ] + if trace: + cmd.append("--trace") + return cmd + + +def _script_cmd( + script: Path, + *, + backend: str, + exe: Path, + run_cwd: Path, + out_dir: Path, + trace: bool, + extra: list[str] | None = None, + env_script: Path | None = None, +) -> list[str]: + cmd = _base_py_cmd() + [ + str(script), + "--bin", + str(exe), + "--cwd", + str(run_cwd), + "--backend", + backend, + "--out-dir", + str(out_dir), + ] + if extra: + cmd.extend(extra) + if env_script is not None: + cmd.extend(["--env-script", str(env_script)]) + if trace: + cmd.append("--trace") + return cmd + + +def _smoke_checks( + repo_root: Path, + backend: str, + exe: Path, + run_cwd: Path, + image: Path, + out_dir: Path, + env_script: Path | None, + trace: bool, +) -> list[tuple[str, list[str], Path, dict[str, str] | None]]: + checks: list[tuple[str, list[str], Path, dict[str, str] | None]] = [] + if backend == "metal": + checks.append( + ( + "smoke", + _script_cmd( + repo_root + / "src" + / "imiv" + / "tools" + / "imiv_metal_screenshot_regression.py", + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime", + trace=trace, + extra=["--open", str(image)], + env_script=env_script, + ), + out_dir / "verify_smoke.log", + None, + ) + ) + return checks + + if backend == "opengl": + checks.append( + ( + "smoke", + _script_cmd( + repo_root / "src" / "imiv" / "tools" / "imiv_opengl_smoke_regression.py", + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime", + trace=trace, + extra=["--open", str(image)], + env_script=env_script, + ), + out_dir / "verify_smoke.log", + None, + ) + ) + return checks + + smoke_out = out_dir / "runtime" + smoke_env = {"IMIV_CONFIG_HOME": str(smoke_out / "cfg")} + checks.append( + ( + "smoke", + _generic_smoke_runner_cmd( + repo_root, + backend, + exe, + run_cwd, + smoke_out, + image, + trace=trace, + ), + out_dir / "verify_smoke.log", + smoke_env, + ) + ) + return checks + + +def _ux_checks( + repo_root: Path, + backend: str, + exe: Path, + run_cwd: Path, + oiiotool: Path, + out_dir: Path, + env_script: Path | None, + trace: bool, +) -> list[tuple[str, list[str], Path, dict[str, str] | None]]: + script = repo_root / "src" / "imiv" / "tools" / "imiv_ux_actions_regression.py" + cmd = _script_cmd( + script, + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime_ux", + trace=trace, + extra=["--oiiotool", str(oiiotool)], + env_script=env_script, + ) + return [("ux", cmd, out_dir / "verify_ux.log", None)] + + +def _sampling_checks( + repo_root: Path, + backend: str, + exe: Path, + run_cwd: Path, + oiiotool: Path, + out_dir: Path, + env_script: Path | None, + trace: bool, +) -> list[tuple[str, list[str], Path, dict[str, str] | None]]: + script = repo_root / "src" / "imiv" / "tools" / "imiv_sampling_regression.py" + cmd = _script_cmd( + script, + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime_sampling", + trace=trace, + extra=["--oiiotool", str(oiiotool)], + env_script=env_script, + ) + return [("sampling", cmd, out_dir / "verify_sampling.log", None)] + + +def _rgb_checks( + repo_root: Path, + backend: str, + exe: Path, + run_cwd: Path, + oiiotool: Path, + out_dir: Path, + source_image: Path, + env_script: Path | None, + trace: bool, +) -> list[tuple[str, list[str], Path, dict[str, str] | None]]: + script = repo_root / "src" / "imiv" / "tools" / "imiv_rgb_input_regression.py" + cmd = _script_cmd( + script, + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime_rgb", + trace=trace, + extra=[ + "--oiiotool", + str(oiiotool), + "--source-image", + str(source_image), + ], + env_script=env_script, + ) + return [("rgb", cmd, out_dir / "verify_rgb.log", None)] + + +def _ocio_checks( + repo_root: Path, + backend: str, + exe: Path, + run_cwd: Path, + oiiotool: Path, + idiff: Path, + out_dir: Path, + image: Path, + ocio_config: str, + env_script: Path | None, + trace: bool, +) -> list[tuple[str, list[str], Path, dict[str, str] | None]]: + checks: list[tuple[str, list[str], Path, dict[str, str] | None]] = [] + checks.append( + ( + "ocio_missing", + _script_cmd( + repo_root + / "src" + / "imiv" + / "tools" + / "imiv_ocio_missing_fallback_regression.py", + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime_ocio_missing", + trace=trace, + extra=[ + "--oiiotool", + str(oiiotool), + "--idiff", + str(idiff), + "--open", + str(image), + ], + env_script=env_script, + ), + out_dir / "verify_ocio_missing.log", + None, + ) + ) + checks.append( + ( + "ocio_config_source", + _script_cmd( + repo_root + / "src" + / "imiv" + / "tools" + / "imiv_ocio_config_source_regression.py", + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / "runtime_ocio_config_source", + trace=trace, + extra=[ + "--oiiotool", + str(oiiotool), + "--idiff", + str(idiff), + "--ocio-config", + ocio_config, + ], + env_script=env_script, + ), + out_dir / "verify_ocio_config_source.log", + None, + ) + ) + for mode, name, log_name, runtime_dir in ( + ("view", "ocio_live", "verify_ocio_live.log", "runtime_ocio_live"), + ( + "display", + "ocio_live_display", + "verify_ocio_live_display.log", + "runtime_ocio_live_display", + ), + ): + checks.append( + ( + name, + _script_cmd( + repo_root + / "src" + / "imiv" + / "tools" + / "imiv_ocio_live_update_regression.py", + backend=backend, + exe=exe, + run_cwd=run_cwd, + out_dir=out_dir / runtime_dir, + trace=trace, + extra=[ + "--oiiotool", + str(oiiotool), + "--idiff", + str(idiff), + "--ocio-config", + ocio_config, + "--switch-mode", + mode, + ], + env_script=env_script, + ), + out_dir / log_name, + None, + ) + ) + return checks + + +def _system_info_text(args: argparse.Namespace, repo_root: Path) -> str: + lines = [ + f"repo_root={repo_root}", + f"backend={args.backend}", + f"build_dir={args.build_dir}", + f"out_dir={args.out_dir}", + f"config={args.config or ''}", + f"image={args.image}", + f"ocio_config={args.ocio_config or 'ocio://default'}", + "", + f"platform={platform.platform()}", + f"python={sys.version}", + f"executable={sys.executable}", + "", + ] + + env_names = [ + "VULKAN_SDK", + "OCIO", + "PATH", + "DISPLAY", + "WAYLAND_DISPLAY", + "XDG_SESSION_TYPE", + "WSL_DISTRO_NAME", + "VisualStudioVersion", + "VSCMD_VER", + ] + for name in env_names: + lines.append(f"{name}={os.environ.get(name, '')}") + lines.append("") + + commands: list[list[str]] = [["cmake", "--version"], ["ninja", "--version"]] + if _is_windows(): + commands.extend([ + ["cmd", "/c", "ver"], + ["where", "python"], + ["where", "cmake"], + ]) + else: + commands.extend([ + ["uname", "-a"], + ["python3", "--version"], + ["clang++", "--version"], + ]) + if shutil.which("g++"): + commands.append(["g++", "--version"]) + if _is_macos(): + commands.extend([["sw_vers"], ["xcode-select", "-p"]]) + elif _is_linux() and shutil.which("lsb_release"): + commands.append(["lsb_release", "-a"]) + + for cmd in commands: + lines.append(f"$ {' '.join(cmd)}") + try: + lines.append(_run_capture(cmd, cwd=repo_root).strip()) + except Exception as exc: # pragma: no cover - best effort + lines.append(f"") + lines.append("") + + if _is_linux(): + try: + osrelease = Path("/proc/sys/kernel/osrelease").read_text(encoding="utf-8") + lines.append(f"wsl={'1' if 'microsoft' in osrelease.lower() else '0'}") + except Exception: + pass + try: + lines.append("") + lines.append(Path("/etc/os-release").read_text(encoding="utf-8").strip()) + except Exception: + pass + + return "\n".join(lines) + + +def main() -> int: + repo_root = _repo_root() + supported_backends = _supported_backends() + default_build_dir = _default_build_dir(repo_root) + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--backend", + default=_default_backend(), + choices=supported_backends, + help="Renderer backend to configure and verify", + ) + ap.add_argument( + "--build-dir", + default=str(default_build_dir), + help="CMake build directory", + ) + ap.add_argument( + "--out-dir", + default="", + help="Output/log directory (default: /imiv_captures/_verify)", + ) + ap.add_argument( + "--config", + default=_default_config(), + help="Build configuration for multi-config generators", + ) + ap.add_argument( + "--jobs", + type=int, + default=int(os.environ.get("IMIV_JOBS", "8")), + help="Parallel build jobs", + ) + ap.add_argument( + "--image", + default=str(_default_image(repo_root)), + help="Image to open for smoke/fallback checks", + ) + ap.add_argument( + "--ocio-config", + default="", + help="Optional external OCIO config path or URI (default: ocio://default)", + ) + ap.add_argument( + "--trace", + action="store_true", + help="Enable verbose Python regression tracing", + ) + ap.add_argument( + "--skip-configure", + action="store_true", + help="Skip the cmake configure step", + ) + ap.add_argument( + "--skip-build", + action="store_true", + help="Skip the cmake build step", + ) + args = ap.parse_args() + + build_dir = Path(args.build_dir).expanduser().resolve() + out_dir = ( + Path(args.out_dir).expanduser().resolve() + if args.out_dir + else (build_dir / "imiv_captures" / f"{args.backend}_verify").resolve() + ) + out_dir.mkdir(parents=True, exist_ok=True) + args.build_dir = str(build_dir) + args.out_dir = str(out_dir) + + image_path = Path(args.image).expanduser().resolve() + args.image = str(image_path) + ocio_config = args.ocio_config.strip() or "ocio://default" + + system_info_log = out_dir / "system_info.txt" + configure_log = out_dir / "cmake_configure.log" + build_log = out_dir / "cmake_build.log" + timings: list[tuple[str, float]] = [] + + _write_text(system_info_log, _system_info_text(args, repo_root)) + + if not image_path.exists(): + print(f"error: image not found: {image_path}", file=sys.stderr) + return 2 + + if not args.skip_configure: + configure_cmd = [ + "cmake", + "-S", + str(repo_root), + "-B", + str(build_dir), + f"-DOIIO_IMIV_RENDERER={args.backend}", + ] + configure_rc, configure_elapsed = _run_logged( + configure_cmd, + cwd=repo_root, + log_path=configure_log, + label="configure", + ) + timings.append(("configure", configure_elapsed)) + if configure_rc != 0: + print(f"error: configure failed, see {configure_log}", file=sys.stderr) + return 1 + else: + _write_text(configure_log, "skip: configure step skipped\n") + timings.append(("configure(skipped)", 0.0)) + + if not args.skip_build: + build_cmd = [ + "cmake", + "--build", + str(build_dir), + ] + if args.config: + build_cmd.extend(["--config", args.config]) + build_cmd.extend([ + "--target", + "imiv", + "oiiotool", + "idiff", + "--parallel", + str(max(1, args.jobs)), + ]) + build_rc, build_elapsed = _run_logged( + build_cmd, + cwd=repo_root, + log_path=build_log, + label="build", + ) + timings.append(("build", build_elapsed)) + if build_rc != 0: + print(f"error: build failed, see {build_log}", file=sys.stderr) + return 1 + else: + _write_text(build_log, "skip: build step skipped\n") + timings.append(("build(skipped)", 0.0)) + + imiv = _find_program(build_dir, args.config, "imiv") + oiiotool = _find_program(build_dir, args.config, "oiiotool") + idiff = _find_program(build_dir, args.config, "idiff") + if imiv is None: + print(f"error: could not locate imiv under {build_dir}", file=sys.stderr) + return 1 + if oiiotool is None: + print(f"error: could not locate oiiotool under {build_dir}", file=sys.stderr) + return 1 + if idiff is None: + print(f"error: could not locate idiff under {build_dir}", file=sys.stderr) + return 1 + + env_script = _discover_env_script(build_dir, args.config) + base_env = _load_env_from_script(env_script) + run_cwd = imiv.parent + + checks: list[tuple[str, list[str], Path, dict[str, str] | None]] = [] + checks.extend( + _smoke_checks( + repo_root, + args.backend, + imiv, + run_cwd, + image_path, + out_dir, + env_script, + args.trace, + ) + ) + checks.extend( + _rgb_checks( + repo_root, + args.backend, + imiv, + run_cwd, + oiiotool, + out_dir, + image_path, + env_script, + args.trace, + ) + ) + checks.extend( + _ux_checks( + repo_root, + args.backend, + imiv, + run_cwd, + oiiotool, + out_dir, + env_script, + args.trace, + ) + ) + checks.extend( + _sampling_checks( + repo_root, + args.backend, + imiv, + run_cwd, + oiiotool, + out_dir, + env_script, + args.trace, + ) + ) + checks.extend( + _ocio_checks( + repo_root, + args.backend, + imiv, + run_cwd, + oiiotool, + idiff, + out_dir, + image_path, + ocio_config, + env_script, + args.trace, + ) + ) + + failures: list[str] = [] + smoke_failed = False + skip_after_smoke = { + "ocio_missing", + "ocio_config_source", + "ocio_live", + "ocio_live_display", + } + for name, cmd, log_path, env_override in checks: + if smoke_failed and name in skip_after_smoke: + message = "skip: skipped because smoke failed\n" + _write_text(log_path, message) + sys.stdout.write(f"==> {name}: skipped because smoke failed\n") + timings.append((f"{name}(skipped)", 0.0)) + continue + env = dict(base_env) + if env_override: + env.update(env_override) + rc, elapsed = _run_logged( + cmd, + cwd=repo_root, + log_path=log_path, + env=env, + label=name, + ) + timings.append((name, elapsed)) + if rc != 0: + failures.append(name) + if name == "smoke": + smoke_failed = True + + print("") + print(f"Verification logs written to: {out_dir}") + print(f" system: {system_info_log}") + print(f" configure: {configure_log}") + print(f" build: {build_log}") + print(f" smoke: {out_dir / 'verify_smoke.log'}") + print(f" runtime+s: {out_dir / 'runtime'}") + print(f" rgb: {out_dir / 'verify_rgb.log'}") + print(f" runtime+rgb: {out_dir / 'runtime_rgb'}") + print(f" ux: {out_dir / 'verify_ux.log'}") + print(f" runtime+ux: {out_dir / 'runtime_ux'}") + print(f" sampling: {out_dir / 'verify_sampling.log'}") + print(f" runtime+sa: {out_dir / 'runtime_sampling'}") + print(f" ocio-miss: {out_dir / 'verify_ocio_missing.log'}") + print(f" runtime+om: {out_dir / 'runtime_ocio_missing'}") + print(f" ocio-src: {out_dir / 'verify_ocio_config_source.log'}") + print(f" runtime+os: {out_dir / 'runtime_ocio_config_source'}") + print(f" ocio-live: {out_dir / 'verify_ocio_live.log'}") + print(f" runtime+ol: {out_dir / 'runtime_ocio_live'}") + print(f" ocio-disp: {out_dir / 'verify_ocio_live_display.log'}") + print(f" runtime+od: {out_dir / 'runtime_ocio_live_display'}") + print(" timings:") + for name, elapsed in timings: + print(f" {name:<18} {_format_elapsed(elapsed)}") + + if failures: + print("") + print("Failed checks:", ", ".join(failures), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_developer_menu_regression.py b/src/imiv/tools/imiv_developer_menu_regression.py new file mode 100644 index 0000000000..a4638c02af --- /dev/null +++ b/src/imiv/tools/imiv_developer_menu_regression.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Regression check for the runtime-enabled Developer menu in imiv.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +ERROR_PATTERNS = ( + "VUID-", + "fatal Vulkan error", + "developer menu regression: demo window did not open", +) + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + loaded: dict[str, str] = {} + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + loaded[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + env.update(loaded) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + default_out = repo_root / "build_u" / "imiv_captures" / "developer_menu_regression" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed directly to imiv", + ) + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--out-dir", default=str(default_out), help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + image_path = Path(args.open).expanduser().resolve() + if not image_path.exists(): + return _fail(f"image not found: {image_path}") + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + layout_path = out_dir / "developer_menu_layout.json" + log_path = out_dir / "developer_menu.log" + + env = _load_env_from_script(Path(args.env_script).expanduser()) + env.update( + { + "OIIO_DEVMODE": "1", + "IMIV_IMGUI_TEST_ENGINE": "1", + "IMIV_IMGUI_TEST_ENGINE_EXIT_ON_FINISH": "1", + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_METRICS": "1", + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_LAYOUT_OUT": str(layout_path), + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_LAYOUT_ITEMS": "1", + "IMIV_IMGUI_TEST_ENGINE_DEVELOPER_MENU_LAYOUT_DEPTH": "8", + } + ) + if args.trace: + env["IMIV_IMGUI_TEST_ENGINE_TRACE"] = "1" + + cmd = [str(exe)] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.append(str(image_path)) + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(cwd), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=60, + ) + + if proc.returncode != 0: + return _fail(f"imiv exited with code {proc.returncode}") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + return _fail(f"found runtime error pattern: {pattern}") + + deadline = time.monotonic() + 2.0 + while not layout_path.exists() and time.monotonic() < deadline: + time.sleep(0.05) + + if not layout_path.exists(): + return _fail(f"layout json not written: {layout_path}") + + window_names: list[str] = [] + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + try: + data = json.loads(layout_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + time.sleep(0.05) + continue + window_names = [window.get("name", "") for window in data.get("windows", [])] + if "Dear ImGui Demo" in window_names: + break + time.sleep(0.05) + + if "Dear ImGui Demo" not in window_names: + return _fail("layout json does not contain Dear ImGui Demo window") + + print("layout:", layout_path) + print("log:", log_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_drag_drop_regression.py b/src/imiv/tools/imiv_drag_drop_regression.py new file mode 100644 index 0000000000..5252b216d3 --- /dev/null +++ b/src/imiv/tools/imiv_drag_drop_regression.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Regression check for multi-file drag/drop into the shared image library.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "drag_drop_regression" + default_images = [ + repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png", + repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr", + ] + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--image", + dest="images", + action="append", + default=[], + help="Dropped image path; may be repeated", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + images = [Path(p).expanduser().resolve() for p in args.images] if args.images else default_images + if len(images) < 2: + return _fail("regression requires at least two dropped images") + for image in images: + if not image.exists(): + return _fail(f"image not found: {image}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + state_path = out_dir / "drag_drop.state.json" + layout_path = out_dir / "drag_drop.layout.json" + log_path = out_dir / "drag_drop.log" + drop_paths_file = out_dir / "drop_paths.txt" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + drop_paths_file.write_text( + "".join(f"{image}\n" for image in images), encoding="utf-8" + ) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + env["IMIV_IMGUI_TEST_ENGINE_DROP_APPLY_FRAME"] = "2" + env["IMIV_IMGUI_TEST_ENGINE_DROP_PATHS_FILE"] = str(drop_paths_file) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + "--state-delay-frames", + "8", + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not state_path.exists(): + return _fail(f"state output not found: {state_path}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + if not bool(state.get("image_loaded", False)): + return _fail("state does not report a loaded image after drop") + if state.get("image_path") != str(images[0]): + return _fail("drop did not load the first dropped image") + if int(state.get("loaded_image_count", 0)) != len(images): + return _fail("shared image library count does not match dropped images") + if int(state.get("current_image_index", -1)) != 0: + return _fail("drop did not select the first dropped image in the queue") + if not bool(state.get("image_list_visible", False)): + return _fail("Image List did not auto-open after multi-file drop") + + print(f"layout: {layout_path}") + print(f"state: {state_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_export_selection_regression.py b/src/imiv/tools/imiv_export_selection_regression.py new file mode 100644 index 0000000000..48117ffa21 --- /dev/null +++ b/src/imiv/tools/imiv_export_selection_regression.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""Regression check for GUI-driven Export Selection As recipe export.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "idiff", + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "src" / "idiff" / "idiff", + repo_root / "build_u" / "src" / "idiff" / "idiff", + repo_root / "build" / "Debug" / "idiff.exe", + repo_root / "build" / "Release" / "idiff.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run( + cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + print("run:", " ".join(cmd)) + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | float | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + root.set("layout_items", "true") + + _scenario_step( + root, + "enable_area_sample", + key_chord="ctrl+a", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_drag", + mouse_pos_image_rel="0.18,0.25", + mouse_drag="180,120", + mouse_drag_button=0, + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "set_blue_channel", + key_chord="b", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "set_single_channel", + key_chord="1", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "adjust_recipe", + exposure=0.75, + gamma=1.6, + offset=0.125, + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "export_selection", + key_chord="ctrl+shift+alt+s", + state=True, + post_action_delay_frames=6, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _assert_close(actual: float, expected: float, name: str) -> None: + if not math.isclose(actual, expected, rel_tol=1.0e-5, abs_tol=1.0e-5): + raise AssertionError(f"{name} mismatch: expected {expected}, got {actual}") + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + default_out_dir = repo_root / "build" / "imiv_captures" / "export_selection_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--backend", default="", help="Optional runtime backend override") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff executable") + ap.add_argument("--env-script", default=str(_default_env_script(repo_root)), help="Optional shell env setup script") + ap.add_argument("--image", default=str(default_image), help="Input image path") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + if not runner.exists(): + return _fail(f"runner not found: {runner}") + image = Path(args.image).expanduser().resolve() + if not image.exists(): + return _fail(f"image not found: {image}") + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if not found: + return _fail(f"oiiotool not found: {oiiotool}") + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + idiff = Path(args.idiff).expanduser() + if not idiff.exists(): + found = shutil.which(str(idiff)) + if not found: + return _fail(f"idiff not found: {idiff}") + idiff = Path(found) + idiff = idiff.resolve() + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + scenario_path = out_dir / "export_selection.scenario.xml" + select_state_path = runtime_dir / "select_drag.state.json" + recipe_state_path = runtime_dir / "adjust_recipe.state.json" + save_state_path = runtime_dir / "export_selection.state.json" + log_path = out_dir / "export_selection.log" + fixture_path = out_dir / "export_selection_input.png" + saved_path = out_dir / "exported_selection.tif" + expected_path = out_dir / "expected_exported_selection.tif" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + prep = _run( + [ + str(oiiotool), + str(image), + "--resize", + "2200x1547", + "-o", + str(fixture_path), + ], + cwd=repo_root, + ) + if prep.returncode != 0: + print(prep.stdout, end="") + return _fail("failed to prepare export-selection fixture") + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + env["IMIV_TEST_SAVE_IMAGE_PATH"] = str(saved_path) + + _write_scenario(scenario_path, _path_for_imiv_output(runtime_dir, cwd)) + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(fixture_path), + "--scenario", + str(scenario_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + proc = _run(cmd, cwd=repo_root, env=env) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not select_state_path.exists(): + return _fail(f"selection state output not found: {select_state_path}") + if not recipe_state_path.exists(): + return _fail(f"recipe state output not found: {recipe_state_path}") + if not save_state_path.exists(): + return _fail(f"export state output not found: {save_state_path}") + if not saved_path.exists(): + return _fail(f"exported selection output not found: {saved_path}") + + select_state = json.loads(select_state_path.read_text(encoding="utf-8")) + recipe_state = json.loads(recipe_state_path.read_text(encoding="utf-8")) + recipe = recipe_state.get("view_recipe", {}) + bounds = [int(v) for v in select_state.get("selection_bounds", [])] + if len(bounds) != 4: + return _fail("selection_bounds missing from selection state") + xbegin, ybegin, xend, yend = bounds + if xend <= xbegin or yend <= ybegin: + return _fail(f"invalid selection bounds: {bounds}") + + _assert_close(float(recipe.get("exposure", 0.0)), 0.75, "exposure") + _assert_close(float(recipe.get("gamma", 0.0)), 1.6, "gamma") + _assert_close(float(recipe.get("offset", 0.0)), 0.125, "offset") + if int(recipe.get("current_channel", 0)) != 3: + return _fail("current_channel did not remain blue") + if int(recipe.get("color_mode", 0)) != 2: + return _fail("color_mode did not remain single channel") + + expected = _run( + [ + str(oiiotool), + str(fixture_path), + "--cut", + f"{xend - xbegin}x{yend - ybegin}+{xbegin}+{ybegin}", + "--ch", + "B,B,B,A=1.0", + "--addc", + "0.125,0.125,0.125,0.0", + "--mulc", + "1.681792830507429,1.681792830507429,1.681792830507429,1.0", + "--powc", + "0.625,0.625,0.625,1.0", + "-d", + "float", + "-o", + str(expected_path), + ], + cwd=repo_root, + ) + if expected.returncode != 0: + print(expected.stdout, end="") + return _fail("failed to generate expected exported selection") + + diff = _run([str(idiff), "-q", "-a", str(expected_path), str(saved_path)], cwd=repo_root) + if diff.returncode != 0: + print(diff.stdout, end="") + return _fail("exported selection did not match expected export") + + print(f"fixture: {fixture_path}") + print(f"select_state: {select_state_path}") + print(f"recipe_state: {recipe_state_path}") + print(f"save_state: {save_state_path}") + print(f"saved: {saved_path}") + print(f"expected: {expected_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_generate_upload_corpus.sh b/src/imiv/tools/imiv_generate_upload_corpus.sh new file mode 100644 index 0000000000..eb4d285335 --- /dev/null +++ b/src/imiv/tools/imiv_generate_upload_corpus.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +out_dir="${1:-$repo_root/build_u/testsuite/imiv/upload_corpus/images}" +manifest_csv="${2:-$repo_root/build_u/testsuite/imiv/upload_corpus/corpus_manifest.csv}" +oiiotool_bin="${OIIOTOOL_BIN:-$repo_root/build_u/bin/oiiotool}" + +if [[ ! -x "$oiiotool_bin" ]]; then + echo "error: oiiotool not found or not executable: $oiiotool_bin" >&2 + exit 2 +fi + +mkdir -p "$out_dir" +mkdir -p "$(dirname "$manifest_csv")" +rm -f "$manifest_csv" + +cat > "$manifest_csv" <<'EOF' +path,width,height,channels,depth,format +EOF + +dimensions=( + "1x1" + "2x3" + "3x5" + "4x7" + "6x10" + "9x13" + "17x17" + "31x17" +) + +channels=( + "rgb:3:0.85,0.35,0.15" + "rgba:4:0.10,0.80,0.95,0.75" +) + +depths=( + "u8:uint8:tif" + "u16:uint16:tif" + "u32:uint32:tif" + "f16:half:exr" + "f32:float:exr" + "f64:double:tif" +) + +count=0 +for dim in "${dimensions[@]}"; do + width="${dim%x*}" + height="${dim#*x}" + for ch_entry in "${channels[@]}"; do + ch_name="${ch_entry%%:*}" + rest="${ch_entry#*:}" + ch_count="${rest%%:*}" + color="${rest#*:}" + for depth_entry in "${depths[@]}"; do + depth_tag="${depth_entry%%:*}" + rest="${depth_entry#*:}" + depth_name="${rest%%:*}" + extension="${rest#*:}" + + file_name="${ch_name}_${depth_tag}_${width}x${height}.${extension}" + file_path="${out_dir}/${file_name}" + + "$oiiotool_bin" \ + --pattern "constant:color=${color}" "${width}x${height}" \ + "${ch_count}" -d "${depth_name}" -o "${file_path}" + + printf '%s,%s,%s,%s,%s,%s\n' \ + "$file_path" "$width" "$height" "$ch_count" "$depth_name" \ + "$extension" >> "$manifest_csv" + count=$((count + 1)) + done + done +done + +echo "generated ${count} images" +echo "corpus dir: ${out_dir}" +echo "manifest: ${manifest_csv}" diff --git a/src/imiv/tools/imiv_gui_test_run.py b/src/imiv/tools/imiv_gui_test_run.py new file mode 100644 index 0000000000..a4b87d90a2 --- /dev/null +++ b/src/imiv/tools/imiv_gui_test_run.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""Run imiv automation via Dear ImGui Test Engine. + +Examples: +#Screenshot only + python3 src/imiv/tools/imiv_gui_test_run.py \ + --screenshot-out build_u/test_captures/smoke.png + +#Layout JSON + SVG + python3 src/imiv/tools/imiv_gui_test_run.py \ + --layout-json-out build_u/test_captures/layout_items.json \ + --layout-items \ + --svg-out build_u/test_captures/layout_items.svg \ + --svg-items --svg-labels + +#Screenshot + layout + junit xml + python3 src/imiv/tools/imiv_gui_test_run.py \ + --screenshot-out build_u/test_captures/smoke.png \ + --layout-json-out build_u/test_captures/layout.json \ + --junit-out build_u/test_captures/imiv_tests.junit.xml +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _resolve_path(path: str, root: Path) -> Path: + p = Path(path) + if p.is_absolute(): + return p + return (root / p).resolve() + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + default_bin = _default_binary(repo_root) + + ap = argparse.ArgumentParser(description="imiv automation runner") + ap.add_argument("--bin", default=str(default_bin), help="imiv executable path") + ap.add_argument( + "--cwd", default="", help="Working directory for imiv (default: binary dir)" + ) + ap.add_argument( + "--backend", + default="", + choices=("", "auto", "vulkan", "metal", "opengl"), + help="Optional runtime backend override passed to imiv", + ) + ap.add_argument( + "--open", + action="append", + default=[], + help="Optional image path to open at startup; may be repeated", + ) + ap.add_argument( + "--scenario", + default="", + help="Optional XML scenario to execute in a single imiv launch", + ) + + ap.add_argument( + "--screenshot-out", + default="", + help="Enable screenshot test and write to this output path", + ) + ap.add_argument( + "--screenshot-frames", type=int, default=1, help="Number of screenshot frames" + ) + ap.add_argument( + "--screenshot-delay-frames", + type=int, + default=3, + help="Initial delay before screenshot capture", + ) + ap.add_argument( + "--screenshot-save-all", action="store_true", help="Save all screenshot frames" + ) + + ap.add_argument( + "--layout-json-out", + default="", + help="Enable layout JSON dump and write to this path", + ) + ap.add_argument( + "--layout-items", action="store_true", help="Include per-item data in layout JSON" + ) + ap.add_argument("--layout-depth", type=int, default=8, help="Layout gather depth") + ap.add_argument( + "--layout-delay-frames", type=int, default=3, help="Initial delay before layout dump" + ) + ap.add_argument( + "--state-json-out", + default="", + help="Enable viewer state JSON dump and write to this path", + ) + ap.add_argument( + "--state-delay-frames", + type=int, + default=3, + help="Initial delay before viewer state dump", + ) + ap.add_argument( + "--post-action-delay-frames", + type=int, + default=0, + help="Extra frames to wait after synthetic input before capture/dump", + ) + + ap.add_argument("--svg-out", default="", help="Post-convert layout JSON to SVG at this path") + ap.add_argument( + "--svg-items", action="store_true", help="Draw items in SVG (implies --layout-items)" + ) + ap.add_argument("--svg-no-items", action="store_true", help="Disable items in SVG") + ap.add_argument("--svg-items-clipped", action="store_true", help="Use clipped item rects in SVG") + ap.add_argument("--svg-labels", action="store_true", help="Draw labels in SVG") + + ap.add_argument("--junit-out", default="", help="Enable JUnit XML export to this path") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace logs") + ap.add_argument( + "--show-drag-overlay", + action="store_true", + help="Force the drag-and-drop dimming overlay during automation", + ) + ap.add_argument( + "--ocio-use", + default="", + help="Optional OCIO enable override for automation (true/false)", + ) + ap.add_argument( + "--ocio-display", + default="", + help="Optional live OCIO display override for automation", + ) + ap.add_argument( + "--ocio-view", default="", help="Optional live OCIO view override for automation" + ) + ap.add_argument( + "--ocio-image-color-space", + default="", + help="Optional live OCIO image color space override for automation", + ) + ap.add_argument( + "--ocio-apply-frame", + type=int, + default=0, + help="Frame number at which automation OCIO overrides should begin", + ) + ap.add_argument( + "--key-chord", + default="", + help="Optional ImGui key chord before capture/layout, e.g. ctrl+i or ctrl+0", + ) + ap.add_argument( + "--mouse-pos", + nargs=2, + type=float, + metavar=("X", "Y"), + default=None, + help="Move mouse to absolute position before capture/layout", + ) + ap.add_argument( + "--mouse-pos-window-rel", + nargs=2, + type=float, + metavar=("X", "Y"), + default=None, + help="Move mouse to viewport-relative position [0..1] before capture/layout", + ) + ap.add_argument( + "--mouse-pos-image-rel", + nargs=2, + type=float, + metavar=("U", "V"), + default=None, + help="Move mouse to image-relative position [0..1] before capture/layout", + ) + ap.add_argument( + "--mouse-click", + type=int, + default=None, + help="Optional mouse click button index before capture/layout", + ) + ap.add_argument( + "--mouse-wheel", + nargs=2, + type=float, + metavar=("DX", "DY"), + default=None, + help="Optional mouse wheel delta before capture/layout", + ) + ap.add_argument( + "--mouse-drag", + nargs=2, + type=float, + metavar=("DX", "DY"), + default=None, + help="Optional mouse drag delta before capture/layout", + ) + ap.add_argument( + "--mouse-drag-button", type=int, default=0, help="Mouse button index for --mouse-drag" + ) + ap.add_argument( + "--mouse-drag-hold", + nargs=2, + type=float, + metavar=("DX", "DY"), + default=None, + help="Optional mouse drag delta that stays held through capture/layout", + ) + ap.add_argument( + "--mouse-drag-hold-button", + type=int, + default=0, + help="Mouse button index for --mouse-drag-hold", + ) + ap.add_argument( + "--mouse-drag-hold-frames", + type=int, + default=1, + help="Frames to keep the held drag active before capture/layout", + ) + args = ap.parse_args() + + exe = _resolve_path(args.bin, repo_root) + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + run_cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + + layout_json_out = args.layout_json_out + if args.svg_out and not layout_json_out: + svg_path = _resolve_path(args.svg_out, repo_root) + layout_json_out = str(svg_path.with_suffix(".json")) + + want_screenshot = bool(args.screenshot_out) + want_layout = bool(layout_json_out) + want_state = bool(args.state_json_out) + want_scenario = bool(args.scenario) + want_svg = bool(args.svg_out) + want_junit = bool(args.junit_out) + same_test_hold_capture = want_screenshot and bool(args.mouse_drag_hold) + + if want_scenario: + if want_screenshot or want_layout or want_state or want_svg: + print( + "error: --scenario cannot be combined with direct screenshot/layout/state/svg outputs", + file=sys.stderr, + ) + return 2 + if ( + args.key_chord + or args.mouse_pos + or args.mouse_pos_window_rel + or args.mouse_pos_image_rel + or args.mouse_click is not None + or args.mouse_wheel + or args.mouse_drag + or args.mouse_drag_hold + or args.post_action_delay_frames > 0 + ): + print( + "error: --scenario manages synthetic input internally; do not combine it with direct key/mouse action flags", + file=sys.stderr, + ) + return 2 + + if not (want_scenario or want_screenshot or want_layout or want_state): + print( + "error: select at least one automation task: --scenario, --screenshot-out, --layout-json-out, --state-json-out, or --svg-out", + file=sys.stderr, + ) + return 2 + + env = dict(os.environ) + env["IMIV_IMGUI_TEST_ENGINE"] = "1" + env["IMIV_IMGUI_TEST_ENGINE_EXIT_ON_FINISH"] = "1" + + open_paths = [_resolve_path(path, repo_root) for path in args.open if path] + if args.scenario: + scenario_path = _resolve_path(args.scenario, repo_root) + if not scenario_path.exists(): + print(f"error: scenario not found: {scenario_path}", file=sys.stderr) + return 2 + env["IMIV_IMGUI_TEST_ENGINE_SCENARIO_FILE"] = _path_for_imiv_output( + scenario_path, run_cwd + ) + + if args.trace: + env["IMIV_IMGUI_TEST_ENGINE_TRACE"] = "1" + + if args.show_drag_overlay: + env["IMIV_IMGUI_TEST_ENGINE_SHOW_DRAG_OVERLAY"] = "1" + + if args.ocio_use: + env["IMIV_IMGUI_TEST_ENGINE_OCIO_USE"] = args.ocio_use + if args.ocio_display: + env["IMIV_IMGUI_TEST_ENGINE_OCIO_DISPLAY"] = args.ocio_display + if args.ocio_view: + env["IMIV_IMGUI_TEST_ENGINE_OCIO_VIEW"] = args.ocio_view + if args.ocio_image_color_space: + env["IMIV_IMGUI_TEST_ENGINE_OCIO_IMAGE_COLOR_SPACE"] = ( + args.ocio_image_color_space + ) + if args.ocio_apply_frame > 0: + env["IMIV_IMGUI_TEST_ENGINE_OCIO_APPLY_FRAME"] = str(args.ocio_apply_frame) + + if args.key_chord: + env["IMIV_IMGUI_TEST_ENGINE_KEY_CHORD"] = args.key_chord + + if args.mouse_pos: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_X"] = str(args.mouse_pos[0]) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_Y"] = str(args.mouse_pos[1]) + + if args.mouse_pos_window_rel: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_WINDOW_REL_X"] = str( + args.mouse_pos_window_rel[0] + ) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_WINDOW_REL_Y"] = str( + args.mouse_pos_window_rel[1] + ) + + if args.mouse_pos_image_rel: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_IMAGE_REL_X"] = str( + args.mouse_pos_image_rel[0] + ) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_IMAGE_REL_Y"] = str( + args.mouse_pos_image_rel[1] + ) + + if args.mouse_click is not None: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_CLICK_BUTTON"] = str(args.mouse_click) + + if args.mouse_wheel: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_WHEEL_X"] = str(args.mouse_wheel[0]) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_WHEEL_Y"] = str(args.mouse_wheel[1]) + + if args.mouse_drag: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_DRAG_DX"] = str(args.mouse_drag[0]) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_DRAG_DY"] = str(args.mouse_drag[1]) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_DRAG_BUTTON"] = str( + args.mouse_drag_button + ) + + if args.mouse_drag_hold: + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_DRAG_DX"] = str(args.mouse_drag_hold[0]) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_DRAG_DY"] = str(args.mouse_drag_hold[1]) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_DRAG_BUTTON"] = str( + args.mouse_drag_hold_button + ) + env["IMIV_IMGUI_TEST_ENGINE_MOUSE_HOLD_FRAMES"] = str( + max(0, args.mouse_drag_hold_frames) + ) + if args.post_action_delay_frames > 0: + env["IMIV_IMGUI_TEST_ENGINE_POST_ACTION_DELAY_FRAMES"] = str( + max(0, args.post_action_delay_frames) + ) + + if want_screenshot: + out = _resolve_path(args.screenshot_out, repo_root) + out.parent.mkdir(parents=True, exist_ok=True) + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT"] = "1" + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_OUT"] = _path_for_imiv_output( + out, run_cwd + ) + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_FRAMES"] = str( + max(1, args.screenshot_frames) + ) + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_DELAY_FRAMES"] = str( + max(0, args.screenshot_delay_frames) + ) + if args.screenshot_save_all: + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_SAVE_ALL"] = "1" + if same_test_hold_capture and want_layout: + layout_out = _resolve_path(layout_json_out, repo_root) + layout_out.parent.mkdir(parents=True, exist_ok=True) + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_LAYOUT_OUT"] = ( + _path_for_imiv_output(layout_out, run_cwd) + ) + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_LAYOUT_DEPTH"] = str( + max(1, args.layout_depth) + ) + if args.layout_items or args.svg_items or (want_svg and not args.svg_no_items): + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_LAYOUT_ITEMS"] = "1" + if same_test_hold_capture and want_state: + state_out = _resolve_path(args.state_json_out, repo_root) + state_out.parent.mkdir(parents=True, exist_ok=True) + env["IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_STATE_OUT"] = ( + _path_for_imiv_output(state_out, run_cwd) + ) + + if want_layout and not same_test_hold_capture: + out = _resolve_path(layout_json_out, repo_root) + out.parent.mkdir(parents=True, exist_ok=True) + env["IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP"] = "1" + env["IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_OUT"] = _path_for_imiv_output( + out, run_cwd + ) + env["IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_DEPTH"] = str( + max(1, args.layout_depth) + ) + env["IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_DELAY_FRAMES"] = str( + max(0, args.layout_delay_frames) + ) + if args.layout_items or args.svg_items or (want_svg and not args.svg_no_items): + env["IMIV_IMGUI_TEST_ENGINE_LAYOUT_DUMP_ITEMS"] = "1" + + if want_state and not same_test_hold_capture: + out = _resolve_path(args.state_json_out, repo_root) + out.parent.mkdir(parents=True, exist_ok=True) + env["IMIV_IMGUI_TEST_ENGINE_STATE_DUMP"] = "1" + env["IMIV_IMGUI_TEST_ENGINE_STATE_DUMP_OUT"] = _path_for_imiv_output( + out, run_cwd + ) + env["IMIV_IMGUI_TEST_ENGINE_STATE_DUMP_DELAY_FRAMES"] = str( + max(0, args.state_delay_frames) + ) + + if want_junit: + junit_out = _resolve_path(args.junit_out, repo_root) + junit_out.parent.mkdir(parents=True, exist_ok=True) + env["IMIV_IMGUI_TEST_ENGINE_JUNIT_XML"] = "1" + env["IMIV_IMGUI_TEST_ENGINE_JUNIT_OUT"] = _path_for_imiv_output( + junit_out, run_cwd + ) + + def _run_once(run_env: dict[str, str]) -> int: + print(f"run: {exe}") + print(f"cwd: {run_cwd}") + cmd = [str(exe)] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.append("-F") + cmd.extend(str(path) for path in open_paths) + return subprocess.run( + cmd, cwd=str(run_cwd), env=run_env, check=False + ).returncode + + rc = _run_once(env) + if rc != 0: + print(f"error: imiv exited with code {rc}", file=sys.stderr) + return rc + + if same_test_hold_capture and (want_layout or want_state): + if want_layout: + layout_path = _resolve_path(layout_json_out, repo_root) + if not layout_path.exists(): + print(f"error: layout output not found: {layout_path}", file=sys.stderr) + return 2 + if want_state: + state_path = _resolve_path(args.state_json_out, repo_root) + if not state_path.exists(): + print(f"error: state output not found: {state_path}", file=sys.stderr) + return 2 + + if want_svg: + if not want_layout: + print("error: internal: svg requested without layout json", file=sys.stderr) + return 2 + + json_path = _resolve_path(layout_json_out, repo_root) + svg_path = _resolve_path(args.svg_out, repo_root) + svg_path.parent.mkdir(parents=True, exist_ok=True) + converter = repo_root / "src" / "imiv" / "tools" / "imiv_layout_json_to_svg.py" + cmd = [ + sys.executable, + str(converter), + "--in", + str(json_path), + "--out", + str(svg_path), + ] + if args.svg_items: + cmd.append("--items") + if args.svg_no_items: + cmd.append("--no-items") + if args.svg_items_clipped: + cmd.append("--items-clipped") + if args.svg_labels: + cmd.append("--labels") + + print("post:", " ".join(cmd)) + rc_svg = subprocess.run(cmd, cwd=str(repo_root), check=False).returncode + if rc_svg != 0: + return rc_svg + + if want_junit: + junit_path = _resolve_path(args.junit_out, repo_root) + if not junit_path.exists(): + print(f"error: junit output not found: {junit_path}", file=sys.stderr) + return 2 + try: + root = ET.parse(junit_path).getroot() + except ET.ParseError as exc: + print( + f"error: failed to parse junit xml '{junit_path}': {exc}", + file=sys.stderr, + ) + return 2 + + failures = 0 + errors = 0 + if root.tag == "testsuite": + failures += int(root.attrib.get("failures", "0") or "0") + errors += int(root.attrib.get("errors", "0") or "0") + else: + for suite in root.iter("testsuite"): + failures += int(suite.attrib.get("failures", "0") or "0") + errors += int(suite.attrib.get("errors", "0") or "0") + + if failures > 0 or errors > 0: + print( + f"error: junit reported failures={failures}, errors={errors}", + file=sys.stderr, + ) + return 1 + + if want_state: + state_path = _resolve_path(args.state_json_out, repo_root) + if not state_path.exists(): + print(f"error: state output not found: {state_path}", file=sys.stderr) + return 2 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_image_list_center_regression.py b/src/imiv/tools/imiv_image_list_center_regression.py new file mode 100644 index 0000000000..1e22749996 --- /dev/null +++ b/src/imiv/tools/imiv_image_list_center_regression.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Regression check for preserving centered scroll when opening Image List.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + baseline = ET.SubElement(root, "step") + baseline.set("name", "baseline") + baseline.set("state", "true") + baseline.set("post_action_delay_frames", "4") + + show_list = ET.SubElement(root, "step") + show_list.set("name", "show_image_list") + show_list.set("image_list_visible", "true") + show_list.set("state", "true") + show_list.set("post_action_delay_frames", "4") + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _build_wide_fixture(oiiotool: Path, out_path: Path) -> None: + cmd = [ + str(oiiotool), + "--pattern", + "constant:color=0.25,0.5,0.75", + "10000x2000", + "3", + "-d", + "half", + "-o", + str(out_path), + ] + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def _load_json(path: Path) -> dict: + if not path.exists(): + raise FileNotFoundError(path) + return json.loads(path.read_text(encoding="utf-8")) + + +def _norm_scroll_centered(state: dict, tol: float = 0.08) -> bool: + norm_scroll = state.get("norm_scroll") + if not ( + isinstance(norm_scroll, list) + and len(norm_scroll) == 2 + and all(isinstance(v, (int, float)) for v in norm_scroll) + ): + return False + return abs(float(norm_scroll[0]) - 0.5) <= tol and abs(float(norm_scroll[1]) - 0.5) <= tol + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "image_list_center_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="opengl", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable" + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + runner = runner.resolve() + if not runner.exists(): + return _fail(f"runner not found: {runner}") + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if found is None: + return _fail(f"oiiotool not found: {oiiotool}") + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + scenario_path = out_dir / "image_list_center.scenario.xml" + fixture_path = out_dir / "wide.exr" + baseline_state_path = runtime_dir / "baseline.state.json" + show_state_path = runtime_dir / "show_image_list.state.json" + log_path = out_dir / "image_list_center.log" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + _build_wide_fixture(oiiotool, fixture_path) + _write_scenario(scenario_path, os.path.relpath(runtime_dir, cwd)) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(fixture_path), + "--scenario", + str(scenario_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + try: + baseline_state = _load_json(baseline_state_path) + show_state = _load_json(show_state_path) + except FileNotFoundError as exc: + return _fail(f"state output not found: {exc}") + + if not bool(baseline_state.get("image_loaded", False)): + return _fail("baseline state does not report a loaded image") + if bool(baseline_state.get("image_list_visible", True)): + return _fail("baseline state unexpectedly reports Image List as visible") + if not _norm_scroll_centered(baseline_state): + return _fail("baseline image was not centered") + + if not bool(show_state.get("image_loaded", False)): + return _fail("show-image-list state does not report a loaded image") + if not bool(show_state.get("image_list_visible", False)): + return _fail("show-image-list state does not report Image List as visible") + if not bool(show_state.get("image_list_drawn", False)): + return _fail("show-image-list state does not report Image List as drawn") + if not _norm_scroll_centered(show_state): + return _fail("opening Image List changed the centered scroll position") + + scroll = show_state.get("scroll") + if not ( + isinstance(scroll, list) + and len(scroll) == 2 + and isinstance(scroll[0], (int, float)) + and float(scroll[0]) > 1.0 + ): + return _fail("wide-image regression did not produce horizontal scrolling") + + print(f"baseline_state: {baseline_state_path}") + print(f"show_state: {show_state_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_image_list_interaction_regression.py b/src/imiv/tools/imiv_image_list_interaction_regression.py new file mode 100644 index 0000000000..9620ae087f --- /dev/null +++ b/src/imiv/tools/imiv_image_list_interaction_regression.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Regression check for Image List row actions and multi-view behavior.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _load_json(path: Path) -> dict: + if not path.exists(): + raise FileNotFoundError(path) + return json.loads(path.read_text(encoding="utf-8")) + + +def _row_open_view_ids(state: dict, index: int) -> list[int]: + rows = state.get("image_list_open_view_ids", []) + if not isinstance(rows, list) or index < 0 or index >= len(rows): + return [] + row = rows[index] + if not isinstance(row, list): + return [] + view_ids: list[int] = [] + for value in row: + try: + view_ids.append(int(value)) + except (TypeError, ValueError): + continue + return view_ids + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_interaction_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + _scenario_step( + root, + "click_second", + image_list_select_index=1, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "double_click_first", + image_list_open_new_view_index=0, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "close_first_in_active", + image_list_close_active_index=0, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "remove_second_from_session", + image_list_remove_index=1, + state=True, + post_action_delay_frames=4, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _run_runner( + *, + runner: Path, + exe: Path, + cwd: Path, + repo_root: Path, + env: dict[str, str], + images: list[Path], + scenario_path: Path, + backend: str, + trace: bool, + log_path: Path, +) -> int: + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + for image in images: + cmd.extend(["--open", str(image)]) + cmd.extend(["--scenario", str(scenario_path)]) + if backend: + cmd.extend(["--backend", backend]) + if trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return proc.returncode + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "image_list_interaction_regression" + default_images = [ + repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png", + repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr", + ] + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--image", + dest="images", + action="append", + default=[], + help="Startup image path; may be repeated", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + runner = runner.resolve() + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + images = [Path(p).expanduser().resolve() for p in args.images] if args.images else default_images + if len(images) < 2: + return _fail("regression requires at least two startup images") + for image in images: + if not image.exists(): + return _fail(f"image not found: {image}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + interaction_runtime_dir = out_dir / "runtime" + interaction_scenario_path = out_dir / "image_list_interactions.scenario.xml" + click_state_path = interaction_runtime_dir / "click_second.state.json" + double_click_state_path = interaction_runtime_dir / "double_click_first.state.json" + close_state_path = interaction_runtime_dir / "close_first_in_active.state.json" + remove_state_path = interaction_runtime_dir / "remove_second_from_session.state.json" + interaction_log_path = out_dir / "image_list_interactions.log" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + + _write_interaction_scenario( + interaction_scenario_path, os.path.relpath(interaction_runtime_dir, cwd) + ) + rc = _run_runner( + runner=runner, + exe=exe, + cwd=cwd, + repo_root=repo_root, + env=env, + images=images, + scenario_path=interaction_scenario_path, + backend=args.backend, + trace=args.trace, + log_path=interaction_log_path, + ) + if rc != 0: + return _fail(f"interaction runner exited with code {rc}") + + try: + click_state = _load_json(click_state_path) + double_click_state = _load_json(double_click_state_path) + close_state = _load_json(close_state_path) + remove_state = _load_json(remove_state_path) + except FileNotFoundError as exc: + return _fail(f"state output not found: {exc}") + + second_image = str(images[1]) + first_image = str(images[0]) + + if click_state.get("image_path") != second_image: + return _fail("single-click did not load the second image into the active view") + if int(click_state.get("current_image_index", -1)) != 1: + return _fail("single-click did not switch current_image_index to the second image") + if not bool(click_state.get("image_list_visible", False)): + return _fail("single-click state does not report Image List as visible") + + if int(double_click_state.get("view_count", 0)) < 2: + return _fail("double-click did not open a new image view") + if int(double_click_state.get("loaded_view_count", 0)) < 2: + return _fail("double-click did not leave two loaded image views open") + if int(double_click_state.get("active_view_id", 0)) <= 1: + return _fail("double-click did not activate the new image view") + if double_click_state.get("image_path") != first_image: + return _fail("double-click did not open the first image in the new view") + if not bool(double_click_state.get("active_view_docked", False)): + return _fail("double-click state does not report the new image view as docked") + if _row_open_view_ids(double_click_state, 0) != [int(double_click_state["active_view_id"])]: + return _fail("first Image List row did not report the secondary view id") + if _row_open_view_ids(double_click_state, 1) != [1]: + return _fail("second Image List row did not preserve the primary view id") + + if bool(close_state.get("image_loaded", True)): + return _fail("close-in-active-view did not clear the active image view") + if int(close_state.get("loaded_view_count", -1)) != 1: + return _fail("close-in-active-view did not leave exactly one loaded view") + + if int(remove_state.get("loaded_image_count", -1)) != 1: + return _fail("remove-from-session did not shrink the session queue") + if not bool(remove_state.get("image_list_visible", False)): + return _fail( + "Image List did not remain visible after the queue shrank to one image" + ) + if int(remove_state.get("loaded_view_count", -1)) != 1: + return _fail( + "remove-from-session did not keep the surviving image loaded in one view" + ) + if _row_open_view_ids(remove_state, 0) != [1]: + return _fail("remaining Image List row did not report the primary view id") + + print(f"click_state: {click_state_path}") + print(f"double_click_state: {double_click_state_path}") + print(f"close_state: {close_state_path}") + print(f"remove_state: {remove_state_path}") + print(f"log: {interaction_log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_image_list_regression.py b/src/imiv/tools/imiv_image_list_regression.py new file mode 100644 index 0000000000..82b2882cf2 --- /dev/null +++ b/src/imiv/tools/imiv_image_list_regression.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Regression check for default Image List visibility on multi-file load.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "image_list_regression" + default_images = [ + repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png", + repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr", + ] + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--image", + dest="images", + action="append", + default=[], + help="Startup image path; may be repeated", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + runner = runner.resolve() + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + images = [Path(p).expanduser().resolve() for p in args.images] if args.images else default_images + if len(images) < 2: + return _fail("regression requires at least two startup images") + for image in images: + if not image.exists(): + return _fail(f"image not found: {image}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + layout_path = out_dir / "image_list.layout.json" + state_path = out_dir / "image_list.state.json" + log_path = out_dir / "image_list.log" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--layout-json-out", + str(layout_path), + "--state-json-out", + str(state_path), + ] + for image in images: + cmd.extend(["--open", str(image)]) + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not layout_path.exists(): + return _fail(f"layout output not found: {layout_path}") + if not state_path.exists(): + return _fail(f"state output not found: {state_path}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + + if int(state.get("loaded_image_count", 0)) < 2: + return _fail("state does not report a multi-image queue") + if not bool(state.get("image_list_visible", False)): + return _fail("state does not report Image List as visible") + + if not bool(state.get("image_list_drawn", False)): + return _fail("state does not report Image List as drawn") + if not bool(state.get("image_list_docked", False)): + return _fail("state does not report Image List as docked") + + image_list_size = state.get("image_list_size") + if not ( + isinstance(image_list_size, list) + and len(image_list_size) == 2 + and isinstance(image_list_size[0], (int, float)) + ): + return _fail("state does not report Image List size") + + image_list_width = float(image_list_size[0]) + if image_list_width < 150.0 or image_list_width > 260.0: + return _fail(f"unexpected Image List width: {image_list_width:.1f}") + + print(f"layout: {layout_path}") + print(f"state: {state_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_large_image_switch_regression.py b/src/imiv/tools/imiv_large_image_switch_regression.py new file mode 100644 index 0000000000..9cebf00ce4 --- /dev/null +++ b/src/imiv/tools/imiv_large_image_switch_regression.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Regression check for large-image queue switching on GPU backends.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +COMMON_ERROR_PATTERNS = ( + "error: imiv exited with code", +) + +VULKAN_ERROR_PATTERNS = ( + "imiv: Vulkan error", + "fatal Vulkan error", + "VK_ERROR_DEVICE_LOST", + "vkUpdateDescriptorSets():", + "maxStorageBufferRange", +) + +OPENGL_ERROR_PATTERNS = ( + "OpenGL texture upload failed", + "OpenGL striped texture upload failed", + "OpenGL preview draw failed", +) + +METAL_ERROR_PATTERNS = ( + "failed to create Metal striped upload buffer", + "failed to create Metal source upload buffer", + "Metal source upload compute dispatch failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + which = shutil.which("oiiotool") + return Path(which) if which else candidates[0] + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + step = ET.SubElement(root, "step") + step.set("name", "next_1") + step.set("key_chord", "pagedown") + step.set("post_action_delay_frames", "4") + + step = ET.SubElement(root, "step") + step.set("name", "next_2") + step.set("key_chord", "pagedown") + step.set("post_action_delay_frames", "4") + step.set("state", "true") + step.set("layout", "true") + step.set("layout_items", "true") + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _run_checked(cmd: list[str], *, cwd: Path) -> None: + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def _generate_large_rgb_fixture( + oiiotool: Path, out_path: Path, color: str, width: int, height: int +) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + _run_checked( + [ + str(oiiotool), + "--pattern", + f"constant:color={color}", + f"{width}x{height}", + "3", + "-d", + "uint16", + "-o", + str(out_path), + ], + cwd=out_path.parent, + ) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _backend_error_patterns(backend: str) -> tuple[str, ...]: + backend = backend.lower() + if backend == "vulkan": + return COMMON_ERROR_PATTERNS + VULKAN_ERROR_PATTERNS + if backend == "opengl": + return COMMON_ERROR_PATTERNS + OPENGL_ERROR_PATTERNS + if backend == "metal": + return COMMON_ERROR_PATTERNS + METAL_ERROR_PATTERNS + return COMMON_ERROR_PATTERNS + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--backend", default="vulkan", help="Runtime backend override") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--env-script", default="", help="Optional shell env setup script") + ap.add_argument("--out-dir", default="", help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable runner trace") + args = ap.parse_args() + + backend = args.backend.lower() + if backend not in ("vulkan", "opengl", "metal"): + print("skip: large image switch regression currently targets Vulkan/OpenGL/Metal only") + return 77 + + exe = Path(args.bin).resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + + oiiotool = Path(args.oiiotool).resolve() + if not oiiotool.exists(): + return _fail(f"oiiotool not found: {oiiotool}") + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + if args.out_dir: + out_dir = Path(args.out_dir).resolve() + else: + out_dir = exe.parent.parent / "imiv_captures" / f"large_image_switch_regression_{backend}" + runtime_dir = out_dir / "runtime" + input_dir = out_dir / "inputs" + scenario_path = out_dir / "large_image_switch.scenario.xml" + log_path = out_dir / "large_image_switch.log" + state_path = runtime_dir / "next_2.state.json" + layout_path = runtime_dir / "next_2.layout.json" + + shutil.rmtree(runtime_dir, ignore_errors=True) + shutil.rmtree(input_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + _write_scenario(scenario_path, os.path.relpath(runtime_dir, cwd)) + + width = 4096 + height = 4096 + image_paths = [ + input_dir / "large_rgb_a.tif", + input_dir / "large_rgb_b.tif", + input_dir / "large_rgb_c.tif", + ] + colors = ["0.10,0.20,0.30", "0.30,0.20,0.10", "0.20,0.30,0.10"] + for path, color in zip(image_paths, colors): + _generate_large_rgb_fixture(oiiotool, path, color, width, height) + + env_script = ( + Path(args.env_script).resolve() + if args.env_script + else _default_env_script(repo_root, exe) + ) + env = _load_env_from_script(env_script) + config_home = out_dir / "cfg" + shutil.rmtree(config_home, ignore_errors=True) + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + if backend == "vulkan": + env["IMIV_VULKAN_MAX_STORAGE_BUFFER_RANGE_OVERRIDE"] = str(64 * 1024 * 1024) + elif backend == "opengl": + env["IMIV_OPENGL_MAX_UPLOAD_CHUNK_BYTES_OVERRIDE"] = str(64 * 1024 * 1024) + elif backend == "metal": + env["IMIV_METAL_MAX_UPLOAD_CHUNK_BYTES_OVERRIDE"] = str(64 * 1024 * 1024) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--backend", + backend, + "--scenario", + str(scenario_path), + ] + for path in image_paths: + cmd.extend(["--open", str(path)]) + if args.trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + timeout=180, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in _backend_error_patterns(backend): + if pattern in log_text: + return _fail(f"found runtime error pattern: {pattern}") + + if not state_path.exists(): + return _fail(f"state output not found: {state_path}") + if not layout_path.exists(): + return _fail(f"layout output not found: {layout_path}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + if not state.get("image_loaded", False): + return _fail("state does not report a loaded image after large-image switching") + if int(state.get("loaded_image_count", 0)) != 3: + return _fail("state does not report the expected loaded-image queue size") + if int(state.get("current_image_index", -1)) != 2: + return _fail("state does not report the third image as active after two switches") + if Path(state.get("image_path", "")).resolve() != image_paths[2]: + return _fail("state does not report the expected third image path") + + print(f"state: {state_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_layout_json_to_svg.py b/src/imiv/tools/imiv_layout_json_to_svg.py new file mode 100644 index 0000000000..b4413bfbc8 --- /dev/null +++ b/src/imiv/tools/imiv_layout_json_to_svg.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Convert imiv ImGui Test Engine layout JSON to an SVG overlay.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any, Iterable, Tuple + + +def _die(msg: str) -> None: + print(f"error: {msg}", file=sys.stderr) + raise SystemExit(2) + + +def _xml_escape(s: str) -> str: + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +def _as_f(v: Any) -> float: + try: + return float(v) + except Exception: + return float("nan") + + +def _vec2(v: Any) -> Tuple[float, float]: + if not isinstance(v, list) or len(v) != 2: + return (float("nan"), float("nan")) + return (_as_f(v[0]), _as_f(v[1])) + + +def _rect(obj: Any, key: str | None = None) -> Tuple[float, float, float, float]: + r = obj if key is None else obj.get(key) + if not isinstance(r, dict): + return (float("nan"), float("nan"), float("nan"), float("nan")) + mn = _vec2(r.get("min")) + mx = _vec2(r.get("max")) + return (mn[0], mn[1], mx[0], mx[1]) + + +def _finite(vals: Iterable[float]) -> Iterable[float]: + for x in vals: + if math.isfinite(x): + yield x + + +def _bounds_from_rects(rects: Iterable[Tuple[float, float, float, float]]) -> Tuple[float, float, float, float]: + xs: list[float] = [] + ys: list[float] = [] + for x0, y0, x1, y1 in rects: + xs.extend([x0, x1]) + ys.extend([y0, y1]) + fx = list(_finite(xs)) + fy = list(_finite(ys)) + if not fx or not fy: + return (0.0, 0.0, 0.0, 0.0) + return (min(fx), min(fy), max(fx), max(fy)) + + +def _iter_window_rects(data: dict[str, Any]) -> Iterable[Tuple[float, float, float, float]]: + for w in data.get("windows", []) or []: + yield _rect(w, "rect") + + +def _iter_item_rects(data: dict[str, Any], use_clipped: bool) -> Iterable[Tuple[float, float, float, float]]: + key = "rect_clipped" if use_clipped else "rect_full" + for w in data.get("windows", []) or []: + for it in w.get("items", []) or []: + yield _rect(it, key) + + +def _svg_rect(x0: float, y0: float, x1: float, y1: float) -> Tuple[float, float, float, float]: + x = min(x0, x1) + y = min(y0, y1) + w = abs(x1 - x0) + h = abs(y1 - y0) + return (x, y, w, h) + + +def _rect_polygon_points(x0: float, y0: float, x1: float, y1: float) -> str: + x, y, w, h = _svg_rect(x0, y0, x1, y1) + x2 = x + w + y2 = y + h + return f"{x:.3f},{y:.3f} {x2:.3f},{y:.3f} {x2:.3f},{y2:.3f} {x:.3f},{y2:.3f}" + + +def main() -> int: + ap = argparse.ArgumentParser(description="Convert imiv layout JSON to SVG overlay.") + ap.add_argument("--in", dest="in_path", required=True, help="Input layout JSON path.") + ap.add_argument("--out", dest="out_path", required=True, help="Output SVG path.") + ap.add_argument("--items", action="store_true", help="Force drawing items (buttons/widgets).") + ap.add_argument("--no-items", action="store_true", help="Disable drawing items.") + ap.add_argument("--items-clipped", action="store_true", help="Use clipped rects for items (rect_clipped).") + ap.add_argument("--labels", action="store_true", help="Draw window names as text labels.") + ap.add_argument("--bg", default="#ffffff", help="Background color (default: #ffffff).") + ap.add_argument("--window-stroke", default="#ef4444", help="Window fill color.") + ap.add_argument("--item-stroke", default="#3b82f6", help="Item fill color.") + ap.add_argument("--window-fill-opacity", type=float, default=0.08, help="Window fill opacity.") + ap.add_argument("--item-fill-opacity", type=float, default=0.12, help="Item fill opacity.") + ap.add_argument("--pad", type=float, default=10.0, help="Padding around bounds (default: 10).") + args = ap.parse_args() + + in_path = Path(args.in_path) + if not in_path.exists(): + _die(f"input not found: {in_path}") + + data = json.loads(in_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + _die("input json is not an object") + + has_items = any( + isinstance(w, dict) and isinstance(w.get("items"), list) and len(w.get("items")) > 0 + for w in (data.get("windows", []) or []) + ) + draw_items = (args.items or has_items) and not args.no_items + + win_bounds = _bounds_from_rects(_iter_window_rects(data)) + item_bounds = (0.0, 0.0, 0.0, 0.0) + if draw_items: + item_bounds = _bounds_from_rects(_iter_item_rects(data, args.items_clipped)) + + rects = [win_bounds] + if draw_items: + rects.append(item_bounds) + x0, y0, x1, y1 = _bounds_from_rects(rects) + + pad = float(args.pad) + x0 -= pad + y0 -= pad + x1 += pad + y1 += pad + + width = max(0.0, x1 - x0) + height = max(0.0, y1 - y0) + if width <= 0.0 or height <= 0.0: + _die("computed empty bounds (no windows/items?)") + + ox, oy = x0, y0 + + def nx(x: float) -> float: + return x - ox + + def ny(y: float) -> float: + return y - oy + + out_path = Path(args.out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + svg_lines: list[str] = [] + svg_lines.append('') + svg_lines.append( + f'' + ) + svg_lines.append("") + + svg_lines.append( + f'' + ) + + win_fill_opacity = max(0.0, min(1.0, float(args.window_fill_opacity))) + svg_lines.append('') + for window in data.get("windows", []) or []: + rx0, ry0, rx1, ry1 = _rect(window, "rect") + if not all(math.isfinite(v) for v in (rx0, ry0, rx1, ry1)): + continue + x, y, w, h = _svg_rect(nx(rx0), ny(ry0), nx(rx1), ny(ry1)) + points = _rect_polygon_points(x, y, x + w, y + h) + svg_lines.append( + f'' + ) + if args.labels: + name = str(window.get("name") or "") + svg_lines.append( + f'' + f"{_xml_escape(name)}" + ) + svg_lines.append("") + + if draw_items: + key = "rect_clipped" if args.items_clipped else "rect_full" + item_fill_opacity = max(0.0, min(1.0, float(args.item_fill_opacity))) + svg_lines.append('') + for window in data.get("windows", []) or []: + for item in window.get("items", []) or []: + rx0, ry0, rx1, ry1 = _rect(item, key) + if not all(math.isfinite(v) for v in (rx0, ry0, rx1, ry1)): + continue + x, y, w, h = _svg_rect(nx(rx0), ny(ry0), nx(rx1), ny(ry1)) + points = _rect_polygon_points(x, y, x + w, y + h) + svg_lines.append( + f'' + ) + if args.labels: + label = str(item.get("debug") or "") + if label: + svg_lines.append( + f'' + f"{_xml_escape(label)}" + ) + svg_lines.append("") + + svg_lines.append("") + out_path.write_text("\n".join(svg_lines) + "\n", encoding="utf-8") + print(f"wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_linux_backend_verify.sh b/src/imiv/tools/imiv_linux_backend_verify.sh new file mode 100644 index 0000000000..07468075a6 --- /dev/null +++ b/src/imiv/tools/imiv_linux_backend_verify.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +python_bin="${PYTHON:-python3}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --python) + python_bin="$2" + shift 2 + ;; + *) + break + ;; + esac +done + +exec "${python_bin}" "${repo_root}/src/imiv/tools/imiv_backend_verify.py" "$@" diff --git a/src/imiv/tools/imiv_macos_backend_verify.sh b/src/imiv/tools/imiv_macos_backend_verify.sh new file mode 100644 index 0000000000..07468075a6 --- /dev/null +++ b/src/imiv/tools/imiv_macos_backend_verify.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +python_bin="${PYTHON:-python3}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --python) + python_bin="$2" + shift 2 + ;; + *) + break + ;; + esac +done + +exec "${python_bin}" "${repo_root}/src/imiv/tools/imiv_backend_verify.py" "$@" diff --git a/src/imiv/tools/imiv_metal_orientation_regression.py b/src/imiv/tools/imiv_metal_orientation_regression.py new file mode 100644 index 0000000000..23c67bdd8a --- /dev/null +++ b/src/imiv/tools/imiv_metal_orientation_regression.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Regression check for Metal image orientation in imiv.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "MTLCreateSystemDefaultDevice failed", + "failed to create Metal command queue", + "failed to create Metal preview texture", + "Metal preview state is not initialized", + "Metal renderer state is not initialized", + "Metal window/device is not initialized", + "screenshot failed: framebuffer readback failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend( + [ + exe.parent / "imiv_env.sh", + exe.parent.parent / "imiv_env.sh", + ] + ) + candidates.extend( + [ + repo_root / "build" / "imiv_env.sh", + repo_root / "build_u" / "imiv_env.sh", + ] + ) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _image_crop_rect(layout_path: Path) -> tuple[int, int, int, int]: + data = json.loads(layout_path.read_text(encoding="utf-8")) + image_window = None + for window in data.get("windows", []): + if window.get("name") == "Image": + image_window = window + break + if image_window is None: + raise RuntimeError(f"{layout_path.name}: missing Image window") + + viewport_id = image_window.get("viewport_id") + origin_x = float(image_window["rect"]["min"][0]) + origin_y = float(image_window["rect"]["min"][1]) + for window in data.get("windows", []): + if window.get("viewport_id") != viewport_id: + continue + rect = window.get("rect") + if not rect: + continue + origin_x = min(origin_x, float(rect["min"][0])) + origin_y = min(origin_y, float(rect["min"][1])) + + chosen_rect = None + for item in image_window.get("items", []): + if item.get("debug") == "image: Image": + chosen_rect = item.get("rect_clipped") or item.get("rect_full") + break + + if chosen_rect is None: + best_rect = None + best_area = -1.0 + for item in image_window.get("items", []): + rect = item.get("rect_clipped") or item.get("rect_full") + if not rect: + continue + min_v = rect.get("min") + max_v = rect.get("max") + if not min_v or not max_v: + continue + width = max(0.0, float(max_v[0]) - float(min_v[0])) + height = max(0.0, float(max_v[1]) - float(min_v[1])) + area = width * height + if area > best_area: + best_area = area + best_rect = rect + chosen_rect = best_rect + + if chosen_rect is None: + raise RuntimeError(f"{layout_path.name}: missing image rect") + + x0 = int(math.floor(float(chosen_rect["min"][0]) - origin_x)) + 1 + y0 = int(math.floor(float(chosen_rect["min"][1]) - origin_y)) + 1 + x1 = int(math.ceil(float(chosen_rect["max"][0]) - origin_x)) - 2 + y1 = int(math.ceil(float(chosen_rect["max"][1]) - origin_y)) - 2 + if x1 <= x0 or y1 <= y0: + raise RuntimeError(f"{layout_path.name}: invalid crop rect") + return x0, y0, x1, y1 + + +def _crop_image( + oiiotool: Path, source: Path, crop_rect: tuple[int, int, int, int], dest: Path +) -> None: + x0, y0, x1, y1 = crop_rect + subprocess.run( + [ + str(oiiotool), + str(source), + "--cut", + f"{x0},{y0},{x1},{y1}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _resize_image(oiiotool: Path, source: Path, width: int, height: int, dest: Path) -> None: + subprocess.run( + [ + str(oiiotool), + str(source), + "--resize", + f"{width}x{height}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _transform_image( + oiiotool: Path, + source: Path, + width: int, + height: int, + dest: Path, + *, + flip: bool = False, + flop: bool = False, +) -> None: + cmd = [str(oiiotool), str(source), "--resize", f"{width}x{height}"] + if flip: + cmd.append("--flip") + if flop: + cmd.append("--flop") + cmd.extend(["--ch", "R,G,B"]) + cmd.extend(["-o", str(dest)]) + subprocess.run(cmd, check=True) + + +def _read_ppm(path: Path) -> tuple[int, int, bytes]: + with path.open("rb") as handle: + magic = handle.readline().strip() + if magic != b"P6": + raise RuntimeError(f"{path.name}: unsupported PPM format {magic!r}") + + def _next_non_comment() -> bytes: + while True: + line = handle.readline() + if not line: + raise RuntimeError(f"{path.name}: truncated PPM header") + line = line.strip() + if not line or line.startswith(b"#"): + continue + return line + + dims = _next_non_comment().split() + if len(dims) != 2: + raise RuntimeError(f"{path.name}: invalid PPM dimensions") + width = int(dims[0]) + height = int(dims[1]) + max_value = int(_next_non_comment()) + if max_value != 255: + raise RuntimeError(f"{path.name}: unsupported max value {max_value}") + + pixels = handle.read(width * height * 3) + if len(pixels) != width * height * 3: + raise RuntimeError(f"{path.name}: truncated PPM payload") + return width, height, pixels + + +def _mean_abs_diff(lhs: Path, rhs: Path) -> float: + lhs_w, lhs_h, lhs_pixels = _read_ppm(lhs) + rhs_w, rhs_h, rhs_pixels = _read_ppm(rhs) + if (lhs_w, lhs_h) != (rhs_w, rhs_h): + raise RuntimeError( + f"image size mismatch: {lhs.name}={lhs_w}x{lhs_h}, {rhs.name}={rhs_w}x{rhs_h}" + ) + total = 0 + count = len(lhs_pixels) + for a, b in zip(lhs_pixels, rhs_pixels): + total += abs(a - b) + return total / max(1, count) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--env-script", default="", help="Optional shell env setup script") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--out-dir", default="", help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = ( + Path(args.out_dir).resolve() + if args.out_dir + else exe.parent.parent / "imiv_captures" / "metal_orientation_regression" + ) + out_dir.mkdir(parents=True, exist_ok=True) + + image_path = Path(args.open).resolve() + if not image_path.exists(): + print(f"error: image not found: {image_path}", file=sys.stderr) + return 2 + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if found is None: + print(f"error: oiiotool not found: {oiiotool}", file=sys.stderr) + return 2 + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + env_script = ( + Path(args.env_script).resolve() + if args.env_script + else _default_env_script(repo_root, exe) + ) + env = _load_env_from_script(env_script) + env["IMIV_CONFIG_HOME"] = str(out_dir / "cfg") + + screenshot_path = out_dir / "metal_orientation.png" + layout_path = out_dir / "metal_orientation.layout.json" + state_path = out_dir / "metal_orientation.state.json" + log_path = out_dir / "metal_orientation.log" + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + ] + ) + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + print(f"error: runner exited with code {proc.returncode}", file=sys.stderr) + return 1 + + for required in (screenshot_path, layout_path, state_path): + if not required.exists(): + print(f"error: missing output: {required}", file=sys.stderr) + return 1 + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + print(f"error: found runtime error pattern: {pattern}", file=sys.stderr) + return 1 + + crop_rect = _image_crop_rect(layout_path) + crop_path = out_dir / "metal_orientation.crop.ppm" + expected_path = out_dir / "metal_orientation.expected.ppm" + flop_path = out_dir / "metal_orientation.flop.ppm" + flip_path = out_dir / "metal_orientation.flip.ppm" + flopflip_path = out_dir / "metal_orientation.flopflip.ppm" + + _crop_image(oiiotool, screenshot_path, crop_rect, crop_path) + width, height, _ = _read_ppm(crop_path) + if width <= 0 or height <= 0: + print("error: invalid cropped image size", file=sys.stderr) + return 1 + _resize_image(oiiotool, image_path, width, height, expected_path) + _transform_image(oiiotool, image_path, width, height, flop_path, flop=True) + _transform_image(oiiotool, image_path, width, height, flip_path, flip=True) + _transform_image( + oiiotool, + image_path, + width, + height, + flopflip_path, + flip=True, + flop=True, + ) + + scores = { + "expected": _mean_abs_diff(crop_path, expected_path), + "flop": _mean_abs_diff(crop_path, flop_path), + "flip": _mean_abs_diff(crop_path, flip_path), + "flopflip": _mean_abs_diff(crop_path, flopflip_path), + } + best_name, best_score = min(scores.items(), key=lambda item: item[1]) + if best_name != "expected": + print( + "error: Metal orientation mismatch; best match was " + f"{best_name} (scores: {scores})", + file=sys.stderr, + ) + return 1 + + wrong_best = min( + value for key, value in scores.items() if key != "expected" + ) + if not (scores["expected"] < wrong_best): + print( + "error: Metal orientation comparison was inconclusive " + f"(scores: {scores})", + file=sys.stderr, + ) + return 1 + + print( + "ok: Metal orientation regression outputs are in " + f"{out_dir} (scores: {scores})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_metal_sampling_regression.py b/src/imiv/tools/imiv_metal_sampling_regression.py new file mode 100644 index 0000000000..d14fe281bb --- /dev/null +++ b/src/imiv/tools/imiv_metal_sampling_regression.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the shared sampling regression.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + script = repo_root / "src" / "imiv" / "tools" / "imiv_sampling_regression.py" + cmd = [sys.executable, str(script), *sys.argv[1:]] + return subprocess.call(cmd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_metal_screenshot_regression.py b/src/imiv/tools/imiv_metal_screenshot_regression.py new file mode 100644 index 0000000000..46fb7ce95b --- /dev/null +++ b/src/imiv/tools/imiv_metal_screenshot_regression.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Basic screenshot smoke test for the Metal imiv backend.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "MTLCreateSystemDefaultDevice failed", + "failed to create Metal command queue", + "failed to create Metal preview texture", + "Metal preview state is not initialized", + "Metal renderer state is not initialized", + "Metal window/device is not initialized", + "screenshot failed: framebuffer readback failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend( + [ + exe.parent / "imiv_env.sh", + exe.parent.parent / "imiv_env.sh", + ] + ) + candidates.extend( + [ + repo_root / "build" / "imiv_env.sh", + repo_root / "build_u" / "imiv_env.sh", + ] + ) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--env-script", default="", help="Optional shell env setup script") + ap.add_argument("--out-dir", default="", help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = ( + Path(args.out_dir).resolve() + if args.out_dir + else exe.parent.parent / "imiv_captures" / "metal_screenshot_regression" + ) + out_dir.mkdir(parents=True, exist_ok=True) + + image_path = Path(args.open).resolve() + if not image_path.exists(): + print(f"error: image not found: {image_path}", file=sys.stderr) + return 2 + + env_script = ( + Path(args.env_script).resolve() + if args.env_script + else _default_env_script(repo_root, exe) + ) + env = _load_env_from_script(env_script) + env["IMIV_CONFIG_HOME"] = str(out_dir / "cfg") + + screenshot_path = out_dir / "metal_smoke.png" + layout_path = out_dir / "metal_smoke.layout.json" + state_path = out_dir / "metal_smoke.state.json" + log_path = out_dir / "metal_smoke.log" + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + ] + ) + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + print(f"error: runner exited with code {proc.returncode}", file=sys.stderr) + return 1 + + for required in (screenshot_path, layout_path, state_path): + if not required.exists(): + print(f"error: missing output: {required}", file=sys.stderr) + return 1 + if screenshot_path.stat().st_size <= 0: + print(f"error: screenshot file is empty: {screenshot_path}", file=sys.stderr) + return 1 + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + print(f"error: found runtime error pattern: {pattern}", file=sys.stderr) + return 1 + + layout = json.loads(layout_path.read_text(encoding="utf-8")) + if not any(window.get("name") == "Image" for window in layout.get("windows", [])): + print("error: layout dump missing Image window", file=sys.stderr) + return 1 + + state = json.loads(state_path.read_text(encoding="utf-8")) + current_path = state.get("image_path") or "" + if not current_path: + print("error: state dump missing image_path", file=sys.stderr) + return 1 + + print(f"ok: Metal screenshot regression outputs are in {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_metal_smoke_regression.py b/src/imiv/tools/imiv_metal_smoke_regression.py new file mode 100644 index 0000000000..10d2574889 --- /dev/null +++ b/src/imiv/tools/imiv_metal_smoke_regression.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Basic smoke regression for the Metal imiv backend. + +This runner intentionally avoids screenshot/readback so it can validate the +Metal backend before `renderer_screen_capture()` is implemented. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "MTLCreateSystemDefaultDevice failed", + "failed to create Metal command queue", + "failed to create Metal preview texture", + "Metal preview state is not initialized", + "Metal renderer state is not initialized", + "Metal window/device is not initialized", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend( + [ + exe.parent / "imiv_env.sh", + exe.parent.parent / "imiv_env.sh", + ] + ) + candidates.extend( + [ + repo_root / "build" / "imiv_env.sh", + repo_root / "build_u" / "imiv_env.sh", + ] + ) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run_case( + repo_root: Path, + runner: Path, + exe: Path, + cwd: Path, + backend: str, + out_dir: Path, + name: str, + env: dict[str, str], + *, + open_path: Path | None = None, + extra_args: list[str] | None = None, + trace: bool = False, + want_layout: bool = True, +) -> tuple[dict, dict | None, str]: + state_path = out_dir / f"{name}.state.json" + layout_path = out_dir / f"{name}.layout.json" + log_path = out_dir / f"{name}.log" + config_home = out_dir / f"cfg_{name}" + shutil.rmtree(config_home, ignore_errors=True) + config_home.mkdir(parents=True, exist_ok=True) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if backend: + cmd.extend(["--backend", backend]) + cmd.extend( + [ + "--state-json-out", + str(state_path), + "--post-action-delay-frames", + "2", + ] + ) + if want_layout: + cmd.extend( + [ + "--layout-json-out", + str(layout_path), + "--layout-items", + ] + ) + if open_path is not None: + cmd.extend(["--open", str(open_path)]) + if trace: + cmd.append("--trace") + if extra_args: + cmd.extend(extra_args) + + case_env = dict(env) + case_env["IMIV_CONFIG_HOME"] = str(config_home) + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=case_env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: runner exited with code {proc.returncode}") + if not state_path.exists(): + raise RuntimeError(f"{name}: missing state output") + if want_layout and not layout_path.exists(): + raise RuntimeError(f"{name}: missing layout output") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + raise RuntimeError(f"{name}: found runtime error pattern: {pattern}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + layout = None + if want_layout: + layout = json.loads(layout_path.read_text(encoding="utf-8")) + return state, layout, log_text + + +def _has_window(layout: dict, name: str) -> bool: + return any(window.get("name") == name for window in layout.get("windows", [])) + + +def _area_probe_initialized(state: dict) -> bool: + lines = state.get("area_probe_lines", []) + if not lines: + return False + for line in lines: + if line == "Area Probe:": + continue + if "-----" in line: + return False + return True + + +def _selection_has_area(state: dict) -> bool: + bounds = state.get("selection_bounds", [0, 0, 0, 0]) + if len(bounds) != 4: + return False + x0, y0, x1, y1 = bounds + return x1 > x0 and y1 > y0 + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default="", + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default="", help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + if args.out_dir: + out_dir = Path(args.out_dir).resolve() + else: + out_dir = exe.parent.parent / "imiv_captures" / "metal_smoke_regression" + out_dir.mkdir(parents=True, exist_ok=True) + + open_path = Path(args.open).resolve() + if not open_path.exists(): + print(f"error: image not found: {open_path}", file=sys.stderr) + return 2 + + env_script = ( + Path(args.env_script).resolve() + if args.env_script + else _default_env_script(repo_root, exe) + ) + env = _load_env_from_script(env_script) + + startup_state, startup_layout, _ = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + out_dir, + "startup_empty", + env, + trace=args.trace, + ) + if startup_state.get("image_loaded"): + print("error: startup_empty unexpectedly loaded an image", file=sys.stderr) + return 1 + if startup_state.get("loaded_image_count") not in (0, None): + print("error: startup_empty has non-zero loaded_image_count", file=sys.stderr) + return 1 + if not _has_window(startup_layout, "Image"): + print("error: startup_empty layout missing Image window", file=sys.stderr) + return 1 + + open_state, open_layout, _ = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + out_dir, + "open_image", + env, + open_path=open_path, + trace=args.trace, + ) + if not open_state.get("image_loaded"): + print("error: open_image did not load image", file=sys.stderr) + return 1 + if not open_state.get("image_path"): + print("error: open_image state missing image_path", file=sys.stderr) + return 1 + image_size = open_state.get("image_size", [0, 0]) + if len(image_size) != 2 or image_size[0] <= 0 or image_size[1] <= 0: + print("error: open_image reported invalid image_size", file=sys.stderr) + return 1 + if not _has_window(open_layout, "Image"): + print("error: open_image layout missing Image window", file=sys.stderr) + return 1 + + area_state, _, _ = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + out_dir, + "area_sample_drag", + env, + open_path=open_path, + extra_args=[ + "--key-chord", + "ctrl+a", + "--mouse-pos-image-rel", + "0.30", + "0.30", + "--mouse-drag", + "120", + "96", + "--mouse-drag-button", + "0", + ], + trace=args.trace, + want_layout=False, + ) + if not area_state.get("selection_active"): + print("error: area_sample_drag did not leave a selection", file=sys.stderr) + return 1 + if not _selection_has_area(area_state): + print("error: area_sample_drag selection_bounds has no area", file=sys.stderr) + return 1 + if not _area_probe_initialized(area_state): + print("error: area_sample_drag did not initialize area_probe_lines", file=sys.stderr) + return 1 + + summary = { + "backend": "metal", + "cases": { + "startup_empty": str(out_dir / "startup_empty.log"), + "open_image": str(out_dir / "open_image.log"), + "area_sample_drag": str(out_dir / "area_sample_drag.log"), + }, + } + (out_dir / "summary.json").write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"ok: Metal smoke regression outputs are in {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_mouse_interaction_regression.py b/src/imiv/tools/imiv_mouse_interaction_regression.py new file mode 100644 index 0000000000..b634e1c6b3 --- /dev/null +++ b/src/imiv/tools/imiv_mouse_interaction_regression.py @@ -0,0 +1,238 @@ +#!/ usr / bin / env python3 +"""Regression check for imiv drag navigation and area-sample gating.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def _run_case( + python_exe: str, + runner: Path, + repo_root: Path, + out_dir: Path, + image_path: Path, + name: str, + extra_args: list[str], + bin_path: str, + cwd_path: str, + trace: bool, +) -> dict: + screenshot_out = out_dir / f"{name}.png" + state_out = out_dir / f"{name}.json" + junit_out = out_dir / f"{name}.junit.xml" + cmd = [ + python_exe, + str(runner), + "--open", + str(image_path), + "--key-chord", + "ctrl+0", + "--mouse-pos-image-rel", + "0.5", + "0.5", + "--screenshot-out", + str(screenshot_out), + "--state-json-out", + str(state_out), + "--junit-out", + str(junit_out), + ] + if bin_path: + cmd.extend(["--bin", bin_path]) + if cwd_path: + cmd.extend(["--cwd", cwd_path]) + if trace: + cmd.append("--trace") + cmd.extend(extra_args) + + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(repo_root), check=True) + + with state_out.open("r", encoding="utf-8") as handle: + state = json.load(handle) + state["screenshot_sha256"] = _sha256(screenshot_out) + state["screenshot_path"] = str(screenshot_out) + state["state_path"] = str(state_out) + return state + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + default_out = repo_root / "build_u" / "imiv_captures" / "mouse_interaction_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--out-dir", default=str(default_out), help="Output directory") + ap.add_argument("--bin", default="", help="Optional imiv binary override") + ap.add_argument("--cwd", default="", help="Optional imiv working directory override") + ap.add_argument("--trace", action="store_true", help="Enable runner trace") + args = ap.parse_args() + + image_path = Path(args.open).resolve() + if not image_path.exists(): + return _fail(f"image not found: {image_path}") + + out_dir = Path(args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + + baseline = _run_case( + sys.executable, + runner, + repo_root, + out_dir, + image_path, + "baseline_ctrl0", + [], + args.bin, + args.cwd, + args.trace, + ) + left_drag = _run_case( + sys.executable, + runner, + repo_root, + out_dir, + image_path, + "left_drag_pan", + ["--mouse-drag-button", "0", "--mouse-drag", "120", "80"], + args.bin, + args.cwd, + args.trace, + ) + right_drag_zoom_in = _run_case( + sys.executable, + runner, + repo_root, + out_dir, + image_path, + "right_drag_zoom_in", + ["--mouse-drag-button", "1", "--mouse-drag", "0", "120"], + args.bin, + args.cwd, + args.trace, + ) + right_drag_zoom_out = _run_case( + sys.executable, + runner, + repo_root, + out_dir, + image_path, + "right_drag_zoom_out", + ["--mouse-drag-button", "1", "--mouse-drag", "0", "-120"], + args.bin, + args.cwd, + args.trace, + ) + middle_drag = _run_case( + sys.executable, + runner, + repo_root, + out_dir, + image_path, + "middle_drag_pan", + ["--mouse-drag-button", "2", "--mouse-drag", "120", "80"], + args.bin, + args.cwd, + args.trace, + ) + + for name, state in ( + ("baseline", baseline), + ("left_drag", left_drag), + ("right_drag_zoom_in", right_drag_zoom_in), + ("right_drag_zoom_out", right_drag_zoom_out), + ("middle_drag", middle_drag), + ): + if not state.get("image_loaded", False): + return _fail(f"{name}: image not loaded") + + baseline_zoom = float(baseline["zoom"]) + left_zoom = float(left_drag["zoom"]) + right_zoom_in = float(right_drag_zoom_in["zoom"]) + right_zoom_out = float(right_drag_zoom_out["zoom"]) + middle_zoom = float(middle_drag["zoom"]) + if abs(baseline_zoom - 1.0) > 1.0e-3: + return _fail(f"baseline zoom expected 1.0, got {baseline_zoom:.6f}") + if abs(left_zoom - baseline_zoom) > 1.0e-3: + return _fail( + f"left drag changed zoom unexpectedly: baseline={baseline_zoom:.6f}, left={left_zoom:.6f}" + ) + if right_zoom_in <= baseline_zoom + 1.0e-3: + return _fail( + "right drag zoom-in did not increase zoom: " + f"baseline={baseline_zoom:.6f}, right_in={right_zoom_in:.6f}" + ) + if right_zoom_out >= baseline_zoom - 1.0e-3: + return _fail( + "right drag zoom-out did not decrease zoom: " + f"baseline={baseline_zoom:.6f}, right_out={right_zoom_out:.6f}" + ) + if abs(middle_zoom - baseline_zoom) > 1.0e-3: + return _fail( + f"middle drag changed zoom unexpectedly: baseline={baseline_zoom:.6f}, middle={middle_zoom:.6f}" + ) + + baseline_scroll = baseline["scroll"] + left_scroll = left_drag["scroll"] + middle_scroll = middle_drag["scroll"] + left_dx = abs(float(left_scroll[0]) - float(baseline_scroll[0])) + left_dy = abs(float(left_scroll[1]) - float(baseline_scroll[1])) + if max(left_dx, left_dy) <= 1.0: + return _fail( + "left drag did not change scroll enough: " + f"baseline={baseline_scroll}, left={left_scroll}" + ) + scroll_dx = abs(float(middle_scroll[0]) - float(baseline_scroll[0])) + scroll_dy = abs(float(middle_scroll[1]) - float(baseline_scroll[1])) + if max(scroll_dx, scroll_dy) <= 1.0: + return _fail( + "middle drag did not change scroll enough: " + f"baseline={baseline_scroll}, middle={middle_scroll}" + ) + + if left_drag.get("selection_active", False): + return _fail("left drag created a selection while area sample was off") + if left_drag["screenshot_sha256"] == baseline["screenshot_sha256"]: + return _fail("left drag screenshot matches baseline") + if right_drag_zoom_in["screenshot_sha256"] == baseline["screenshot_sha256"]: + return _fail("right drag zoom-in screenshot matches baseline") + if right_drag_zoom_out["screenshot_sha256"] == baseline["screenshot_sha256"]: + return _fail("right drag zoom-out screenshot matches baseline") + if middle_drag["screenshot_sha256"] == baseline["screenshot_sha256"]: + return _fail("middle drag screenshot matches baseline") + + print("baseline zoom:", f"{baseline_zoom:.6f}", "scroll:", baseline_scroll) + print("left drag zoom:", f"{left_zoom:.6f}", "scroll:", left_scroll) + print("right drag zoom in:", f"{right_zoom_in:.6f}") + print("right drag zoom out:", f"{right_zoom_out:.6f}") + print("middle drag zoom:", f"{middle_zoom:.6f}", "scroll:", middle_scroll) + print("artifacts:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_multiview_regression.py b/src/imiv/tools/imiv_multiview_regression.py new file mode 100644 index 0000000000..7ecb20bf4b --- /dev/null +++ b/src/imiv/tools/imiv_multiview_regression.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Regression check for opening a second image view window.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + step = ET.SubElement(root, "step") + step.set("name", "new_view") + step.set("key_chord", "ctrl+shift+n") + step.set("state", "true") + step.set("post_action_delay_frames", "4") + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "multiview_regression" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image path to open") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + runner = runner.resolve() + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + scenario_path = out_dir / "multiview.scenario.xml" + state_path = runtime_dir / "new_view.state.json" + log_path = out_dir / "multiview.log" + + shutil.rmtree(runtime_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + _write_scenario(scenario_path, os.path.relpath(runtime_dir, cwd)) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + shutil.rmtree(config_home, ignore_errors=True) + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(Path(args.open).expanduser().resolve()), + "--scenario", + str(scenario_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not state_path.exists(): + return _fail(f"state output not found: {state_path}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + if int(state.get("view_count", 0)) < 2: + return _fail("state does not report multiple image views") + if not state.get("image_loaded", False): + return _fail("state does not report a loaded image after opening a new view") + if int(state.get("active_view_id", 0)) <= 1: + return _fail("state does not report the new image view as active") + if not bool(state.get("active_view_docked", False)): + return _fail("state does not report the new image view as docked") + + print(f"state: {state_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_ocio_config_source_regression.py b/src/imiv/tools/imiv_ocio_config_source_regression.py new file mode 100644 index 0000000000..b838e5b540 --- /dev/null +++ b/src/imiv/tools/imiv_ocio_config_source_regression.py @@ -0,0 +1,976 @@ +#!/usr/bin/env python3 +"""Regression check for OCIO config-source selection and builtin fallback.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + + +ERROR_PATTERNS = ( + "VUID-", + "fatal Vulkan error", + "error: imiv exited with code", +) + + +SOURCE_GLOBAL = 0 +SOURCE_BUILTIN = 1 +SOURCE_USER = 2 + + +def _default_case_timeout() -> float: + return 180.0 if os.name == "nt" else 60.0 + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + Path("/mnt/f/UBc/Release/bin/oiiotool"), + Path("/mnt/f/UBc/Debug/bin/oiiotool"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "bin" / "idiff", + Path("/mnt/f/UBc/Release/bin/idiff"), + Path("/mnt/f/UBc/Debug/bin/idiff"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _default_ocio_config(repo_root: Path) -> str: + return "ocio://default" + + +def _resolve_ocio_config_argument(value: str) -> str: + candidate = str(value).strip() + if candidate.startswith("ocio://"): + return candidate + return str(Path(candidate).expanduser().resolve()) + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + loaded: dict[str, str] = {} + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + loaded[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + env.update(loaded) + return env + + +def _json_load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _write_prefs( + config_home: Path, + *, + use_ocio: bool, + ocio_config_source: int, + ocio_display: str, + ocio_view: str, + ocio_image_color_space: str, + ocio_user_config_path: str = "", +) -> Path: + prefs_dir = config_home / "OpenImageIO" / "imiv" + prefs_dir.mkdir(parents=True, exist_ok=True) + prefs_path = prefs_dir / "imiv.inf" + prefs_text = ( + "[ImivApp][State]\n" + f"use_ocio={1 if use_ocio else 0}\n" + f"ocio_config_source={ocio_config_source}\n" + f"ocio_display={ocio_display}\n" + f"ocio_view={ocio_view}\n" + f"ocio_image_color_space={ocio_image_color_space}\n" + f"ocio_user_config_path={ocio_user_config_path}\n" + ) + prefs_path.write_text(prefs_text, encoding="utf-8") + return prefs_path + + +def _generate_probe_image(repo_root: Path, oiiotool: Path, image_path: Path) -> None: + image_path.parent.mkdir(parents=True, exist_ok=True) + chart_image = repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr" + if chart_image.exists(): + shutil.copyfile(chart_image, image_path) + return + cmd = [ + str(oiiotool), + "--create", + "96x48", + "3", + "--d", + "float", + "--fill:color=4.0,0.5,0.1", + "0,0,96,48", + "--attrib", + "oiio:ColorSpace", + "ACEScg", + "-o", + str(image_path), + ] + subprocess.run(cmd, check=True) + + +def _run_case( + repo_root: Path, + runner: Path, + exe: Path, + cwd: Path, + backend: str, + env: dict[str, str], + image_path: Path, + out_dir: Path, + name: str, + trace: bool, + case_timeout: float, + *, + capture_screenshot: bool = True, + capture_layout: bool = True, + capture_state: bool = True, +) -> tuple[Optional[Path], Optional[Path], Optional[Path], Path]: + screenshot_path = out_dir / f"{name}.png" if capture_screenshot else None + layout_path = out_dir / f"{name}.json" if capture_layout else None + state_path = out_dir / f"{name}.state.json" if capture_state else None + log_path = out_dir / f"{name}.log" + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if backend: + cmd.extend(["--backend", backend]) + cmd.extend(["--open", str(image_path)]) + if screenshot_path is not None: + cmd.extend(["--screenshot-out", str(screenshot_path)]) + if layout_path is not None: + cmd.extend(["--layout-json-out", str(layout_path), "--layout-items"]) + if state_path is not None: + cmd.extend(["--state-json-out", str(state_path)]) + if trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=case_timeout, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: runner exited with code {proc.returncode}") + if screenshot_path is not None and not screenshot_path.exists(): + raise RuntimeError(f"{name}: screenshot not written") + if layout_path is not None and not layout_path.exists(): + raise RuntimeError(f"{name}: layout json not written") + if state_path is not None and not state_path.exists(): + raise RuntimeError(f"{name}: state json not written") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + raise RuntimeError(f"{name}: found runtime error pattern: {pattern}") + return screenshot_path, layout_path, state_path, log_path + + +def _image_crop_rect(layout_path: Path) -> tuple[int, int, int, int]: + data = json.loads(layout_path.read_text(encoding="utf-8")) + image_window = None + for window in data.get("windows", []): + if window.get("name") == "Image": + image_window = window + break + if image_window is None: + raise RuntimeError(f"{layout_path.name}: missing Image window") + + viewport_id = image_window.get("viewport_id") + origin_x = float(image_window["rect"]["min"][0]) + origin_y = float(image_window["rect"]["min"][1]) + for window in data.get("windows", []): + if window.get("viewport_id") != viewport_id: + continue + rect = window.get("rect") + if not rect: + continue + origin_x = min(origin_x, float(rect["min"][0])) + origin_y = min(origin_y, float(rect["min"][1])) + + items = image_window.get("items", []) + best_rect = None + best_area = -1.0 + for item in items: + rect = item.get("rect_clipped") or item.get("rect_full") + if not rect: + continue + min_v = rect.get("min") + max_v = rect.get("max") + if not min_v or not max_v: + continue + width = max(0.0, float(max_v[0]) - float(min_v[0])) + height = max(0.0, float(max_v[1]) - float(min_v[1])) + area = width * height + if area > best_area: + best_area = area + best_rect = rect + + if best_rect is None: + rect = image_window.get("rect") + if not rect: + raise RuntimeError(f"{layout_path.name}: missing crop rect") + best_rect = rect + + x0 = int(math.floor(float(best_rect["min"][0]) - origin_x)) + 1 + y0 = int(math.floor(float(best_rect["min"][1]) - origin_y)) + 1 + x1 = int(math.ceil(float(best_rect["max"][0]) - origin_x)) - 2 + y1 = int(math.ceil(float(best_rect["max"][1]) - origin_y)) - 2 + if x1 <= x0 or y1 <= y0: + raise RuntimeError(f"{layout_path.name}: invalid crop rect") + return x0, y0, x1, y1 + + +def _crop_image( + oiiotool: Path, source: Path, crop_rect: tuple[int, int, int, int], dest: Path +) -> None: + x0, y0, x1, y1 = crop_rect + cmd = [ + str(oiiotool), + str(source), + "--crop", + f"{x0},{y0},{x1},{y1}", + "-o", + str(dest), + ] + subprocess.run(cmd, check=True) + + +def _write_rgb_ppm( + oiiotool: Path, source: Path, width: int, height: int, dest: Path +) -> None: + subprocess.run( + [ + str(oiiotool), + str(source), + "--resize", + f"{width}x{height}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _read_ppm(path: Path) -> tuple[int, int, bytes]: + with path.open("rb") as handle: + magic = handle.readline().strip() + if magic != b"P6": + raise RuntimeError(f"{path.name}: unsupported PPM format {magic!r}") + + def _next_non_comment() -> bytes: + while True: + line = handle.readline() + if not line: + raise RuntimeError(f"{path.name}: truncated PPM header") + line = line.strip() + if not line or line.startswith(b"#"): + continue + return line + + dims = _next_non_comment().split() + if len(dims) != 2: + raise RuntimeError(f"{path.name}: invalid PPM dimensions") + width = int(dims[0]) + height = int(dims[1]) + max_value = int(_next_non_comment()) + if max_value != 255: + raise RuntimeError(f"{path.name}: unsupported max value {max_value}") + + pixels = handle.read(width * height * 3) + if len(pixels) != width * height * 3: + raise RuntimeError(f"{path.name}: truncated PPM payload") + return width, height, pixels + + +def _mean_abs_diff(lhs: Path, rhs: Path) -> float: + lhs_w, lhs_h, lhs_pixels = _read_ppm(lhs) + rhs_w, rhs_h, rhs_pixels = _read_ppm(rhs) + if (lhs_w, lhs_h) != (rhs_w, rhs_h): + raise RuntimeError( + f"image size mismatch: {lhs.name}={lhs_w}x{lhs_h}, {rhs.name}={rhs_w}x{rhs_h}" + ) + total = 0 + count = len(lhs_pixels) + for a, b in zip(lhs_pixels, rhs_pixels): + total += abs(a - b) + return total / max(1, count) + + +def _normalized_rgb_diff( + oiiotool: Path, lhs: Path, rhs: Path, work_dir: Path, stem: str +) -> float: + lhs_ppm = work_dir / f"{stem}.lhs.ppm" + rhs_ppm = work_dir / f"{stem}.rhs.ppm" + _write_rgb_ppm(oiiotool, lhs, 256, 256, lhs_ppm) + _write_rgb_ppm(oiiotool, rhs, 256, 256, rhs_ppm) + return _mean_abs_diff(lhs_ppm, rhs_ppm) + + +def _validate_ocio_state( + name: str, + state_path: Path, + *, + image_path: Path, + expected_use_ocio: bool, + expected_requested_source: str, + expected_resolved_source: str, + expected_fallback_applied: bool, + expected_resolved_config_path: str, +) -> dict: + state = _json_load(state_path) + if not bool(state.get("image_loaded")): + raise RuntimeError(f"{name}: image was not loaded") + if str(state.get("image_path", "")).strip() != str(image_path): + raise RuntimeError( + f"{name}: unexpected image path {state.get('image_path', '')!r}" + ) + + ocio = state.get("ocio") + if not isinstance(ocio, dict): + raise RuntimeError(f"{name}: missing ocio state block") + if bool(ocio.get("use_ocio")) != expected_use_ocio: + raise RuntimeError( + f"{name}: unexpected use_ocio={ocio.get('use_ocio')!r}" + ) + if str(ocio.get("requested_source", "")).strip() != expected_requested_source: + raise RuntimeError( + f"{name}: unexpected requested_source=" + f"{ocio.get('requested_source', '')!r}" + ) + if str(ocio.get("resolved_source", "")).strip() != expected_resolved_source: + raise RuntimeError( + f"{name}: unexpected resolved_source=" + f"{ocio.get('resolved_source', '')!r}" + ) + if bool(ocio.get("fallback_applied")) != expected_fallback_applied: + raise RuntimeError( + f"{name}: unexpected fallback_applied=" + f"{ocio.get('fallback_applied')!r}" + ) + if ( + str(ocio.get("resolved_config_path", "")).strip() + != expected_resolved_config_path + ): + raise RuntimeError( + f"{name}: unexpected resolved_config_path=" + f"{ocio.get('resolved_config_path', '')!r}" + ) + if not bool(ocio.get("menu_data_ok")): + raise RuntimeError( + f"{name}: OCIO menu data unavailable: " + f"{str(ocio.get('menu_error', '')).strip()}" + ) + + displays = ocio.get("available_displays") + if not isinstance(displays, list) or not displays: + raise RuntimeError(f"{name}: available_displays is empty") + resolved_display = str(ocio.get("resolved_display", "")).strip() + if not resolved_display: + raise RuntimeError(f"{name}: resolved_display is empty") + if resolved_display not in [str(item).strip() for item in displays]: + raise RuntimeError( + f"{name}: resolved_display {resolved_display!r} is not in " + "available_displays" + ) + + views_by_display = ocio.get("views_by_display") + if not isinstance(views_by_display, dict): + raise RuntimeError(f"{name}: missing views_by_display") + display_views = views_by_display.get(resolved_display) + if not isinstance(display_views, list) or not display_views: + raise RuntimeError( + f"{name}: no views advertised for display {resolved_display!r}" + ) + resolved_view = str(ocio.get("resolved_view", "")).strip() + if not resolved_view: + raise RuntimeError(f"{name}: resolved_view is empty") + if resolved_view not in [str(item).strip() for item in display_views]: + raise RuntimeError( + f"{name}: resolved_view {resolved_view!r} is not valid for " + f"display {resolved_display!r}" + ) + + color_spaces = ocio.get("available_image_color_spaces") + if not isinstance(color_spaces, list) or not color_spaces: + raise RuntimeError(f"{name}: available_image_color_spaces is empty") + return state + + +if __name__ == "__main__": + repo_root = Path(__file__).resolve().parents[3] + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_out = repo_root / "build_u" / "imiv_captures" / "ocio_config_source_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument("--out-dir", default=str(default_out), help="Output directory") + ap.add_argument("--ocio-config", default=str(_default_ocio_config(repo_root)), help="OCIO config to use") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool path") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff path") + ap.add_argument( + "--case-timeout", + type=float, + default=_default_case_timeout(), + help="Per-case GUI runner timeout in seconds", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + raise SystemExit(_fail(f"binary not found: {exe}")) + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + + runner = default_runner.resolve() + if not runner.exists(): + raise SystemExit(_fail(f"runner not found: {runner}")) + + ocio_config = _resolve_ocio_config_argument(args.ocio_config) + if not ocio_config.startswith("ocio://"): + ocio_config_path = Path(ocio_config) + if not ocio_config_path.exists(): + raise SystemExit(_fail(f"OCIO config not found: {ocio_config_path}")) + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if not found: + raise SystemExit(_fail(f"oiiotool not found: {oiiotool}")) + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + idiff = Path(args.idiff).expanduser() + if not idiff.exists(): + found = shutil.which(str(idiff)) + if not found: + raise SystemExit(_fail(f"idiff not found: {idiff}")) + idiff = Path(found) + idiff = idiff.resolve() + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + image_path = out_dir / "ocio_source_fixture.exr" + _generate_probe_image(repo_root, oiiotool, image_path) + + if ocio_config.startswith("ocio://"): + external_display = "default" + external_view = "default" + external_color_space = "auto" + else: + external_display = "sRGB - Display" + external_view = "Un-tone-mapped" + external_color_space = "ACEScg" + + builtin_display = "default" + builtin_view = "default" + builtin_color_space = "auto" + + base_env = _load_env_from_script(Path(args.env_script).expanduser()) + base_env.pop("OCIO", None) + + baseline_cfg = out_dir / "cfg_baseline" + _write_prefs( + baseline_cfg, + use_ocio=False, + ocio_config_source=SOURCE_GLOBAL, + ocio_display=builtin_display, + ocio_view=builtin_view, + ocio_image_color_space=builtin_color_space, + ) + baseline_env = dict(base_env) + baseline_env["IMIV_CONFIG_HOME"] = str(baseline_cfg) + + global_cfg = out_dir / "cfg_global" + _write_prefs( + global_cfg, + use_ocio=True, + ocio_config_source=SOURCE_GLOBAL, + ocio_display=external_display, + ocio_view=external_view, + ocio_image_color_space=external_color_space, + ) + global_env = dict(base_env) + global_env["IMIV_CONFIG_HOME"] = str(global_cfg) + global_env["OCIO"] = ocio_config + + global_default_cfg = out_dir / "cfg_global_default" + _write_prefs( + global_default_cfg, + use_ocio=True, + ocio_config_source=SOURCE_GLOBAL, + ocio_display="default", + ocio_view="default", + ocio_image_color_space=external_color_space, + ) + global_default_env = dict(base_env) + global_default_env["IMIV_CONFIG_HOME"] = str(global_default_cfg) + global_default_env["OCIO"] = ocio_config + + global_invalid_cfg = out_dir / "cfg_global_invalid_selection" + _write_prefs( + global_invalid_cfg, + use_ocio=True, + ocio_config_source=SOURCE_GLOBAL, + ocio_display="Missing Display", + ocio_view="Missing View", + ocio_image_color_space=external_color_space, + ) + global_invalid_env = dict(base_env) + global_invalid_env["IMIV_CONFIG_HOME"] = str(global_invalid_cfg) + global_invalid_env["OCIO"] = ocio_config + + global_builtin_cfg = out_dir / "cfg_global_builtin" + _write_prefs( + global_builtin_cfg, + use_ocio=True, + ocio_config_source=SOURCE_GLOBAL, + ocio_display=builtin_display, + ocio_view=builtin_view, + ocio_image_color_space=builtin_color_space, + ) + global_builtin_env = dict(base_env) + global_builtin_env["IMIV_CONFIG_HOME"] = str(global_builtin_cfg) + + builtin_cfg = out_dir / "cfg_builtin" + _write_prefs( + builtin_cfg, + use_ocio=True, + ocio_config_source=SOURCE_BUILTIN, + ocio_display=builtin_display, + ocio_view=builtin_view, + ocio_image_color_space=builtin_color_space, + ) + builtin_env = dict(base_env) + builtin_env["IMIV_CONFIG_HOME"] = str(builtin_cfg) + + user_cfg = out_dir / "cfg_user" + _write_prefs( + user_cfg, + use_ocio=True, + ocio_config_source=SOURCE_USER, + ocio_display=external_display, + ocio_view=external_view, + ocio_image_color_space=external_color_space, + ocio_user_config_path=ocio_config, + ) + user_env = dict(base_env) + user_env["IMIV_CONFIG_HOME"] = str(user_cfg) + + user_missing_builtin_cfg = out_dir / "cfg_user_missing_builtin" + _write_prefs( + user_missing_builtin_cfg, + use_ocio=True, + ocio_config_source=SOURCE_USER, + ocio_display=builtin_display, + ocio_view=builtin_view, + ocio_image_color_space=builtin_color_space, + ocio_user_config_path=str(out_dir / "missing_user_config.ocio"), + ) + user_missing_builtin_env = dict(base_env) + user_missing_builtin_env["IMIV_CONFIG_HOME"] = str(user_missing_builtin_cfg) + + expected_builtin_config_path = "ocio://default" + expected_external_config_path = ocio_config + + try: + baseline_png, baseline_layout, baseline_state, baseline_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + baseline_env, + image_path, + out_dir, + "baseline", + args.trace, + args.case_timeout, + ) + global_png, global_layout, global_state, global_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + global_env, + image_path, + out_dir, + "global", + args.trace, + args.case_timeout, + ) + global_default_png, global_default_layout, global_default_state, global_default_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + global_default_env, + image_path, + out_dir, + "global_default", + args.trace, + args.case_timeout, + ) + global_invalid_png, global_invalid_layout, global_invalid_state, global_invalid_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + global_invalid_env, + image_path, + out_dir, + "global_invalid_selection", + args.trace, + args.case_timeout, + capture_screenshot=False, + capture_layout=False, + capture_state=True, + ) + global_builtin_png, global_builtin_layout, global_builtin_state, global_builtin_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + global_builtin_env, + image_path, + out_dir, + "global_builtin_fallback", + args.trace, + args.case_timeout, + ) + builtin_png, builtin_layout, builtin_state, builtin_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + builtin_env, + image_path, + out_dir, + "builtin", + args.trace, + args.case_timeout, + ) + user_png, user_layout, user_state, user_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + user_env, + image_path, + out_dir, + "user", + args.trace, + args.case_timeout, + ) + user_missing_builtin_png, user_missing_builtin_layout, user_missing_builtin_state, user_missing_builtin_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + user_missing_builtin_env, + image_path, + out_dir, + "user_missing_builtin", + args.trace, + args.case_timeout, + ) + except (subprocess.SubprocessError, RuntimeError) as exc: + raise SystemExit(_fail(str(exc))) + + try: + baseline_state_data = _validate_ocio_state( + "baseline", + baseline_state, + image_path=image_path, + expected_use_ocio=False, + expected_requested_source="global", + expected_resolved_source="builtin", + expected_fallback_applied=True, + expected_resolved_config_path=expected_builtin_config_path, + ) + global_state_data = _validate_ocio_state( + "global", + global_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="global", + expected_resolved_source="global", + expected_fallback_applied=False, + expected_resolved_config_path=expected_external_config_path, + ) + global_default_state_data = _validate_ocio_state( + "global_default", + global_default_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="global", + expected_resolved_source="global", + expected_fallback_applied=False, + expected_resolved_config_path=expected_external_config_path, + ) + global_invalid_state_data = _validate_ocio_state( + "global_invalid_selection", + global_invalid_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="global", + expected_resolved_source="global", + expected_fallback_applied=False, + expected_resolved_config_path=expected_external_config_path, + ) + global_builtin_state_data = _validate_ocio_state( + "global_builtin_fallback", + global_builtin_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="global", + expected_resolved_source="builtin", + expected_fallback_applied=True, + expected_resolved_config_path=expected_builtin_config_path, + ) + builtin_state_data = _validate_ocio_state( + "builtin", + builtin_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="builtin", + expected_resolved_source="builtin", + expected_fallback_applied=False, + expected_resolved_config_path=expected_builtin_config_path, + ) + user_state_data = _validate_ocio_state( + "user", + user_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="user", + expected_resolved_source="user", + expected_fallback_applied=False, + expected_resolved_config_path=expected_external_config_path, + ) + user_missing_builtin_state_data = _validate_ocio_state( + "user_missing_builtin", + user_missing_builtin_state, + image_path=image_path, + expected_use_ocio=True, + expected_requested_source="user", + expected_resolved_source="builtin", + expected_fallback_applied=True, + expected_resolved_config_path=expected_builtin_config_path, + ) + except RuntimeError as exc: + raise SystemExit(_fail(str(exc))) + + global_default_ocio = global_default_state_data["ocio"] + global_invalid_ocio = global_invalid_state_data["ocio"] + if ( + str(global_invalid_ocio.get("display", "")).strip() != "Missing Display" + or str(global_invalid_ocio.get("view", "")).strip() != "Missing View" + ): + raise SystemExit( + _fail( + "global_invalid_selection: persisted invalid display/view were " + "not preserved in state output" + ) + ) + if ( + str(global_invalid_ocio.get("resolved_display", "")).strip() + != str(global_default_ocio.get("resolved_display", "")).strip() + or str(global_invalid_ocio.get("resolved_view", "")).strip() + != str(global_default_ocio.get("resolved_view", "")).strip() + ): + raise SystemExit( + _fail( + "global_invalid_selection: invalid persisted display/view did " + "not resolve to the config defaults" + ) + ) + + baseline_crop = out_dir / "baseline_crop.png" + global_crop = out_dir / "global_crop.png" + global_default_crop = out_dir / "global_default_crop.png" + global_builtin_crop = out_dir / "global_builtin_fallback_crop.png" + builtin_crop = out_dir / "builtin_crop.png" + user_crop = out_dir / "user_crop.png" + user_missing_builtin_crop = out_dir / "user_missing_builtin_crop.png" + + _crop_image(oiiotool, baseline_png, _image_crop_rect(baseline_layout), baseline_crop) + _crop_image(oiiotool, global_png, _image_crop_rect(global_layout), global_crop) + _crop_image( + oiiotool, + global_default_png, + _image_crop_rect(global_default_layout), + global_default_crop, + ) + _crop_image( + oiiotool, + global_builtin_png, + _image_crop_rect(global_builtin_layout), + global_builtin_crop, + ) + _crop_image(oiiotool, builtin_png, _image_crop_rect(builtin_layout), builtin_crop) + _crop_image(oiiotool, user_png, _image_crop_rect(user_layout), user_crop) + _crop_image( + oiiotool, + user_missing_builtin_png, + _image_crop_rect(user_missing_builtin_layout), + user_missing_builtin_crop, + ) + + baseline_global_diff = _normalized_rgb_diff( + oiiotool, baseline_crop, global_crop, out_dir, "baseline_vs_global" + ) + if baseline_global_diff <= 4.0: + raise SystemExit( + _fail( + "global OCIO source matched non-OCIO baseline; expected a real " + f"OCIO transform result (mean abs RGB diff={baseline_global_diff:.4f})" + ) + ) + global_user_diff = _normalized_rgb_diff( + oiiotool, global_crop, user_crop, out_dir, "global_vs_user" + ) + if global_user_diff > 2.0: + raise SystemExit( + _fail( + "user OCIO source output differs from global " + f"(mean abs RGB diff={global_user_diff:.4f})" + ) + ) + global_builtin_diff = _normalized_rgb_diff( + oiiotool, + global_builtin_crop, + builtin_crop, + out_dir, + "global_builtin_vs_builtin", + ) + if global_builtin_diff > 2.0: + raise SystemExit( + _fail( + "global source did not fall back to builtin when $OCIO was " + f"missing (mean abs RGB diff={global_builtin_diff:.4f})" + ) + ) + builtin_user_missing_diff = _normalized_rgb_diff( + oiiotool, + builtin_crop, + user_missing_builtin_crop, + out_dir, + "builtin_vs_user_missing_builtin", + ) + if builtin_user_missing_diff > 2.0: + raise SystemExit( + _fail( + "user source did not fall back to builtin when user config was " + f"missing (mean abs RGB diff={builtin_user_missing_diff:.4f})" + ) + ) + + print("baseline:", baseline_png) + print("global:", global_png) + print("global_default:", global_default_png) + print("global_builtin_fallback:", global_builtin_png) + print("builtin:", builtin_png) + print("user:", user_png) + print("user_missing_builtin:", user_missing_builtin_png) + print("baseline_log:", baseline_log) + print("global_log:", global_log) + print("global_default_log:", global_default_log) + print("global_invalid_selection_log:", global_invalid_log) + print("global_builtin_fallback_log:", global_builtin_log) + print("builtin_log:", builtin_log) + print("user_log:", user_log) + print("user_missing_builtin_log:", user_missing_builtin_log) + print("baseline_state:", baseline_state) + print("global_state:", global_state) + print("global_default_state:", global_default_state) + print("global_invalid_selection_state:", global_invalid_state) + print("global_builtin_fallback_state:", global_builtin_state) + print("builtin_state:", builtin_state) + print("user_state:", user_state) + print("user_missing_builtin_state:", user_missing_builtin_state) diff --git a/src/imiv/tools/imiv_ocio_live_update_regression.py b/src/imiv/tools/imiv_ocio_live_update_regression.py new file mode 100644 index 0000000000..3039844d11 --- /dev/null +++ b/src/imiv/tools/imiv_ocio_live_update_regression.py @@ -0,0 +1,952 @@ +#!/usr/bin/env python3 +"""Regression check for live OCIO display/view updates in imiv.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +ERROR_PATTERNS = ( + "VUID-", + "fatal Vulkan error", + "OCIO runtime shader preflight failed", + "vkCreateShaderModule failed", + "error: imiv exited with code", +) + + +def _default_case_timeout() -> float: + return 180.0 if os.name == "nt" else 45.0 + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + Path("/mnt/f/UBc/Release/bin/oiiotool"), + Path("/mnt/f/UBc/Debug/bin/oiiotool"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "bin" / "idiff", + Path("/mnt/f/UBc/Release/bin/idiff"), + Path("/mnt/f/UBc/Debug/bin/idiff"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _default_ocio_config(_: Path) -> str: + return "ocio://default" + + +def _resolve_existing_tool(requested: str, fallback: Path) -> Path: + candidate = Path(requested) + if requested: + candidate = candidate.expanduser() + if candidate.exists(): + return candidate.resolve() + if fallback.exists(): + return fallback.resolve() + return candidate + + +def _resolve_ocio_config_argument(value: str) -> str: + candidate = str(value).strip() + if not candidate: + return "" + if candidate.startswith("ocio://"): + return candidate + return str(Path(candidate).expanduser().resolve()) + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + loaded: dict[str, str] = {} + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + loaded[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + env.update(loaded) + return env + + +def _generate_probe_image(repo_root: Path, oiiotool: Path, image_path: Path) -> None: + image_path.parent.mkdir(parents=True, exist_ok=True) + chart_image = repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr" + if chart_image.exists(): + shutil.copyfile(chart_image, image_path) + return + cmd = [ + str(oiiotool), + "--create", + "96x48", + "3", + "--d", + "float", + "--fill:color=4.0,0.5,0.1", + "0,0,96,48", + "--attrib", + "oiio:ColorSpace", + "ACEScg", + "-o", + str(image_path), + ] + subprocess.run(cmd, check=True) + + +def _json_load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + result: list[str] = [] + for item in value: + text = str(item).strip() + if text: + result.append(text) + return result + + +def _views_by_display(ocio_state: dict) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + raw = ocio_state.get("views_by_display") + if not isinstance(raw, dict): + return result + for key, value in raw.items(): + display_name = str(key).strip() + if not display_name: + continue + result[display_name] = _string_list(value) + return result + + +def _pick_first_other(values: list[str], current: str) -> str: + for value in values: + if value != current: + return value + return "" + + +def _pick_preferred_target_view(values: list[str], current: str) -> str: + ranked: list[str] = [] + for value in values: + if value == current: + continue + lowered = value.lower() + penalty = 0 + if "raw" in lowered: + penalty += 100 + if "video" in lowered: + penalty += 40 + ranked.append((penalty, value)) + if not ranked: + return "" + ranked.sort(key=lambda item: (item[0], item[1])) + return ranked[0][1] + + +def _display_priority(display_name: str, current_display: str) -> tuple[int, str]: + name = display_name.lower() + score = 100 + if display_name == current_display: + score += 1000 + if "hdr" in name or "2100" in name or "pq" in name or "st2084" in name: + score -= 50 + if "p3" in name: + score -= 20 + if "1886" in name or "gamma 2.2" in name: + score += 10 + return score, display_name + + +def _pick_image_color_space( + requested: str, available_color_spaces: list[str] +) -> str: + text = requested.strip() + if text: + return text + if "ACEScg" in available_color_spaces: + return "ACEScg" + return "auto" + + +def _resolve_live_targets( + args: argparse.Namespace, probe_state_path: Path +) -> tuple[str, str, str, str, str]: + state_data = _json_load(probe_state_path) + ocio_state = state_data.get("ocio") + if not isinstance(ocio_state, dict): + raise RuntimeError(f"{probe_state_path.name}: missing ocio state block") + if not bool(ocio_state.get("menu_data_ok")): + raise RuntimeError( + "OCIO menu data unavailable: " + + str(ocio_state.get("menu_error", "")).strip() + ) + + displays = _string_list(ocio_state.get("available_displays")) + available_color_spaces = _string_list( + ocio_state.get("available_image_color_spaces") + ) + display_views = _views_by_display(ocio_state) + + resolved_display = str(ocio_state.get("resolved_display", "")).strip() + resolved_view = str(ocio_state.get("resolved_view", "")).strip() + if not resolved_display: + resolved_display = "default" + if not resolved_view: + resolved_view = "default" + + requested_display = args.display.strip() + if requested_display and ( + requested_display == "default" or requested_display in displays + ): + initial_display = requested_display + else: + initial_display = resolved_display + + current_views = display_views.get(resolved_display, []) + requested_view = args.raw_view.strip() + if requested_view and ( + requested_view == "default" or requested_view in current_views + ): + initial_view = requested_view + else: + initial_view = resolved_view + + image_color_space = _pick_image_color_space( + args.image_color_space, available_color_spaces + ) + + switch_mode = args.switch_mode + if switch_mode == "auto": + if args.target_display.strip(): + switch_mode = "display" + else: + target_views = display_views.get(initial_display, []) + switch_mode = ( + "view" + if _pick_first_other(target_views, initial_view) + else "display" + ) + + if switch_mode == "view": + target_display = args.target_display.strip() or initial_display + target_views = display_views.get(target_display, []) + target_view = args.target_view.strip() + if not target_view: + target_view = _pick_first_other(target_views, initial_view) + if not target_view: + raise RuntimeError( + "no alternate OCIO view is available in the active config" + ) + elif switch_mode == "display": + target_display = args.target_display.strip() + if not target_display: + ranked_displays = sorted( + displays, key=lambda value: _display_priority(value, initial_display) + ) + target_display = _pick_first_other(ranked_displays, initial_display) + if not target_display: + raise RuntimeError( + "no alternate OCIO display is available in the active config" + ) + target_views = display_views.get(target_display, []) + target_view = args.target_view.strip() + if not target_view: + target_view = _pick_preferred_target_view(target_views, initial_view) + if not target_view: + target_view = ( + target_views[0] + if target_views + else ("default" if initial_view != "default" else initial_view) + ) + else: + raise RuntimeError(f"unsupported switch mode: {switch_mode}") + + return ( + initial_display, + initial_view, + target_display, + target_view, + image_color_space, + ) + + +def _image_crop_rect(layout_path: Path) -> tuple[int, int, int, int]: + data = json.loads(layout_path.read_text(encoding="utf-8")) + image_window = None + for window in data.get("windows", []): + if window.get("name") == "Image": + image_window = window + break + if image_window is None: + raise RuntimeError(f"{layout_path.name}: missing Image window") + + viewport_id = image_window.get("viewport_id") + origin_x = float(image_window["rect"]["min"][0]) + origin_y = float(image_window["rect"]["min"][1]) + for window in data.get("windows", []): + if window.get("viewport_id") != viewport_id: + continue + rect = window.get("rect") + if not rect: + continue + origin_x = min(origin_x, float(rect["min"][0])) + origin_y = min(origin_y, float(rect["min"][1])) + + items = image_window.get("items", []) + best_rect = None + best_area = -1.0 + for item in items: + rect = item.get("rect_clipped") or item.get("rect_full") + if not rect: + continue + min_v = rect.get("min") + max_v = rect.get("max") + if not min_v or not max_v: + continue + width = max(0.0, float(max_v[0]) - float(min_v[0])) + height = max(0.0, float(max_v[1]) - float(min_v[1])) + area = width * height + if area > best_area: + best_area = area + best_rect = rect + + if best_rect is None: + rect = image_window.get("rect") + if not rect: + raise RuntimeError(f"{layout_path.name}: missing crop rect") + best_rect = rect + + x0 = int(math.floor(float(best_rect["min"][0]) - origin_x)) + 1 + y0 = int(math.floor(float(best_rect["min"][1]) - origin_y)) + 1 + x1 = int(math.ceil(float(best_rect["max"][0]) - origin_x)) - 2 + y1 = int(math.ceil(float(best_rect["max"][1]) - origin_y)) - 2 + if x1 <= x0 or y1 <= y0: + raise RuntimeError(f"{layout_path.name}: invalid crop rect") + return x0, y0, x1, y1 + + +def _crop_image( + oiiotool: Path, + source: Path, + crop_rect: tuple[int, int, int, int], + dest: Path, +) -> None: + x0, y0, x1, y1 = crop_rect + cmd = [ + str(oiiotool), + str(source), + "--crop", + f"{x0},{y0},{x1},{y1}", + "-o", + str(dest), + ] + subprocess.run(cmd, check=True) + + +def _write_rgb_ppm( + oiiotool: Path, source: Path, width: int, height: int, dest: Path +) -> None: + subprocess.run( + [ + str(oiiotool), + str(source), + "--resize", + f"{width}x{height}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _read_ppm(path: Path) -> tuple[int, int, bytes]: + with path.open("rb") as handle: + magic = handle.readline().strip() + if magic != b"P6": + raise RuntimeError(f"{path.name}: unsupported PPM format {magic!r}") + + def _next_non_comment() -> bytes: + while True: + line = handle.readline() + if not line: + raise RuntimeError(f"{path.name}: truncated PPM header") + line = line.strip() + if not line or line.startswith(b"#"): + continue + return line + + dims = _next_non_comment().split() + if len(dims) != 2: + raise RuntimeError(f"{path.name}: invalid PPM dimensions") + width = int(dims[0]) + height = int(dims[1]) + max_value = int(_next_non_comment()) + if max_value != 255: + raise RuntimeError(f"{path.name}: unsupported max value {max_value}") + + pixels = handle.read(width * height * 3) + if len(pixels) != width * height * 3: + raise RuntimeError(f"{path.name}: truncated PPM payload") + return width, height, pixels + + +def _mean_abs_diff(lhs: Path, rhs: Path) -> float: + lhs_w, lhs_h, lhs_pixels = _read_ppm(lhs) + rhs_w, rhs_h, rhs_pixels = _read_ppm(rhs) + if (lhs_w, lhs_h) != (rhs_w, rhs_h): + raise RuntimeError( + f"image size mismatch: {lhs.name}={lhs_w}x{lhs_h}, {rhs.name}={rhs_w}x{rhs_h}" + ) + total = 0 + count = len(lhs_pixels) + for a, b in zip(lhs_pixels, rhs_pixels): + total += abs(a - b) + return total / max(1, count) + + +def _normalized_rgb_diff( + oiiotool: Path, lhs: Path, rhs: Path, work_dir: Path, stem: str +) -> float: + lhs_ppm = work_dir / f"{stem}.lhs.ppm" + rhs_ppm = work_dir / f"{stem}.rhs.ppm" + _write_rgb_ppm(oiiotool, lhs, 256, 256, lhs_ppm) + _write_rgb_ppm(oiiotool, rhs, 256, 256, rhs_ppm) + return _mean_abs_diff(lhs_ppm, rhs_ppm) + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _run_gui_case( + repo_root: Path, + runner: Path, + exe: Path, + run_cwd: Path, + backend: str, + env: dict[str, str], + image_path: Path, + out_dir: Path, + name: str, + extra_args: list[str], + case_timeout: float, + trace: bool, +) -> tuple[Path, Path, Path, Path]: + screenshot_path = out_dir / f"{name}.png" + layout_path = out_dir / f"{name}.json" + state_path = out_dir / f"{name}.state.json" + log_path = out_dir / f"{name}.log" + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(run_cwd), + ] + if backend: + cmd.extend(["--backend", backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + *extra_args, + ] + ) + if trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=case_timeout, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: runner exited with code {proc.returncode}") + for path, label in ( + (screenshot_path, "screenshot"), + (layout_path, "layout json"), + (state_path, "state json"), + ): + if not path.exists(): + raise RuntimeError(f"{name}: {label} not written") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + raise RuntimeError(f"{name}: found runtime error pattern: {pattern}") + return screenshot_path, layout_path, state_path, log_path + + +def _scenario_step( + root: ET.Element, name: str, **attrs: str | int | bool +) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_live_scenario( + path: Path, + runtime_dir_rel: str, + initial_display: str, + initial_view: str, + target_display: str, + target_view: str, + image_color_space: str, + apply_delay_frames: int, +) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + root.set("layout_items", "true") + + _scenario_step( + root, + "static_raw", + delay_frames=10, + ocio_use=True, + ocio_display=initial_display, + ocio_view=initial_view, + ocio_image_color_space=image_color_space, + screenshot=True, + layout=True, + state=True, + post_action_delay_frames=apply_delay_frames, + ) + _scenario_step( + root, + "live_noop_raw", + ocio_use=True, + ocio_display=initial_display, + ocio_view=initial_view, + ocio_image_color_space=image_color_space, + screenshot=True, + layout=True, + state=True, + post_action_delay_frames=apply_delay_frames, + ) + _scenario_step( + root, + "live_switch", + ocio_use=True, + ocio_display=target_display, + ocio_view=target_view, + ocio_image_color_space=image_color_space, + screenshot=True, + layout=True, + state=True, + post_action_delay_frames=apply_delay_frames, + ) + _scenario_step( + root, + "static_target", + delay_frames=max(2, apply_delay_frames), + ocio_use=True, + ocio_display=target_display, + ocio_view=target_view, + ocio_image_color_space=image_color_space, + screenshot=True, + layout=True, + state=True, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _run_scenario_case( + repo_root: Path, + runner: Path, + exe: Path, + run_cwd: Path, + backend: str, + env: dict[str, str], + image_path: Path, + out_dir: Path, + scenario_path: Path, + case_timeout: float, + trace: bool, +) -> Path: + scenario_log = out_dir / "scenario.log" + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(run_cwd), + ] + if backend: + cmd.extend(["--backend", backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--scenario", + str(scenario_path), + ] + ) + if trace: + cmd.append("--trace") + + with scenario_log.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=case_timeout, + ) + if proc.returncode != 0: + raise RuntimeError(f"scenario: runner exited with code {proc.returncode}") + + log_text = scenario_log.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + raise RuntimeError(f"scenario: found runtime error pattern: {pattern}") + return scenario_log + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_out = repo_root / "build_u" / "imiv_captures" / "ocio_live_update_regression" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_image = default_out / "ocio_live_input.exr" + default_config = _default_ocio_config(repo_root) + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff executable") + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env script") + ap.add_argument("--out-dir", default=str(default_out), help="Artifact directory") + ap.add_argument("--image", default=str(default_image), help="Generated input image path") + ap.add_argument( + "--ocio-config", + default=str(default_config), + help="OCIO config path or URI", + ) + ap.add_argument( + "--switch-mode", + choices=("auto", "view", "display"), + default="view", + help="How to choose the live OCIO switch target", + ) + ap.add_argument("--display", default="", help="Initial OCIO display") + ap.add_argument("--target-display", default="", help="Target OCIO display") + ap.add_argument("--raw-view", default="", help="Initial OCIO view") + ap.add_argument("--target-view", default="", help="Live switch target OCIO view") + ap.add_argument("--image-color-space", default="", help="Input image color space") + ap.add_argument( + "--apply-frame", + type=int, + default=5, + help="Frames to wait after each live OCIO override before capture", + ) + ap.add_argument( + "--case-timeout", + type=float, + default=_default_case_timeout(), + help="Per-launch GUI runner timeout in seconds", + ) + ap.add_argument("--trace", action="store_true", help="Enable GUI runner trace") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + oiiotool = _resolve_existing_tool(args.oiiotool, _default_oiiotool(repo_root)) + idiff = _resolve_existing_tool(args.idiff, _default_idiff(repo_root)) + _ = idiff + ocio_config = _resolve_ocio_config_argument(args.ocio_config) + if not ocio_config.startswith("ocio://"): + ocio_config_path = Path(ocio_config) + if not ocio_config_path.exists(): + return _fail(f"OCIO config not found: {ocio_config_path}") + + run_cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).resolve() + runtime_dir = out_dir / "runtime" + image_path = Path(args.image).resolve() + probe_config_home = out_dir / "config_home_probe" + scenario_config_home = out_dir / "config_home_runtime" + scenario_path = out_dir / "ocio_live.scenario.xml" + + shutil.rmtree(out_dir, ignore_errors=True) + runtime_dir.mkdir(parents=True, exist_ok=True) + probe_config_home.mkdir(parents=True, exist_ok=True) + scenario_config_home.mkdir(parents=True, exist_ok=True) + + env = _load_env_from_script(Path(args.env_script).resolve()) + env["OCIO"] = ocio_config + + try: + _generate_probe_image(repo_root, oiiotool, image_path) + + probe_env = dict(env) + probe_env["IMIV_CONFIG_HOME"] = str(probe_config_home) + _, _, probe_state, probe_log = _run_gui_case( + repo_root, + runner, + exe, + run_cwd, + args.backend, + probe_env, + image_path, + out_dir, + "probe_menu", + [], + case_timeout=args.case_timeout, + trace=args.trace, + ) + + ( + initial_display, + initial_view, + target_display, + target_view, + image_color_space, + ) = _resolve_live_targets(args, probe_state) + + runtime_dir_rel = _path_for_imiv_output(runtime_dir, run_cwd) + _write_live_scenario( + scenario_path, + runtime_dir_rel, + initial_display, + initial_view, + target_display, + target_view, + image_color_space, + max(0, args.apply_frame), + ) + + scenario_env = dict(env) + scenario_env["IMIV_CONFIG_HOME"] = str(scenario_config_home) + scenario_log = _run_scenario_case( + repo_root, + runner, + exe, + run_cwd, + args.backend, + scenario_env, + image_path, + out_dir, + scenario_path, + case_timeout=args.case_timeout, + trace=args.trace, + ) + + static_raw_png = runtime_dir / "static_raw.png" + static_raw_layout = runtime_dir / "static_raw.layout.json" + static_raw_state = runtime_dir / "static_raw.state.json" + static_target_png = runtime_dir / "static_target.png" + static_target_layout = runtime_dir / "static_target.layout.json" + static_target_state = runtime_dir / "static_target.state.json" + live_noop_png = runtime_dir / "live_noop_raw.png" + live_noop_layout = runtime_dir / "live_noop_raw.layout.json" + live_noop_state = runtime_dir / "live_noop_raw.state.json" + live_switch_png = runtime_dir / "live_switch.png" + live_switch_layout = runtime_dir / "live_switch.layout.json" + live_switch_state = runtime_dir / "live_switch.state.json" + + required_paths = [ + static_raw_png, + static_raw_layout, + static_raw_state, + static_target_png, + static_target_layout, + static_target_state, + live_noop_png, + live_noop_layout, + live_noop_state, + live_switch_png, + live_switch_layout, + live_switch_state, + ] + for required in required_paths: + if not required.exists(): + raise RuntimeError(f"scenario: expected output missing: {required}") + + crop_dir = runtime_dir / "crops" + crop_dir.mkdir(parents=True, exist_ok=True) + static_raw_crop = crop_dir / "static_raw.png" + static_target_crop = crop_dir / "static_target.png" + live_noop_crop = crop_dir / "live_noop_raw.png" + live_switch_crop = crop_dir / "live_switch.png" + + _crop_image( + oiiotool, + static_raw_png, + _image_crop_rect(static_raw_layout), + static_raw_crop, + ) + _crop_image( + oiiotool, + static_target_png, + _image_crop_rect(static_target_layout), + static_target_crop, + ) + _crop_image( + oiiotool, + live_noop_png, + _image_crop_rect(live_noop_layout), + live_noop_crop, + ) + _crop_image( + oiiotool, + live_switch_png, + _image_crop_rect(live_switch_layout), + live_switch_crop, + ) + except (OSError, subprocess.CalledProcessError, RuntimeError, subprocess.TimeoutExpired) as exc: + return _fail(str(exc)) + + static_noop_diff = _normalized_rgb_diff( + oiiotool, static_raw_crop, live_noop_crop, crop_dir, "static_vs_noop" + ) + if static_noop_diff > 2.0: + return _fail( + "live noop OCIO update changed the image region unexpectedly " + f"(mean abs RGB diff={static_noop_diff:.4f})" + ) + + static_switch_diff = _normalized_rgb_diff( + oiiotool, static_raw_crop, live_switch_crop, crop_dir, "static_vs_switch" + ) + if static_switch_diff <= 4.0: + return _fail( + f"live OCIO {args.switch_mode} switch did not update the image region " + f"(mean abs RGB diff={static_switch_diff:.4f})" + ) + + target_switch_diff = _normalized_rgb_diff( + oiiotool, static_target_crop, live_switch_crop, crop_dir, "target_vs_switch" + ) + if target_switch_diff > 2.0: + return _fail( + f"live OCIO {args.switch_mode} switch does not match the settled target state " + f"(mean abs RGB diff={target_switch_diff:.4f})" + ) + + print("probe log:", probe_log) + print("scenario log:", scenario_log) + print("display:", initial_display) + print("initial display:", initial_display) + print("target display:", target_display) + print("initial view:", initial_view) + print("target view:", target_view) + print("image color space:", image_color_space) + print("OCIO config:", ocio_config) + print("static raw:", static_raw_png) + print("static target:", static_target_png) + print("live noop:", live_noop_png) + print("live switch:", live_switch_png) + print("crop static raw:", static_raw_crop) + print("crop static target:", static_target_crop) + print("crop live noop:", live_noop_crop) + print("crop live switch:", live_switch_crop) + print( + "state dumps:", + probe_state, + static_raw_state, + static_target_state, + live_noop_state, + live_switch_state, + ) + print("logs:", probe_log, scenario_log) + print("artifacts:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_ocio_missing_fallback_regression.py b/src/imiv/tools/imiv_ocio_missing_fallback_regression.py new file mode 100644 index 0000000000..725fdd12ab --- /dev/null +++ b/src/imiv/tools/imiv_ocio_missing_fallback_regression.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +"""Regression check for builtin OCIO fallback when $OCIO is unavailable.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "VUID-", + "fatal Vulkan error", + "error: imiv exited with code", +) + + +SOURCE_GLOBAL = 0 +SOURCE_BUILTIN = 1 + + +def _default_case_timeout() -> float: + return 180.0 if os.name == "nt" else 60.0 + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + loaded: dict[str, str] = {} + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + loaded[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + env.update(loaded) + return env + + +def _json_load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + Path("/mnt/f/UBc/Release/bin/oiiotool"), + Path("/mnt/f/UBc/Debug/bin/oiiotool"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "bin" / "idiff", + Path("/mnt/f/UBc/Release/bin/idiff"), + Path("/mnt/f/UBc/Debug/bin/idiff"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _write_prefs(config_home: Path, *, ocio_config_source: int) -> Path: + prefs_dir = config_home / "OpenImageIO" / "imiv" + prefs_dir.mkdir(parents=True, exist_ok=True) + prefs_path = prefs_dir / "imiv.inf" + prefs_text = ( + "[ImivApp][State]\n" + "use_ocio=1\n" + f"ocio_config_source={ocio_config_source}\n" + "ocio_display=default\n" + "ocio_view=default\n" + "ocio_image_color_space=auto\n" + "ocio_user_config_path=\n" + ) + prefs_path.write_text(prefs_text, encoding="utf-8") + return prefs_path + + +def _run_case( + repo_root: Path, + runner: Path, + exe: Path, + cwd: Path, + backend: str, + env: dict[str, str], + image_path: Path, + out_dir: Path, + name: str, + trace: bool, + case_timeout: float, + ) -> tuple[Path, Path, Path, Path]: + screenshot_path = out_dir / f"{name}.png" + layout_path = out_dir / f"{name}.json" + state_path = out_dir / f"{name}.state.json" + log_path = out_dir / f"{name}.log" + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if backend: + cmd.extend(["--backend", backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + ] + ) + if trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=case_timeout, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: runner exited with code {proc.returncode}") + if not screenshot_path.exists(): + raise RuntimeError(f"{name}: screenshot not written") + if not layout_path.exists(): + raise RuntimeError(f"{name}: layout json not written") + if not state_path.exists(): + raise RuntimeError(f"{name}: state json not written") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + raise RuntimeError(f"{name}: found runtime error pattern: {pattern}") + return screenshot_path, layout_path, state_path, log_path + + +def _image_crop_rect(layout_path: Path) -> tuple[int, int, int, int]: + data = json.loads(layout_path.read_text(encoding="utf-8")) + image_window = None + for window in data.get("windows", []): + if window.get("name") == "Image": + image_window = window + break + if image_window is None: + raise RuntimeError(f"{layout_path.name}: missing Image window") + + viewport_id = image_window.get("viewport_id") + origin_x = float(image_window["rect"]["min"][0]) + origin_y = float(image_window["rect"]["min"][1]) + for window in data.get("windows", []): + if window.get("viewport_id") != viewport_id: + continue + rect = window.get("rect") + if not rect: + continue + origin_x = min(origin_x, float(rect["min"][0])) + origin_y = min(origin_y, float(rect["min"][1])) + + items = image_window.get("items", []) + best_rect = None + best_area = -1.0 + for item in items: + rect = item.get("rect_clipped") or item.get("rect_full") + if not rect: + continue + min_v = rect.get("min") + max_v = rect.get("max") + if not min_v or not max_v: + continue + width = max(0.0, float(max_v[0]) - float(min_v[0])) + height = max(0.0, float(max_v[1]) - float(min_v[1])) + area = width * height + if area > best_area: + best_area = area + best_rect = rect + + if best_rect is None: + rect = image_window.get("rect") + if not rect: + raise RuntimeError(f"{layout_path.name}: missing crop rect") + best_rect = rect + + x0 = int(math.floor(float(best_rect["min"][0]) - origin_x)) + 1 + y0 = int(math.floor(float(best_rect["min"][1]) - origin_y)) + 1 + x1 = int(math.ceil(float(best_rect["max"][0]) - origin_x)) - 2 + y1 = int(math.ceil(float(best_rect["max"][1]) - origin_y)) - 2 + if x1 <= x0 or y1 <= y0: + raise RuntimeError(f"{layout_path.name}: invalid crop rect") + return x0, y0, x1, y1 + + +def _crop_image( + oiiotool: Path, source: Path, crop_rect: tuple[int, int, int, int], dest: Path +) -> None: + x0, y0, x1, y1 = crop_rect + cmd = [ + str(oiiotool), + str(source), + "--crop", + f"{x0},{y0},{x1},{y1}", + "-o", + str(dest), + ] + subprocess.run(cmd, check=True) + + +def _write_rgb_ppm( + oiiotool: Path, source: Path, width: int, height: int, dest: Path +) -> None: + subprocess.run( + [ + str(oiiotool), + str(source), + "--resize", + f"{width}x{height}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _read_ppm(path: Path) -> tuple[int, int, bytes]: + with path.open("rb") as handle: + magic = handle.readline().strip() + if magic != b"P6": + raise RuntimeError(f"{path.name}: unsupported PPM format {magic!r}") + + def _next_non_comment() -> bytes: + while True: + line = handle.readline() + if not line: + raise RuntimeError(f"{path.name}: truncated PPM header") + line = line.strip() + if not line or line.startswith(b"#"): + continue + return line + + dims = _next_non_comment().split() + if len(dims) != 2: + raise RuntimeError(f"{path.name}: invalid PPM dimensions") + width = int(dims[0]) + height = int(dims[1]) + max_value = int(_next_non_comment()) + if max_value != 255: + raise RuntimeError(f"{path.name}: unsupported max value {max_value}") + + pixels = handle.read(width * height * 3) + if len(pixels) != width * height * 3: + raise RuntimeError(f"{path.name}: truncated PPM payload") + return width, height, pixels + + +def _mean_abs_diff(lhs: Path, rhs: Path) -> float: + lhs_w, lhs_h, lhs_pixels = _read_ppm(lhs) + rhs_w, rhs_h, rhs_pixels = _read_ppm(rhs) + if (lhs_w, lhs_h) != (rhs_w, rhs_h): + raise RuntimeError( + f"image size mismatch: {lhs.name}={lhs_w}x{lhs_h}, {rhs.name}={rhs_w}x{rhs_h}" + ) + total = 0 + count = len(lhs_pixels) + for a, b in zip(lhs_pixels, rhs_pixels): + total += abs(a - b) + return total / max(1, count) + + +def _normalized_rgb_diff( + oiiotool: Path, lhs: Path, rhs: Path, work_dir: Path, stem: str +) -> float: + lhs_ppm = work_dir / f"{stem}.lhs.ppm" + rhs_ppm = work_dir / f"{stem}.rhs.ppm" + _write_rgb_ppm(oiiotool, lhs, 256, 256, lhs_ppm) + _write_rgb_ppm(oiiotool, rhs, 256, 256, rhs_ppm) + return _mean_abs_diff(lhs_ppm, rhs_ppm) + + +def _validate_ocio_state( + name: str, + state_path: Path, + *, + image_path: Path, + expected_requested_source: str, + expected_resolved_source: str, + expected_fallback_applied: bool, +) -> dict: + state = _json_load(state_path) + if not bool(state.get("image_loaded")): + raise RuntimeError(f"{name}: image was not loaded") + if str(state.get("image_path", "")).strip() != str(image_path): + raise RuntimeError( + f"{name}: unexpected image path {state.get('image_path', '')!r}" + ) + + ocio = state.get("ocio") + if not isinstance(ocio, dict): + raise RuntimeError(f"{name}: missing ocio state block") + if not bool(ocio.get("use_ocio")): + raise RuntimeError(f"{name}: use_ocio was not enabled") + if str(ocio.get("requested_source", "")).strip() != expected_requested_source: + raise RuntimeError( + f"{name}: unexpected requested_source=" + f"{ocio.get('requested_source', '')!r}" + ) + if str(ocio.get("resolved_source", "")).strip() != expected_resolved_source: + raise RuntimeError( + f"{name}: unexpected resolved_source=" + f"{ocio.get('resolved_source', '')!r}" + ) + if bool(ocio.get("fallback_applied")) != expected_fallback_applied: + raise RuntimeError( + f"{name}: unexpected fallback_applied=" + f"{ocio.get('fallback_applied')!r}" + ) + if str(ocio.get("resolved_config_path", "")).strip() != "ocio://default": + raise RuntimeError( + f"{name}: unexpected resolved_config_path=" + f"{ocio.get('resolved_config_path', '')!r}" + ) + if not bool(ocio.get("menu_data_ok")): + raise RuntimeError( + f"{name}: OCIO menu data unavailable: " + f"{str(ocio.get('menu_error', '')).strip()}" + ) + resolved_display = str(ocio.get("resolved_display", "")).strip() + resolved_view = str(ocio.get("resolved_view", "")).strip() + if not resolved_display: + raise RuntimeError(f"{name}: resolved_display is empty") + if not resolved_view: + raise RuntimeError(f"{name}: resolved_view is empty") + return state + + +if __name__ == "__main__": + repo_root = Path(__file__).resolve().parents[3] + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + default_out = repo_root / "build_u" / "imiv_captures" / "ocio_missing_fallback_regression" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_oiiotool = _default_oiiotool(repo_root) + default_idiff = _default_idiff(repo_root) + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--oiiotool", default=str(default_oiiotool), help="oiiotool executable") + ap.add_argument("--idiff", default=str(default_idiff), help="idiff executable") + ap.add_argument("--out-dir", default=str(default_out), help="Output directory") + ap.add_argument( + "--case-timeout", + type=float, + default=_default_case_timeout(), + help="Per-case GUI runner timeout in seconds", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + raise SystemExit(_fail(f"binary not found: {exe}")) + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + image_path = Path(args.open).expanduser().resolve() + if not image_path.exists(): + raise SystemExit(_fail(f"image not found: {image_path}")) + + runner = default_runner.resolve() + if not runner.exists(): + raise SystemExit(_fail(f"runner not found: {runner}")) + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists() and shutil.which(str(oiiotool)) is None: + raise SystemExit(_fail(f"oiiotool not found: {oiiotool}")) + idiff = Path(args.idiff).expanduser() + if not idiff.exists(): + found = shutil.which(str(idiff)) + if not found: + raise SystemExit(_fail(f"idiff not found: {idiff}")) + idiff = Path(found) + idiff = idiff.resolve() + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + base_env = _load_env_from_script(Path(args.env_script).expanduser()) + base_env.pop("OCIO", None) + + global_cfg = out_dir / "cfg_global" + _write_prefs(global_cfg, ocio_config_source=SOURCE_GLOBAL) + global_env = dict(base_env) + global_env["IMIV_CONFIG_HOME"] = str(global_cfg) + + builtin_cfg = out_dir / "cfg_builtin" + _write_prefs(builtin_cfg, ocio_config_source=SOURCE_BUILTIN) + builtin_env = dict(base_env) + builtin_env["IMIV_CONFIG_HOME"] = str(builtin_cfg) + + try: + global_png, global_layout, global_state, global_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + global_env, + image_path, + out_dir, + "global_builtin_fallback", + args.trace, + args.case_timeout, + ) + builtin_png, builtin_layout, builtin_state, builtin_log = _run_case( + repo_root, + runner, + exe, + cwd, + args.backend, + builtin_env, + image_path, + out_dir, + "builtin", + args.trace, + args.case_timeout, + ) + except (subprocess.SubprocessError, RuntimeError) as exc: + raise SystemExit(_fail(str(exc))) + + try: + global_state_data = _validate_ocio_state( + "global_builtin_fallback", + global_state, + image_path=image_path, + expected_requested_source="global", + expected_resolved_source="builtin", + expected_fallback_applied=True, + ) + builtin_state_data = _validate_ocio_state( + "builtin", + builtin_state, + image_path=image_path, + expected_requested_source="builtin", + expected_resolved_source="builtin", + expected_fallback_applied=False, + ) + except RuntimeError as exc: + raise SystemExit(_fail(str(exc))) + + global_ocio = global_state_data["ocio"] + builtin_ocio = builtin_state_data["ocio"] + if ( + str(global_ocio.get("resolved_display", "")).strip() + != str(builtin_ocio.get("resolved_display", "")).strip() + or str(global_ocio.get("resolved_view", "")).strip() + != str(builtin_ocio.get("resolved_view", "")).strip() + ): + raise SystemExit( + _fail( + "global_builtin_fallback: fallback did not resolve to the same " + "display/view as the explicit builtin source" + ) + ) + + global_crop = out_dir / "global_builtin_fallback.crop.png" + builtin_crop = out_dir / "builtin.crop.png" + try: + _crop_image( + oiiotool, global_png, _image_crop_rect(global_layout), global_crop + ) + _crop_image( + oiiotool, builtin_png, _image_crop_rect(builtin_layout), builtin_crop + ) + except (subprocess.SubprocessError, RuntimeError) as exc: + raise SystemExit(_fail(str(exc))) + + diff = _normalized_rgb_diff( + oiiotool, global_crop, builtin_crop, out_dir, "global_builtin_fallback" + ) + if diff > 2.0: + raise SystemExit( + _fail( + "global OCIO source did not match explicit builtin source when " + f"$OCIO was unavailable (mean abs RGB diff={diff:.4f})" + ) + ) + + print("global_builtin_fallback:", global_png) + print("builtin:", builtin_png) + print("global_builtin_fallback_log:", global_log) + print("builtin_log:", builtin_log) + print("global_builtin_fallback_state:", global_state) + print("builtin_state:", builtin_state) diff --git a/src/imiv/tools/imiv_ocio_regression.py b/src/imiv/tools/imiv_ocio_regression.py new file mode 100644 index 0000000000..d109b9e1bc --- /dev/null +++ b/src/imiv/tools/imiv_ocio_regression.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Regression check for imiv OCIO auto colorspace resolution.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "VUID-", + "fatal Vulkan error", + "OCIO runtime shader preflight failed", + "vkCreateShaderModule failed", + "error: imiv exited with code", +) + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + Path("/mnt/f/UBc/Release/bin/oiiotool"), + Path("/mnt/f/UBc/Debug/bin/oiiotool"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_iinfo(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "iinfo", + repo_root / "build" / "bin" / "iinfo", + Path("/mnt/f/UBc/Release/bin/iinfo"), + Path("/mnt/f/UBc/Debug/bin/iinfo"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("iinfo") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "bin" / "idiff", + Path("/mnt/f/UBc/Release/bin/idiff"), + Path("/mnt/f/UBc/Debug/bin/idiff"), + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists(): + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + loaded = {} + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + loaded[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + env.update(loaded) + return env + + +def _generate_probe_image(oiiotool: Path, image_path: Path) -> None: + image_path.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + str(oiiotool), + "--create", + "96x48", + "3", + "--d", + "float", + "--fill:color=0.02,0.18,0.72", + "0,0,96,48", + "--attrib", + "oiio:ColorSpace", + "srgb_texture", + "-o", + str(image_path), + ] + subprocess.run(cmd, check=True) + + +def _detect_metadata_colorspace(iinfo: Path, image_path: Path) -> str: + proc = subprocess.run( + [str(iinfo), "-v", str(image_path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + for line in proc.stdout.splitlines(): + if "oiio:ColorSpace:" not in line: + continue + _, _, tail = line.partition(":") + _, _, value = tail.partition(":") + value = value.strip().strip('"') + if value: + return value + raise RuntimeError("failed to detect oiio:ColorSpace from iinfo output") + + +def _run_case( + exe: Path, + cwd: Path, + env: dict[str, str], + out_dir: Path, + image_path: Path, + name: str, + extra_args: list[str], +) -> tuple[str, Path, Path]: + screenshot_path = out_dir / f"{name}.png" + log_path = out_dir / f"{name}.log" + exe_cmd = [str(exe), "-F", *extra_args, str(image_path)] + shell_cmd = "exec " + " ".join(shlex.quote(arg) for arg in exe_cmd) + + run_env = dict(env) + run_env.update( + { + "OCIO": run_env.get("OCIO", "ocio://default"), + "IMIV_IMGUI_TEST_ENGINE": "1", + "IMIV_IMGUI_TEST_ENGINE_EXIT_ON_FINISH": "1", + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT": "1", + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_OUT": str(screenshot_path), + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_DELAY_FRAMES": "5", + "IMIV_IMGUI_TEST_ENGINE_AUTOSSCREENSHOT_FRAMES": "1", + } + ) + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + ["bash", "-lc", shell_cmd], + cwd=str(cwd), + env=run_env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=45, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: imiv exited with code {proc.returncode}") + if not screenshot_path.exists(): + raise RuntimeError(f"{name}: screenshot not written") + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + raise RuntimeError(f"{name}: found runtime error pattern: {pattern}") + + return _sha256(screenshot_path), screenshot_path, log_path + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _images_identical(idiff: Path, lhs: Path, rhs: Path) -> bool: + proc = subprocess.run( + [str(idiff), "-q", "-a", str(lhs), str(rhs)], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return proc.returncode == 0 + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[3] + default_out = repo_root / "build_u" / "imiv_captures" / "ocio_auto_regression" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_image = default_out / "ocio_auto_input.exr" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--iinfo", default=str(_default_iinfo(repo_root)), help="iinfo executable") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff executable") + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env script") + ap.add_argument("--out-dir", default=str(default_out), help="Artifact directory") + ap.add_argument("--image", default=str(default_image), help="Generated input image path") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + + oiiotool = Path(args.oiiotool) + iinfo = Path(args.iinfo) + idiff = Path(args.idiff) + run_cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).resolve() + image_path = Path(args.image).resolve() + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + config_home = out_dir / "config_home" + config_home.mkdir(parents=True, exist_ok=True) + + env = _load_env_from_script(Path(args.env_script).resolve()) + env["IMIV_CONFIG_HOME"] = str(config_home) + + try: + _generate_probe_image(oiiotool, image_path) + metadata_color_space = _detect_metadata_colorspace(iinfo, image_path) + + auto_hash, auto_png, auto_log = _run_case( + exe, + run_cwd, + env, + out_dir, + image_path, + "auto", + ["--image-color-space", "auto"], + ) + explicit_hash, explicit_png, explicit_log = _run_case( + exe, + run_cwd, + env, + out_dir, + image_path, + "explicit_metadata", + ["--image-color-space", metadata_color_space], + ) + scene_linear_hash, scene_linear_png, scene_linear_log = _run_case( + exe, + run_cwd, + env, + out_dir, + image_path, + "forced_scene_linear", + ["--image-color-space", "scene_linear"], + ) + except (OSError, subprocess.CalledProcessError, RuntimeError, subprocess.TimeoutExpired) as exc: + return _fail(str(exc)) + + if not _images_identical(idiff, auto_png, explicit_png): + return _fail( + "auto colorspace does not match explicit metadata colorspace: " + f"auto={auto_hash} explicit={explicit_hash}" + ) + if _images_identical(idiff, auto_png, scene_linear_png): + return _fail( + "auto colorspace unexpectedly matches forced scene_linear output: " + f"auto={auto_hash}" + ) + + print("metadata colorspace:", metadata_color_space) + print("auto hash:", auto_hash) + print("explicit metadata hash:", explicit_hash) + print("forced scene_linear hash:", scene_linear_hash) + print("auto screenshot:", auto_png) + print("explicit screenshot:", explicit_png) + print("scene_linear screenshot:", scene_linear_png) + print("auto log:", auto_log) + print("explicit log:", explicit_log) + print("scene_linear log:", scene_linear_log) + print("artifacts:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_open_folder_regression.py b/src/imiv/tools/imiv_open_folder_regression.py new file mode 100644 index 0000000000..53cf35d19e --- /dev/null +++ b/src/imiv/tools/imiv_open_folder_regression.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Regression check for startup folder-open queue filtering.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _run(cmd: list[str], *, cwd: Path, env: dict[str, str], log_path: Path) -> None: + proc = subprocess.run( + cmd, + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + raise RuntimeError( + f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stdout}" + ) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "open_folder_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--oiiotool", + default=str(repo_root / "build" / "bin" / "oiiotool"), + help="oiiotool executable", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + oiiotool = Path(args.oiiotool).expanduser().resolve() + if not oiiotool.exists(): + return _fail(f"oiiotool not found: {oiiotool}") + runner = runner.resolve() + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + state_path = out_dir / "open_folder.state.json" + log_path = out_dir / "open_folder.log" + + shutil.rmtree(out_dir, ignore_errors=True) + runtime_dir.mkdir(parents=True, exist_ok=True) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + + source_logo = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + if not source_logo.exists(): + return _fail(f"source image not found: {source_logo}") + + folder_dir = runtime_dir / "mixed_folder" + folder_dir.mkdir(parents=True, exist_ok=True) + image_a = folder_dir / "01_logo.png" + image_b = folder_dir / "02_logo.exr" + notes = folder_dir / "03_notes.txt" + blob = folder_dir / "04_blob.bin" + + try: + _run( + [str(oiiotool), str(source_logo), "-o", str(image_a)], + cwd=repo_root, + env=env, + log_path=runtime_dir / "make_png.log", + ) + _run( + [str(oiiotool), str(source_logo), "--ch", "R,G,B", "-d", "half", "-o", str(image_b)], + cwd=repo_root, + env=env, + log_path=runtime_dir / "make_exr.log", + ) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + + notes.write_text("not an image\n", encoding="utf-8") + blob.write_bytes(b"\x00\x01\x02\x03") + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(folder_dir), + "--state-json-out", + str(state_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not state_path.exists(): + return _fail(f"state output not found: {state_path}") + + state = json.loads(state_path.read_text(encoding="utf-8")) + if not bool(state.get("image_loaded", False)): + return _fail("state does not report a loaded image") + if int(state.get("loaded_image_count", 0)) != 2: + return _fail( + f"expected 2 supported images from folder, got {state.get('loaded_image_count')}" + ) + if not bool(state.get("image_list_visible", False)): + return _fail("Image List did not auto-open for folder queue") + + image_path = Path(str(state.get("image_path", ""))) + if image_path.name != "01_logo.png": + return _fail(f"unexpected first loaded image: {image_path}") + + print(f"state: {state_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_opengl_multiopen_ocio_regression.py b/src/imiv/tools/imiv_opengl_multiopen_ocio_regression.py new file mode 100644 index 0000000000..b28bb6e02d --- /dev/null +++ b/src/imiv/tools/imiv_opengl_multiopen_ocio_regression.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Regression check for OpenGL OCIO preview with multiple startup images.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "OpenGL preview draw failed", + "OpenGL OCIO preview draw failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = ( + repo_root / "build_u" / "imiv_captures" / "opengl_multiopen_ocio_regression" + ) + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--backend", default="opengl", help="Runtime backend override") + ap.add_argument( + "--env-script", default=str(default_env_script), help="Optional shell env setup script" + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Source image to duplicate") + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + image_a = Path(args.open).resolve() + if not image_a.exists(): + print(f"error: image not found: {image_a}", file=sys.stderr) + return 2 + image_b = out_dir / f"{image_a.stem}_copy{image_a.suffix}" + shutil.copyfile(image_a, image_b) + + env = _load_env_from_script(Path(args.env_script).resolve()) + env["IMIV_CONFIG_HOME"] = str(out_dir / "cfg") + + screenshot_path = out_dir / "opengl_multiopen_ocio.png" + layout_path = out_dir / "opengl_multiopen_ocio.layout.json" + state_path = out_dir / "opengl_multiopen_ocio.state.json" + log_path = out_dir / "opengl_multiopen_ocio.log" + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--backend", + args.backend, + "--open", + str(image_a), + "--open", + str(image_b), + "--ocio-use", + "true", + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + ] + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + print(f"error: runner exited with code {proc.returncode}", file=sys.stderr) + return 1 + + for required in (screenshot_path, layout_path, state_path): + if not required.exists(): + print(f"error: missing output: {required}", file=sys.stderr) + return 1 + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + print(f"error: found runtime error pattern: {pattern}", file=sys.stderr) + return 1 + + layout = json.loads(layout_path.read_text(encoding="utf-8")) + if not any(window.get("name") == "Image" for window in layout.get("windows", [])): + print("error: layout dump missing Image window", file=sys.stderr) + return 1 + + state = json.loads(state_path.read_text(encoding="utf-8")) + if not state.get("image_path"): + print("error: state dump missing image_path", file=sys.stderr) + return 1 + if int(state.get("loaded_image_count", 0)) < 2: + print("error: expected at least 2 loaded images in session", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_opengl_selection_regression.py b/src/imiv/tools/imiv_opengl_selection_regression.py new file mode 100644 index 0000000000..2039d20ed0 --- /dev/null +++ b/src/imiv/tools/imiv_opengl_selection_regression.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""OpenGL-focused selection regression with real UI toggles.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run_checked(cmd: list[str], *, cwd: Path) -> None: + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def _generate_fixture(oiiotool: Path, out_path: Path, width: int, height: int) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + _run_checked( + [ + str(oiiotool), + "--pattern", + "fill:top=0.12,0.18,0.24,bottom=0.78,0.84,0.96", + f"{width}x{height}", + "3", + "-d", + "uint8", + "-o", + str(out_path), + ], + cwd=out_path.parent, + ) + + +def _run_case( + repo_root: Path, + runner: Path, + exe: Path, + cwd: Path, + image_path: Path, + out_dir: Path, + name: str, + env: dict[str, str], + extra_args: list[str], + *, + want_layout: bool = False, + trace: bool = False, +) -> tuple[dict, dict | None]: + state_path = out_dir / f"{name}.state.json" + layout_path = out_dir / f"{name}.layout.json" + log_path = out_dir / f"{name}.log" + config_home = out_dir / f"cfg_{name}" + shutil.rmtree(config_home, ignore_errors=True) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(image_path), + "--state-json-out", + str(state_path), + "--post-action-delay-frames", + "2", + ] + if want_layout: + cmd.extend(["--layout-json-out", str(layout_path), "--layout-items"]) + if trace: + cmd.append("--trace") + cmd.extend(extra_args) + + case_env = dict(env) + case_env["IMIV_CONFIG_HOME"] = str(config_home) + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=case_env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=120, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: runner exited with code {proc.returncode}") + if not state_path.exists(): + raise RuntimeError(f"{name}: state file not written") + + state = json.loads(state_path.read_text(encoding="utf-8")) + state["_state_path"] = str(state_path) + state["_log_path"] = str(log_path) + layout = None + if want_layout: + if not layout_path.exists(): + raise RuntimeError(f"{name}: layout file not written") + layout = json.loads(layout_path.read_text(encoding="utf-8")) + return state, layout + + +def _selection_bounds_valid(state: dict) -> bool: + bounds = state.get("selection_bounds", []) + if len(bounds) != 4: + return False + return bounds[2] > bounds[0] and bounds[3] > bounds[1] + + +def _selection_is_cleared(state: dict) -> bool: + bounds = state.get("selection_bounds", []) + if len(bounds) != 4: + return False + return ( + not state.get("selection_active", False) + and bounds[0] == 0 + and bounds[1] == 0 + and bounds[2] == 0 + and bounds[3] == 0 + ) + + +def _area_probe_is_initialized(state: dict) -> bool: + lines = state.get("area_probe_lines", []) + return bool(lines) and all("-----" not in line for line in lines[1:]) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = repo_root / "build_u" / "imiv_captures" / "opengl_selection_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", required=True, help="imiv executable") + ap.add_argument("--cwd", required=True, help="Working directory for imiv") + ap.add_argument("--oiiotool", required=True, help="oiiotool executable") + ap.add_argument("--env-script", default=str(default_env_script)) + ap.add_argument("--out-dir", default=str(default_out_dir)) + ap.add_argument("--trace", action="store_true") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + cwd = Path(args.cwd).expanduser().resolve() + oiiotool = Path(args.oiiotool).expanduser().resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + if not exe.exists(): + return _fail(f"binary not found: {exe}") + if not oiiotool.exists(): + return _fail(f"oiiotool not found: {oiiotool}") + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + select_image = out_dir / "select_input.tif" + pan_image = out_dir / "pan_input.tif" + try: + _generate_fixture(oiiotool, select_image, 320, 240) + _generate_fixture(oiiotool, pan_image, 3072, 2048) + except subprocess.SubprocessError as exc: + return _fail(f"failed to generate fixtures: {exc}") + + env = _load_env_from_script(Path(args.env_script).expanduser()) + + try: + select_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + select_image, + out_dir, + "select_drag", + env, + [ + "--key-chord", + "ctrl+a", + "--mouse-pos-image-rel", + "0.25", + "0.35", + "--mouse-drag", + "120", + "90", + "--mouse-drag-button", + "0", + ], + want_layout=False, + trace=args.trace, + ) + pan_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + pan_image, + out_dir, + "left_drag_pan", + env, + [ + "--mouse-pos-image-rel", + "0.50", + "0.50", + "--mouse-drag", + "220", + "0", + "--mouse-drag-button", + "0", + ], + want_layout=False, + trace=args.trace, + ) + except subprocess.TimeoutExpired as exc: + return _fail(str(exc)) + except (subprocess.SubprocessError, RuntimeError) as exc: + return _fail(str(exc)) + + if not select_state.get("selection_active", False): + return _fail("selection drag did not activate a selection") + if not _selection_bounds_valid(select_state): + return _fail("selection drag did not produce valid selection bounds") + if not _area_probe_is_initialized(select_state): + return _fail("selection drag did not initialize area probe statistics") + + if not _selection_is_cleared(pan_state): + return _fail("left drag pan created or retained a selection") + scroll = pan_state.get("scroll", [0.0, 0.0]) + scroll_x = float(scroll[0]) if isinstance(scroll, list) and len(scroll) > 0 else 0.0 + scroll_y = float(scroll[1]) if isinstance(scroll, list) and len(scroll) > 1 else 0.0 + if abs(scroll_x) < 1.0 and abs(scroll_y) < 1.0: + return _fail("left drag pan did not move the viewport") + + print(f"select_drag: {select_state['_state_path']}") + print(f"left_drag_pan: {pan_state['_state_path']}") + print(f"artifacts: {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_opengl_smoke_regression.py b/src/imiv/tools/imiv_opengl_smoke_regression.py new file mode 100644 index 0000000000..f2ab655e07 --- /dev/null +++ b/src/imiv/tools/imiv_opengl_smoke_regression.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Basic screenshot smoke test for the OpenGL imiv backend.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "OpenGL preview draw failed", + "OpenGL OCIO preview draw failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = repo_root / "build_u" / "imiv_captures" / "opengl_smoke_regression" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--open", default=str(default_image), help="Image to open") + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + image_path = Path(args.open).resolve() + if not image_path.exists(): + print(f"error: image not found: {image_path}", file=sys.stderr) + return 2 + + env = _load_env_from_script(Path(args.env_script).resolve()) + env["IMIV_CONFIG_HOME"] = str(out_dir / "cfg") + + screenshot_path = out_dir / "opengl_smoke.png" + layout_path = out_dir / "opengl_smoke.layout.json" + state_path = out_dir / "opengl_smoke.state.json" + log_path = out_dir / "opengl_smoke.log" + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.extend( + [ + "--open", + str(image_path), + "--screenshot-out", + str(screenshot_path), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + ] + ) + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + print(f"error: runner exited with code {proc.returncode}", file=sys.stderr) + return 1 + + for required in (screenshot_path, layout_path, state_path): + if not required.exists(): + print(f"error: missing output: {required}", file=sys.stderr) + return 1 + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + print(f"error: found runtime error pattern: {pattern}", file=sys.stderr) + return 1 + + layout = json.loads(layout_path.read_text(encoding="utf-8")) + if not any(window.get("name") == "Image" for window in layout.get("windows", [])): + print("error: layout dump missing Image window", file=sys.stderr) + return 1 + + state = json.loads(state_path.read_text(encoding="utf-8")) + current_path = state.get("image_path") or "" + if not current_path: + print("error: state dump missing image_path", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_rgb_input_regression.py b/src/imiv/tools/imiv_rgb_input_regression.py new file mode 100644 index 0000000000..b2e24e2cd2 --- /dev/null +++ b/src/imiv/tools/imiv_rgb_input_regression.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Regression check for loading a true RGB input image in imiv.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "OpenGL texture upload failed", + "OpenGL preview draw failed", + "OpenGL OCIO preview draw failed", + "failed to create Metal source texture", + "failed to create Metal upload pipeline", + "failed to create Metal source upload buffer", + "Metal source upload compute dispatch failed", + "failed to create Metal preview texture", + "Metal preview render failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + which = shutil.which("oiiotool") + return Path(which) if which else candidates[0] + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run_checked(cmd: list[str], *, cwd: Path) -> None: + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def _generate_rgb_fixture(oiiotool: Path, source_path: Path, out_path: Path) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + _run_checked( + [ + str(oiiotool), + str(source_path), + "--ch", + "R,G,B", + "-d", + "uint8", + "-o", + str(out_path), + ], + cwd=out_path.parent, + ) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_source = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable" + ) + ap.add_argument("--env-script", default="", help="Optional shell env setup script") + ap.add_argument("--out-dir", default="", help="Output directory") + ap.add_argument( + "--source-image", + default=str(default_source), + help="Source image used to generate a 3-channel RGB fixture", + ) + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + oiiotool = Path(args.oiiotool).resolve() + if not oiiotool.exists(): + print(f"error: oiiotool not found: {oiiotool}", file=sys.stderr) + return 2 + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + if args.out_dir: + out_dir = Path(args.out_dir).resolve() + else: + out_dir = exe.parent.parent / "imiv_captures" / "rgb_input_regression" + out_dir.mkdir(parents=True, exist_ok=True) + + source_path = Path(args.source_image).resolve() + if not source_path.exists(): + print(f"error: source image not found: {source_path}", file=sys.stderr) + return 2 + + env_script = ( + Path(args.env_script).resolve() + if args.env_script + else _default_env_script(repo_root, exe) + ) + env = _load_env_from_script(env_script) + env["IMIV_CONFIG_HOME"] = str(out_dir / "cfg") + + rgb_fixture = out_dir / "rgb_input_fixture_u8.tif" + _generate_rgb_fixture(oiiotool, source_path, rgb_fixture) + + layout_path = out_dir / "rgb_input.layout.json" + state_path = out_dir / "rgb_input.state.json" + log_path = out_dir / "rgb_input.log" + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.extend( + [ + "--open", + str(rgb_fixture), + "--layout-json-out", + str(layout_path), + "--layout-items", + "--state-json-out", + str(state_path), + "--post-action-delay-frames", + "2", + ] + ) + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + print(f"error: runner exited with code {proc.returncode}", file=sys.stderr) + return 1 + + for required in (layout_path, state_path): + if not required.exists(): + print(f"error: missing output: {required}", file=sys.stderr) + return 1 + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + print(f"error: found runtime error pattern: {pattern}", file=sys.stderr) + return 1 + + layout = json.loads(layout_path.read_text(encoding="utf-8")) + if not any(window.get("name") == "Image" for window in layout.get("windows", [])): + print("error: layout dump missing Image window", file=sys.stderr) + return 1 + + state = json.loads(state_path.read_text(encoding="utf-8")) + if not state.get("image_loaded"): + print("error: state dump says image is not loaded", file=sys.stderr) + return 1 + + current_path = state.get("image_path") or "" + if not current_path: + print("error: state dump missing image_path", file=sys.stderr) + return 1 + try: + current_resolved = Path(current_path).resolve() + except Exception: + current_resolved = Path(current_path) + if current_resolved != rgb_fixture.resolve(): + print( + f"error: loaded path mismatch: expected {rgb_fixture}, got {current_path}", + file=sys.stderr, + ) + return 1 + + image_size = state.get("image_size", [0, 0]) + if ( + not isinstance(image_size, list) + or len(image_size) != 2 + or int(image_size[0]) <= 0 + or int(image_size[1]) <= 0 + ): + print(f"error: invalid image_size in state dump: {image_size}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_sampling_regression.py b/src/imiv/tools/imiv_sampling_regression.py new file mode 100644 index 0000000000..7cb841dfa1 --- /dev/null +++ b/src/imiv/tools/imiv_sampling_regression.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 +"""Regression check for nearest vs linear preview sampling in a single app run.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +ERROR_PATTERNS = ( + "error: imiv exited with code", + "OpenGL texture upload failed", + "OpenGL preview draw failed", + "OpenGL OCIO preview draw failed", + "failed to create Metal source texture", + "failed to create Metal upload pipeline", + "failed to create Metal source upload buffer", + "Metal source upload compute dispatch failed", + "failed to create Metal preview texture", + "Metal preview render failed", + "screenshot failed: framebuffer readback failed", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _write_prefs(config_home: Path) -> Path: + prefs_dir = config_home / "OpenImageIO" / "imiv" + prefs_dir.mkdir(parents=True, exist_ok=True) + prefs_path = prefs_dir / "imiv.inf" + prefs_text = ( + "[ImivApp][State]\n" + "linear_interpolation=0\n" + "fit_image_to_window=1\n" + "pixelview_follows_mouse=1\n" + "show_mouse_mode_selector=0\n" + ) + prefs_path.write_text(prefs_text, encoding="utf-8") + return prefs_path + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + root.set("layout_items", "true") + + _scenario_step( + root, + "nearest", + delay_frames=3, + linear_interpolation=False, + screenshot=True, + layout=True, + ) + _scenario_step( + root, + "linear", + linear_interpolation=True, + post_action_delay_frames=2, + screenshot=True, + layout=True, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _image_crop_rect(layout_path: Path) -> tuple[int, int, int, int]: + data = json.loads(layout_path.read_text(encoding="utf-8")) + image_window = None + for window in data.get("windows", []): + if window.get("name") == "Image": + image_window = window + break + if image_window is None: + raise RuntimeError(f"{layout_path.name}: missing Image window") + + viewport_id = image_window.get("viewport_id") + origin_x = float(image_window["rect"]["min"][0]) + origin_y = float(image_window["rect"]["min"][1]) + for window in data.get("windows", []): + if window.get("viewport_id") != viewport_id: + continue + rect = window.get("rect") + if not rect: + continue + origin_x = min(origin_x, float(rect["min"][0])) + origin_y = min(origin_y, float(rect["min"][1])) + + chosen_rect = None + for item in image_window.get("items", []): + debug = item.get("debug") or "" + if debug == "image: Image" or "image_canvas" in debug: + chosen_rect = item.get("rect_clipped") or item.get("rect_full") + break + + if chosen_rect is None: + best_rect = None + best_area = -1.0 + for item in image_window.get("items", []): + rect = item.get("rect_clipped") or item.get("rect_full") + if not rect: + continue + min_v = rect.get("min") + max_v = rect.get("max") + if not min_v or not max_v: + continue + width = max(0.0, float(max_v[0]) - float(min_v[0])) + height = max(0.0, float(max_v[1]) - float(min_v[1])) + area = width * height + if area > best_area: + best_area = area + best_rect = rect + chosen_rect = best_rect + + if chosen_rect is None: + raise RuntimeError(f"{layout_path.name}: missing image rect") + + x0 = int(math.floor(float(chosen_rect["min"][0]) - origin_x)) + 1 + y0 = int(math.floor(float(chosen_rect["min"][1]) - origin_y)) + 1 + x1 = int(math.ceil(float(chosen_rect["max"][0]) - origin_x)) - 2 + y1 = int(math.ceil(float(chosen_rect["max"][1]) - origin_y)) - 2 + if x1 <= x0 or y1 <= y0: + raise RuntimeError(f"{layout_path.name}: invalid crop rect") + return x0, y0, x1, y1 + + +def _crop_to_ppm( + oiiotool: Path, source: Path, crop_rect: tuple[int, int, int, int], dest: Path +) -> None: + x0, y0, x1, y1 = crop_rect + subprocess.run( + [ + str(oiiotool), + str(source), + "--cut", + f"{x0},{y0},{x1},{y1}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _resize_to_ppm( + oiiotool: Path, source: Path, width: int, height: int, dest: Path +) -> None: + subprocess.run( + [ + str(oiiotool), + str(source), + "--resize", + f"{width}x{height}", + "--ch", + "R,G,B", + "-o", + str(dest), + ], + check=True, + ) + + +def _read_ppm(path: Path) -> tuple[int, int, bytes]: + with path.open("rb") as handle: + magic = handle.readline().strip() + if magic != b"P6": + raise RuntimeError(f"{path.name}: unsupported PPM format {magic!r}") + + def _next_non_comment() -> bytes: + while True: + line = handle.readline() + if not line: + raise RuntimeError(f"{path.name}: truncated PPM header") + line = line.strip() + if not line or line.startswith(b"#"): + continue + return line + + dims = _next_non_comment().split() + if len(dims) != 2: + raise RuntimeError(f"{path.name}: invalid PPM dimensions") + width = int(dims[0]) + height = int(dims[1]) + max_value = int(_next_non_comment()) + if max_value != 255: + raise RuntimeError(f"{path.name}: unsupported max value {max_value}") + + pixels = handle.read(width * height * 3) + if len(pixels) != width * height * 3: + raise RuntimeError(f"{path.name}: truncated PPM payload") + return width, height, pixels + + +def _mean_abs_diff(lhs: Path, rhs: Path) -> float: + lhs_w, lhs_h, lhs_pixels = _read_ppm(lhs) + rhs_w, rhs_h, rhs_pixels = _read_ppm(rhs) + if (lhs_w, lhs_h) != (rhs_w, rhs_h): + raise RuntimeError( + f"image size mismatch: {lhs.name}={lhs_w}x{lhs_h}, {rhs.name}={rhs_w}x{rhs_h}" + ) + total = 0 + for a, b in zip(lhs_pixels, rhs_pixels): + total += abs(a - b) + return total / float(len(lhs_pixels)) + + +def _midtone_fraction(path: Path) -> float: + _, _, pixels = _read_ppm(path) + midtone = 0 + pixel_count = len(pixels) // 3 + for index in range(0, len(pixels), 3): + value = (int(pixels[index]) + int(pixels[index + 1]) + int(pixels[index + 2])) // 3 + if 8 < value < 247: + midtone += 1 + return midtone / float(pixel_count) + + +def _build_fixture(oiiotool: Path, path: Path) -> None: + subprocess.run( + [ + str(oiiotool), + "--pattern", + "checker:width=1:height=1:color1=0,0,0:color2=1,1,1", + "17x17", + "3", + "-d", + "uint8", + "-o", + str(path), + ], + check=True, + ) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--env-script", default="", help="Optional shell env setup script") + ap.add_argument("--out-dir", default="", help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable runner tracing") + args = ap.parse_args() + + exe = Path(args.bin).resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if found is None: + print(f"error: oiiotool not found: {oiiotool}", file=sys.stderr) + return 2 + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + cwd = Path(args.cwd).resolve() if args.cwd else exe.parent.resolve() + out_dir = ( + Path(args.out_dir).resolve() + if args.out_dir + else exe.parent.parent / "imiv_captures" / "sampling_regression" + ) + out_dir.mkdir(parents=True, exist_ok=True) + + env_script = ( + Path(args.env_script).resolve() + if args.env_script + else _default_env_script(repo_root, exe) + ) + env = _load_env_from_script(env_script) + env["IMIV_CONFIG_HOME"] = str(out_dir / "cfg") + _write_prefs(Path(env["IMIV_CONFIG_HOME"])) + + image_path = out_dir / "sampling_checker_input.tif" + _build_fixture(oiiotool, image_path) + + scenario_path = out_dir / "sampling.scenario.xml" + runtime_dir = out_dir / "runtime" + runtime_dir_rel = _path_for_imiv_output(runtime_dir, cwd) + _write_scenario(scenario_path, runtime_dir_rel=runtime_dir_rel) + + log_path = out_dir / "sampling.log" + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.extend(["--open", str(image_path), "--scenario", str(scenario_path)]) + if args.trace: + cmd.append("--trace") + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=120, + ) + if proc.returncode != 0: + print(f"error: runner exited with code {proc.returncode}", file=sys.stderr) + return 1 + + nearest_screenshot = runtime_dir / "nearest.png" + nearest_layout = runtime_dir / "nearest.layout.json" + linear_screenshot = runtime_dir / "linear.png" + linear_layout = runtime_dir / "linear.layout.json" + for required in ( + nearest_screenshot, + nearest_layout, + linear_screenshot, + linear_layout, + ): + if not required.exists(): + print(f"error: missing output: {required}", file=sys.stderr) + return 1 + + log_text = log_path.read_text(encoding="utf-8", errors="ignore") + for pattern in ERROR_PATTERNS: + if pattern in log_text: + print(f"error: found runtime error pattern: {pattern}", file=sys.stderr) + return 1 + + crop_dir = out_dir / "crops" + crop_dir.mkdir(parents=True, exist_ok=True) + nearest_crop = crop_dir / "nearest.ppm" + linear_crop = crop_dir / "linear.ppm" + _crop_to_ppm(oiiotool, nearest_screenshot, _image_crop_rect(nearest_layout), nearest_crop) + _crop_to_ppm(oiiotool, linear_screenshot, _image_crop_rect(linear_layout), linear_crop) + + nearest_w, nearest_h, _ = _read_ppm(nearest_crop) + linear_w, linear_h, _ = _read_ppm(linear_crop) + common_w = min(nearest_w, linear_w) + common_h = min(nearest_h, linear_h) + if common_w <= 0 or common_h <= 0: + print("error: invalid normalized crop size", file=sys.stderr) + return 1 + + nearest_norm = crop_dir / "nearest.norm.ppm" + linear_norm = crop_dir / "linear.norm.ppm" + _resize_to_ppm(oiiotool, nearest_crop, common_w, common_h, nearest_norm) + _resize_to_ppm(oiiotool, linear_crop, common_w, common_h, linear_norm) + + diff = _mean_abs_diff(nearest_norm, linear_norm) + nearest_midtones = _midtone_fraction(nearest_norm) + linear_midtones = _midtone_fraction(linear_norm) + + print(f"nearest: {nearest_norm}") + print(f"linear: {linear_norm}") + print( + "scores: " + f"mean_abs_diff={diff:.4f}, " + f"nearest_midtones={nearest_midtones:.4f}, " + f"linear_midtones={linear_midtones:.4f}" + ) + + if diff < 8.0: + print( + f"error: nearest and linear preview crops are too similar (mean abs diff={diff:.4f})", + file=sys.stderr, + ) + return 1 + if nearest_midtones > 0.35: + print( + "error: nearest preview still looks blurred " + f"(midtone fraction={nearest_midtones:.4f})", + file=sys.stderr, + ) + return 1 + if linear_midtones <= nearest_midtones + 0.10: + print( + "error: linear preview does not look materially smoother than nearest " + f"(nearest={nearest_midtones:.4f}, linear={linear_midtones:.4f})", + file=sys.stderr, + ) + return 1 + + print(f"ok: sampling regression outputs are in {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_save_selection_regression.py b/src/imiv/tools/imiv_save_selection_regression.py new file mode 100644 index 0000000000..1774204978 --- /dev/null +++ b/src/imiv/tools/imiv_save_selection_regression.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Regression check for GUI-driven Save Selection As crop export.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "idiff", + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "src" / "idiff" / "idiff", + repo_root / "build_u" / "src" / "idiff" / "idiff", + repo_root / "build" / "Debug" / "idiff.exe", + repo_root / "build" / "Release" / "idiff.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run(cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + print("run:", " ".join(cmd)) + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + root.set("layout_items", "true") + + _scenario_step( + root, + "enable_area_sample", + key_chord="ctrl+a", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_drag", + mouse_pos_image_rel="0.18,0.25", + mouse_drag="180,120", + mouse_drag_button=0, + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "save_selection", + key_chord="ctrl+alt+s", + state=True, + post_action_delay_frames=6, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + default_out_dir = repo_root / "build" / "imiv_captures" / "save_selection_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--backend", default="", help="Optional runtime backend override") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff executable") + ap.add_argument("--env-script", default=str(_default_env_script(repo_root)), help="Optional shell env setup script") + ap.add_argument("--image", default=str(default_image), help="Input image path") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + if not runner.exists(): + return _fail(f"runner not found: {runner}") + image = Path(args.image).expanduser().resolve() + if not image.exists(): + return _fail(f"image not found: {image}") + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if not found: + return _fail(f"oiiotool not found: {oiiotool}") + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + idiff = Path(args.idiff).expanduser() + if not idiff.exists(): + found = shutil.which(str(idiff)) + if not found: + return _fail(f"idiff not found: {idiff}") + idiff = Path(found) + idiff = idiff.resolve() + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + scenario_path = out_dir / "save_selection.scenario.xml" + select_state_path = runtime_dir / "select_drag.state.json" + save_state_path = runtime_dir / "save_selection.state.json" + log_path = out_dir / "save_selection.log" + fixture_path = out_dir / "save_selection_input.png" + saved_path = out_dir / "saved_selection.tif" + expected_path = out_dir / "expected_selection.tif" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + prep = _run( + [ + str(oiiotool), + str(image), + "--resize", + "2200x1547", + "-o", + str(fixture_path), + ], + cwd=repo_root, + ) + if prep.returncode != 0: + print(prep.stdout, end="") + return _fail("failed to prepare save-selection fixture") + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + env["IMIV_TEST_SAVE_IMAGE_PATH"] = str(saved_path) + + _write_scenario(scenario_path, _path_for_imiv_output(runtime_dir, cwd)) + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(fixture_path), + "--scenario", + str(scenario_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + proc = _run(cmd, cwd=repo_root, env=env) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not select_state_path.exists(): + return _fail(f"selection state output not found: {select_state_path}") + if not save_state_path.exists(): + return _fail(f"save state output not found: {save_state_path}") + if not saved_path.exists(): + return _fail(f"saved selection output not found: {saved_path}") + + select_state = json.loads(select_state_path.read_text(encoding="utf-8")) + bounds = [int(v) for v in select_state.get("selection_bounds", [])] + if len(bounds) != 4: + return _fail("selection_bounds missing from selection state") + xbegin, ybegin, xend, yend = bounds + if xend <= xbegin or yend <= ybegin: + return _fail(f"invalid selection bounds: {bounds}") + + expected = _run( + [ + str(oiiotool), + str(fixture_path), + "--cut", + f"{xend - xbegin}x{yend - ybegin}+{xbegin}+{ybegin}", + "-o", + str(expected_path), + ], + cwd=repo_root, + ) + if expected.returncode != 0: + print(expected.stdout, end="") + return _fail("failed to generate expected crop") + + diff = _run([str(idiff), "-q", "-a", str(expected_path), str(saved_path)], cwd=repo_root) + if diff.returncode != 0: + print(diff.stdout, end="") + return _fail("saved selection did not match expected crop") + + print(f"fixture: {fixture_path}") + print(f"select_state: {select_state_path}") + print(f"save_state: {save_state_path}") + print(f"saved: {saved_path}") + print(f"expected: {expected_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_save_window_ocio_regression.py b/src/imiv/tools/imiv_save_window_ocio_regression.py new file mode 100644 index 0000000000..5d4743e8e8 --- /dev/null +++ b/src/imiv/tools/imiv_save_window_ocio_regression.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Regression check for GUI-driven Export As OCIO export.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "idiff", + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "src" / "idiff" / "idiff", + repo_root / "build_u" / "src" / "idiff" / "idiff", + repo_root / "build" / "Debug" / "idiff.exe", + repo_root / "build" / "Release" / "idiff.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run( + cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + print("run:", " ".join(cmd)) + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | float | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + _scenario_step( + root, + "enable_ocio_recipe", + ocio_use=True, + ocio_image_color_space="ACEScg", + exposure=0.0, + gamma=1.0, + offset=0.0, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "save_window", + key_chord="ctrl+shift+s", + state=True, + post_action_delay_frames=6, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _assert_close(actual: float, expected: float, name: str) -> None: + if not math.isclose(actual, expected, rel_tol=1.0e-5, abs_tol=1.0e-5): + raise AssertionError(f"{name} mismatch: expected {expected}, got {actual}") + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr" + default_out_dir = repo_root / "build" / "imiv_captures" / "save_window_ocio_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--backend", default="", help="Optional runtime backend override") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff executable") + ap.add_argument("--env-script", default=str(_default_env_script(repo_root)), help="Optional shell env setup script") + ap.add_argument("--image", default=str(default_image), help="Input image path") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + if not runner.exists(): + return _fail(f"runner not found: {runner}") + image = Path(args.image).expanduser().resolve() + if not image.exists(): + return _fail(f"image not found: {image}") + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if not found: + return _fail(f"oiiotool not found: {oiiotool}") + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + idiff = Path(args.idiff).expanduser() + if not idiff.exists(): + found = shutil.which(str(idiff)) + if not found: + return _fail(f"idiff not found: {idiff}") + idiff = Path(found) + idiff = idiff.resolve() + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + scenario_path = out_dir / "save_window_ocio.scenario.xml" + recipe_state_path = runtime_dir / "enable_ocio_recipe.state.json" + save_state_path = runtime_dir / "save_window.state.json" + log_path = out_dir / "save_window_ocio.log" + saved_path = out_dir / "saved_window_ocio.tif" + expected_path = out_dir / "expected_window_ocio.tif" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + env["IMIV_TEST_SAVE_IMAGE_PATH"] = str(saved_path) + + _write_scenario(scenario_path, _path_for_imiv_output(runtime_dir, cwd)) + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(image), + "--scenario", + str(scenario_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + proc = _run(cmd, cwd=repo_root, env=env) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not recipe_state_path.exists(): + return _fail(f"recipe state output not found: {recipe_state_path}") + if not save_state_path.exists(): + return _fail(f"save state output not found: {save_state_path}") + if not saved_path.exists(): + return _fail(f"saved window output not found: {saved_path}") + + recipe_state = json.loads(recipe_state_path.read_text(encoding="utf-8")) + recipe = recipe_state.get("view_recipe", {}) + ocio = recipe_state.get("ocio", {}) + try: + _assert_close(float(recipe.get("exposure", 0.0)), 0.0, "window exposure") + _assert_close(float(recipe.get("gamma", 0.0)), 1.0, "window gamma") + _assert_close(float(recipe.get("offset", 0.0)), 0.0, "window offset") + except (TypeError, ValueError, AssertionError) as exc: + return _fail(str(exc)) + if not bool(recipe.get("use_ocio", False)): + return _fail("window export did not keep OCIO enabled") + + resolved_display = str(ocio.get("resolved_display", "")).strip() + resolved_view = str(ocio.get("resolved_view", "")).strip() + if not resolved_display or not resolved_view: + return _fail("resolved OCIO display/view missing from state output") + + expect = _run( + [ + str(oiiotool), + str(image), + "--ociodisplay:from=ACEScg", + resolved_display, + resolved_view, + "-d", + "float", + "-o", + str(expected_path), + ], + cwd=repo_root, + ) + if expect.returncode != 0: + print(expect.stdout, end="") + return _fail("failed to prepare expected OCIO save-window output") + + diff = _run( + [ + str(idiff), + "-q", + "-a", + str(expected_path), + str(saved_path), + ], + cwd=repo_root, + ) + if diff.returncode != 0: + print(diff.stdout, end="") + return _fail("saved OCIO window output does not match expected recipe") + + print(f"recipe_state: {recipe_state_path}") + print(f"save_state: {save_state_path}") + print(f"saved: {saved_path}") + print(f"expected: {expected_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_save_window_regression.py b/src/imiv/tools/imiv_save_window_regression.py new file mode 100644 index 0000000000..e563d621ea --- /dev/null +++ b/src/imiv/tools/imiv_save_window_regression.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Regression check for GUI-driven Export As recipe export.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("oiiotool") + + +def _default_idiff(repo_root: Path) -> Path: + candidates = [ + repo_root / "build" / "bin" / "idiff", + repo_root / "build_u" / "bin" / "idiff", + repo_root / "build" / "src" / "idiff" / "idiff", + repo_root / "build_u" / "src" / "idiff" / "idiff", + repo_root / "build" / "Debug" / "idiff.exe", + repo_root / "build" / "Release" / "idiff.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return Path("idiff") + + +def _default_env_script(repo_root: Path, exe: Path | None = None) -> Path: + candidates: list[Path] = [] + if exe is not None: + exe = exe.resolve() + candidates.extend([exe.parent / "imiv_env.sh", exe.parent.parent / "imiv_env.sh"]) + candidates.extend([repo_root / "build" / "imiv_env.sh", repo_root / "build_u" / "imiv_env.sh"]) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run( + cmd: list[str], *, cwd: Path, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + print("run:", " ".join(cmd)) + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | float | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + _scenario_step( + root, + "set_blue_channel", + key_chord="b", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "set_single_channel", + key_chord="1", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "adjust_recipe", + exposure=0.75, + gamma=1.6, + offset=0.125, + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "save_window", + key_chord="ctrl+shift+s", + state=True, + post_action_delay_frames=6, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _assert_close(actual: float, expected: float, name: str) -> None: + if not math.isclose(actual, expected, rel_tol=1.0e-5, abs_tol=1.0e-5): + raise AssertionError(f"{name} mismatch: expected {expected}, got {actual}") + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + default_out_dir = repo_root / "build" / "imiv_captures" / "save_window_regression" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument("--backend", default="", help="Optional runtime backend override") + ap.add_argument("--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable") + ap.add_argument("--idiff", default=str(_default_idiff(repo_root)), help="idiff executable") + ap.add_argument("--env-script", default=str(_default_env_script(repo_root)), help="Optional shell env setup script") + ap.add_argument("--image", default=str(default_image), help="Input image path") + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + if not runner.exists(): + return _fail(f"runner not found: {runner}") + image = Path(args.image).expanduser().resolve() + if not image.exists(): + return _fail(f"image not found: {image}") + + oiiotool = Path(args.oiiotool).expanduser() + if not oiiotool.exists(): + found = shutil.which(str(oiiotool)) + if not found: + return _fail(f"oiiotool not found: {oiiotool}") + oiiotool = Path(found) + oiiotool = oiiotool.resolve() + + idiff = Path(args.idiff).expanduser() + if not idiff.exists(): + found = shutil.which(str(idiff)) + if not found: + return _fail(f"idiff not found: {idiff}") + idiff = Path(found) + idiff = idiff.resolve() + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + scenario_path = out_dir / "save_window.scenario.xml" + adjust_state_path = runtime_dir / "adjust_recipe.state.json" + save_state_path = runtime_dir / "save_window.state.json" + log_path = out_dir / "save_window.log" + fixture_path = out_dir / "save_window_input.png" + saved_path = out_dir / "saved_window.tif" + expected_path = out_dir / "expected_window.tif" + + shutil.rmtree(out_dir, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + prep = _run( + [ + str(oiiotool), + str(image), + "--resize", + "2200x1547", + "-o", + str(fixture_path), + ], + cwd=repo_root, + ) + if prep.returncode != 0: + print(prep.stdout, end="") + return _fail("failed to prepare save-window fixture") + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + env["IMIV_TEST_SAVE_IMAGE_PATH"] = str(saved_path) + + _write_scenario(scenario_path, _path_for_imiv_output(runtime_dir, cwd)) + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(fixture_path), + "--scenario", + str(scenario_path), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + proc = _run(cmd, cwd=repo_root, env=env) + log_path.write_text(proc.stdout, encoding="utf-8") + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + if not adjust_state_path.exists(): + return _fail(f"adjust state output not found: {adjust_state_path}") + if not save_state_path.exists(): + return _fail(f"save state output not found: {save_state_path}") + if not saved_path.exists(): + return _fail(f"saved window output not found: {saved_path}") + + adjust_state = json.loads(adjust_state_path.read_text(encoding="utf-8")) + recipe = adjust_state.get("view_recipe", {}) + try: + _assert_close(float(recipe.get("exposure", 0.0)), 0.75, "window exposure") + _assert_close(float(recipe.get("gamma", 0.0)), 1.6, "window gamma") + _assert_close(float(recipe.get("offset", 0.0)), 0.125, "window offset") + except (TypeError, ValueError, AssertionError) as exc: + return _fail(str(exc)) + if int(recipe.get("current_channel", -1)) != 3: + return _fail("window export did not keep blue channel selection") + if int(recipe.get("color_mode", -1)) != 2: + return _fail("window export did not keep single-channel mode") + + exposure_scale = 2.0 ** 0.75 + inv_gamma = 1.0 / 1.6 + expect = _run( + [ + str(oiiotool), + str(fixture_path), + "--ch", + "B,B,B,A=1.0", + "--addc", + "0.125,0.125,0.125,0.0", + "--mulc", + f"{exposure_scale},{exposure_scale},{exposure_scale},1.0", + "--powc", + f"{inv_gamma},{inv_gamma},{inv_gamma},1.0", + "-d", + "float", + "-o", + str(expected_path), + ], + cwd=repo_root, + ) + if expect.returncode != 0: + print(expect.stdout, end="") + return _fail("failed to prepare expected save-window output") + + diff = _run( + [ + str(idiff), + "-q", + "-a", + str(expected_path), + str(saved_path), + ], + cwd=repo_root, + ) + if diff.returncode != 0: + print(diff.stdout, end="") + return _fail("saved window output does not match expected recipe") + + print(f"fixture: {fixture_path}") + print(f"adjust_state: {adjust_state_path}") + print(f"save_state: {save_state_path}") + print(f"saved: {saved_path}") + print(f"expected: {expected_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_selection_regression.py b/src/imiv/tools/imiv_selection_regression.py new file mode 100644 index 0000000000..08ef035d35 --- /dev/null +++ b/src/imiv/tools/imiv_selection_regression.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Regression check for persistent image selection interactions.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + which = shutil.which("oiiotool") + return Path(which) if which else candidates[0] + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run_checked(cmd: list[str], *, cwd: Path) -> None: + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def _generate_fixture(oiiotool: Path, out_path: Path, width: int, height: int) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + _run_checked( + [ + str(oiiotool), + "--pattern", + "fill:top=0.15,0.18,0.22,bottom=0.75,0.82,0.95", + f"{width}x{height}", + "3", + "-d", + "uint8", + "-o", + str(out_path), + ], + cwd=out_path.parent, + ) + + +def _run_case( + repo_root: Path, + runner: Path, + exe: Path, + cwd: Path, + image_path: Path, + out_dir: Path, + name: str, + extra_args: list[str], + env: dict[str, str], + extra_env: dict[str, str] | None, + trace: bool, + want_layout: bool = False, +) -> tuple[dict, dict | None]: + state_path = out_dir / f"{name}.state.json" + layout_path = out_dir / f"{name}.layout.json" + log_path = out_dir / f"{name}.log" + config_home = out_dir / f"cfg_{name}" + shutil.rmtree(config_home, ignore_errors=True) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + "--open", + str(image_path), + "--state-json-out", + str(state_path), + "--post-action-delay-frames", + "2", + ] + if want_layout: + cmd.extend(["--layout-json-out", str(layout_path), "--layout-items"]) + if trace: + cmd.append("--trace") + cmd.extend(extra_args) + + case_env = dict(env) + case_env["IMIV_CONFIG_HOME"] = str(config_home) + if extra_env: + case_env.update(extra_env) + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=case_env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=90, + ) + if proc.returncode != 0: + raise RuntimeError(f"{name}: runner exited with code {proc.returncode}") + if not state_path.exists(): + raise RuntimeError(f"{name}: state file not written") + + state = json.loads(state_path.read_text(encoding="utf-8")) + state["_state_path"] = str(state_path) + state["_log_path"] = str(log_path) + + layout = None + if want_layout: + if not layout_path.exists(): + raise RuntimeError(f"{name}: layout file not written") + layout = json.loads(layout_path.read_text(encoding="utf-8")) + + return state, layout + + +def _layout_has_debug_label(layout: dict | None, label: str) -> bool: + if layout is None: + return False + for window in layout.get("windows", []): + for item in window.get("items", []): + if item.get("debug") == label: + return True + return False + + +def _area_probe_is_initialized(state: dict) -> bool: + for line in state.get("area_probe_lines", []): + if "-----" in line: + return False + return True + + +def _area_probe_is_reset(state: dict) -> bool: + return all( + "-----" in line or line == "Area Probe:" + for line in state.get("area_probe_lines", []) + ) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = repo_root / "build_u" / "imiv_captures" / "selection_regression" + default_drag_image = default_out_dir / "selection_drag_input.tif" + default_pan_image = default_out_dir / "selection_pan_input.tif" + default_viewport_image = default_out_dir / "selection_viewport_input.tif" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable" + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--drag-image", + default=str(default_drag_image), + help="Generated fixture used for drag/image-click selection cases", + ) + ap.add_argument( + "--pan-image", + default=str(default_pan_image), + help="Generated large fixture used for pan/no-selection case", + ) + ap.add_argument( + "--viewport-image", + default=str(default_viewport_image), + help="Generated fixture used for empty-viewport deselect case", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + oiiotool = Path(args.oiiotool).expanduser().resolve() + if not oiiotool.exists(): + return _fail(f"oiiotool not found: {oiiotool}") + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + drag_image = Path(args.drag_image).expanduser().resolve() + pan_image = Path(args.pan_image).expanduser().resolve() + viewport_image = Path(args.viewport_image).expanduser().resolve() + + try: + _generate_fixture(oiiotool, drag_image, 320, 240) + _generate_fixture(oiiotool, pan_image, 3072, 2048) + _generate_fixture(oiiotool, viewport_image, 320, 32) + except subprocess.SubprocessError as exc: + return _fail(f"failed to generate selection fixtures: {exc}") + + env = _load_env_from_script(Path(args.env_script).expanduser()) + area_env = {"IMIV_IMGUI_TEST_ENGINE_SHOW_AREA": "1"} + + try: + drag_state, drag_layout = _run_case( + repo_root, + runner, + exe, + cwd, + drag_image, + out_dir, + "drag_select", + [ + "--mouse-pos-image-rel", + "0.25", + "0.35", + "--mouse-drag", + "120", + "90", + "--mouse-drag-button", + "0", + ], + env, + area_env, + args.trace, + want_layout=True, + ) + select_all_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + drag_image, + out_dir, + "select_all", + ["--key-chord", "ctrl+shift+a"], + env, + area_env, + args.trace, + ) + deselect_image_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + drag_image, + out_dir, + "deselect_image_click", + [ + "--key-chord", + "ctrl+shift+a", + "--mouse-pos-image-rel", + "0.50", + "0.50", + "--mouse-click", + "0", + ], + env, + area_env, + args.trace, + ) + deselect_viewport_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + viewport_image, + out_dir, + "deselect_viewport_click", + [ + "--key-chord", + "ctrl+shift+a", + "--mouse-pos-window-rel", + "0.50", + "0.95", + "--mouse-click", + "0", + ], + env, + area_env, + args.trace, + ) + area_sample_pan_baseline_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + pan_image, + out_dir, + "area_sample_pan_baseline", + [], + env, + area_env, + args.trace, + ) + toggle_area_off_pan_state, _ = _run_case( + repo_root, + runner, + exe, + cwd, + pan_image, + out_dir, + "toggle_area_off_left_drag_pan", + [ + "--key-chord", + "ctrl+a", + "--mouse-pos-image-rel", + "0.50", + "0.50", + "--mouse-drag", + "180", + "120", + "--mouse-drag-button", + "0", + ], + env, + area_env, + args.trace, + ) + except (RuntimeError, subprocess.SubprocessError) as exc: + return _fail(str(exc)) + + for name, state in ( + ("drag_select", drag_state), + ("select_all", select_all_state), + ("deselect_image_click", deselect_image_state), + ("deselect_viewport_click", deselect_viewport_state), + ("area_sample_pan_baseline", area_sample_pan_baseline_state), + ("toggle_area_off_left_drag_pan", toggle_area_off_pan_state), + ): + if not state.get("image_loaded", False): + return _fail(f"{name}: image not loaded") + + if not drag_state.get("selection_active", False): + return _fail("drag_select: selection was not created") + drag_bounds = drag_state.get("selection_bounds", []) + if len(drag_bounds) != 4: + return _fail("drag_select: selection bounds missing") + if not ( + int(drag_bounds[2]) > int(drag_bounds[0]) + and int(drag_bounds[3]) > int(drag_bounds[1]) + ): + return _fail(f"drag_select: selection bounds are empty: {drag_bounds}") + if not _layout_has_debug_label(drag_layout, "rect: Image selection overlay"): + return _fail("drag_select: selection overlay was not present in layout dump") + if not _area_probe_is_initialized(drag_state): + return _fail("drag_select: area probe statistics were not initialized") + + image_size = select_all_state.get("image_size", []) + if len(image_size) != 2: + return _fail("select_all: image size missing") + expected_bounds = [0, 0, int(image_size[0]), int(image_size[1])] + if not select_all_state.get("selection_active", False): + return _fail("select_all: selection is not active") + if [int(v) for v in select_all_state.get("selection_bounds", [])] != expected_bounds: + return _fail( + "select_all: wrong selection bounds: " + f"expected {expected_bounds}, got {select_all_state.get('selection_bounds')}" + ) + if not _area_probe_is_initialized(select_all_state): + return _fail("select_all: area probe statistics were not initialized") + + if deselect_image_state.get("selection_active", False): + return _fail("deselect_image_click: selection remained active") + if any(int(v) != 0 for v in deselect_image_state.get("selection_bounds", [])): + return _fail( + "deselect_image_click: selection bounds were not cleared: " + f"{deselect_image_state.get('selection_bounds')}" + ) + if not _area_probe_is_reset(deselect_image_state): + return _fail("deselect_image_click: area probe was not reset") + + if deselect_viewport_state.get("selection_active", False): + return _fail("deselect_viewport_click: selection remained active") + if any(int(v) != 0 for v in deselect_viewport_state.get("selection_bounds", [])): + return _fail( + "deselect_viewport_click: selection bounds were not cleared: " + f"{deselect_viewport_state.get('selection_bounds')}" + ) + if not _area_probe_is_reset(deselect_viewport_state): + return _fail("deselect_viewport_click: area probe was not reset") + + if toggle_area_off_pan_state.get("selection_active", False): + return _fail("toggle_area_off_left_drag_pan: selection became active") + if any(int(v) != 0 for v in toggle_area_off_pan_state.get("selection_bounds", [])): + return _fail( + "toggle_area_off_left_drag_pan: selection bounds were not cleared: " + f"{toggle_area_off_pan_state.get('selection_bounds')}" + ) + if not _area_probe_is_reset(toggle_area_off_pan_state): + return _fail("toggle_area_off_left_drag_pan: area probe was not reset") + + baseline_scroll = [ + float(v) for v in area_sample_pan_baseline_state.get("scroll", [0.0, 0.0]) + ] + toggle_pan_scroll = [ + float(v) for v in toggle_area_off_pan_state.get("scroll", [0.0, 0.0]) + ] + if len(baseline_scroll) != 2 or len(toggle_pan_scroll) != 2: + return _fail("toggle_area_off_left_drag_pan: scroll state missing") + if baseline_scroll == toggle_pan_scroll: + return _fail( + "toggle_area_off_left_drag_pan: left drag did not pan the image: " + f"baseline={baseline_scroll}, after_drag={toggle_pan_scroll}" + ) + + print("drag_select:", drag_state["_state_path"]) + print("select_all:", select_all_state["_state_path"]) + print("deselect_image_click:", deselect_image_state["_state_path"]) + print("deselect_viewport_click:", deselect_viewport_state["_state_path"]) + print( + "area_sample_pan_baseline:", + area_sample_pan_baseline_state["_state_path"], + ) + print("toggle_area_off_left_drag_pan:", toggle_area_off_pan_state["_state_path"]) + print("artifacts:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_upload_corpus_ctest.sh b/src/imiv/tools/imiv_upload_corpus_ctest.sh new file mode 100644 index 0000000000..306c3c2431 --- /dev/null +++ b/src/imiv/tools/imiv_upload_corpus_ctest.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +build_dir="${1:-$repo_root/build_u}" +corpus_root="${build_dir}/testsuite/imiv/upload_corpus" +images_dir="${corpus_root}/images" +manifest_csv="${corpus_root}/corpus_manifest.csv" +results_dir="${corpus_root}/results" + +generate_script="${repo_root}/src/imiv/tools/imiv_generate_upload_corpus.sh" +smoke_script="${repo_root}/src/imiv/tools/imiv_upload_corpus_smoke_test.sh" + +if [[ -z "${IMIV_ENV_SCRIPT:-}" ]]; then + export IMIV_ENV_SCRIPT="${build_dir}/imiv_env.sh" +fi +if [[ -z "${OIIOTOOL_BIN:-}" ]]; then + export OIIOTOOL_BIN="${build_dir}/bin/oiiotool" +fi + +export PER_CASE_TIMEOUT="${PER_CASE_TIMEOUT:-90s}" +export RUNNER_TRACE="${RUNNER_TRACE:-0}" + +if ! [[ -d "${images_dir}" ]] \ + || ! find "${images_dir}" -maxdepth 1 -type f | read -r _; then + "${generate_script}" "${images_dir}" "${manifest_csv}" +fi + +"${smoke_script}" "${images_dir}" "${results_dir}" diff --git a/src/imiv/tools/imiv_upload_corpus_smoke.py b/src/imiv/tools/imiv_upload_corpus_smoke.py new file mode 100644 index 0000000000..144c3e23ad --- /dev/null +++ b/src/imiv/tools/imiv_upload_corpus_smoke.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Batched smoke test for the imiv upload corpus.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path | None) -> dict[str, str]: + env = dict(os.environ) + if script_path is None or not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _path_for_imiv_output(path: Path, run_cwd: Path) -> str: + try: + return os.path.relpath(path, run_cwd) + except ValueError: + return str(path) + + +def _parse_duration_seconds(text: str) -> int: + value = text.strip().lower() + if not value: + raise ValueError("empty duration") + if value[-1].isdigit(): + return max(1, int(value)) + + scale_map = {"s": 1, "m": 60, "h": 3600} + scale = scale_map.get(value[-1]) + if scale is None: + raise ValueError(f"unsupported duration suffix: {text}") + return max(1, int(value[:-1]) * scale) + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _chunks(items: list[Path], chunk_size: int) -> list[list[Path]]: + return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)] + + +def _step_name(index: int, image: Path) -> str: + stem = image.stem + safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in stem) + if not safe: + safe = "image" + return f"{index:03d}_{safe}" + + +@dataclass(frozen=True) +class BatchImage: + index: int + path: Path + step_name: str + screenshot_out: Path + state_out: Path + + +def _write_scenario( + path: Path, + *, + runtime_dir_rel: str, + images: list[BatchImage], + post_action_delay_frames: int, +) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + for image in images: + step = ET.SubElement(root, "step") + step.set("name", image.step_name) + step.set("image_list_select_index", str(image.index)) + step.set("state", "true") + step.set("screenshot", "true") + step.set("post_action_delay_frames", str(max(0, post_action_delay_frames))) + if image.index == 0: + step.set("image_list_visible", "true") + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _copy_if_exists(src: Path, dst: Path) -> None: + if not src.exists(): + return + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + + +def _validation_reason( + *, + state_path: Path, + expected_image: Path, + screenshot_path: Path, + batch_reason: str | None, +) -> tuple[str, str]: + if batch_reason is not None: + return "FAIL", batch_reason + if not screenshot_path.exists(): + return "FAIL", "no_screenshot_saved" + if not state_path.exists(): + return "FAIL", "no_state_saved" + + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "FAIL", "invalid_state_json" + if not bool(state.get("image_loaded", False)): + return "FAIL", "image_not_loaded" + + actual_path_value = str(state.get("image_path", "")).strip() + if not actual_path_value: + return "FAIL", "missing_image_path" + actual_path = Path(actual_path_value).expanduser() + if actual_path.exists(): + try: + actual_path = actual_path.resolve() + except OSError: + pass + try: + expected_resolved = expected_image.resolve() + except OSError: + expected_resolved = expected_image + + if actual_path != expected_resolved: + return "FAIL", "wrong_loaded_image" + + return "PASS", "ok" + + +def _run_batch( + *, + repo_root: Path, + runner: Path, + exe: Path, + corpus_images: list[Path], + batch_index: int, + result_dir: Path, + env: dict[str, str], + run_cwd: Path, + trace: bool, + timeout_seconds: int, + post_action_delay_frames: int, +) -> tuple[list[tuple[Path, str, str, Path, Path]], int]: + batch_id = f"batch_{batch_index:03d}" + runtime_dir = result_dir / "runtime" / batch_id + logs_dir = result_dir / "logs" + screenshots_dir = result_dir / "screenshots" + states_dir = result_dir / "states" + scenarios_dir = result_dir / "scenarios" + log_path = logs_dir / f"{batch_id}.log" + scenario_path = scenarios_dir / f"{batch_id}.scenario.xml" + + runtime_dir.mkdir(parents=True, exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) + screenshots_dir.mkdir(parents=True, exist_ok=True) + states_dir.mkdir(parents=True, exist_ok=True) + + batch_images = [ + BatchImage( + index=i, + path=image, + step_name=_step_name(i, image), + screenshot_out=screenshots_dir / f"{image.stem}.png", + state_out=states_dir / f"{image.stem}.state.json", + ) + for i, image in enumerate(corpus_images) + ] + + _write_scenario( + scenario_path, + runtime_dir_rel=_path_for_imiv_output(runtime_dir, run_cwd), + images=batch_images, + post_action_delay_frames=post_action_delay_frames, + ) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(run_cwd), + "--scenario", + str(scenario_path), + ] + for image in corpus_images: + cmd.extend(["--open", str(image)]) + if trace: + cmd.append("--trace") + + print(f"batch {batch_index + 1}: {len(corpus_images)} images") + print("run:", " ".join(cmd)) + + proc_stdout = "" + return_code = 0 + batch_reason: str | None = None + try: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + timeout=timeout_seconds, + ) + proc_stdout = proc.stdout or "" + return_code = proc.returncode + if return_code != 0: + batch_reason = f"runner_exit_{return_code}" + except subprocess.TimeoutExpired as exc: + proc_stdout = (exc.stdout or "") if isinstance(exc.stdout, str) else "" + return_code = 124 + batch_reason = f"timeout_{timeout_seconds}s" + + log_path.write_text(proc_stdout, encoding="utf-8") + + if batch_reason is None: + for pattern in ("upload failed", "vk[error][validation]", "VUID-"): + if pattern in proc_stdout: + batch_reason = "validation_or_upload_error" + break + + results: list[tuple[Path, str, str, Path, Path]] = [] + for batch_image in batch_images: + step_base = runtime_dir / batch_image.step_name + screenshot_path = step_base.with_suffix(".png") + state_path = step_base.with_suffix(".state.json") + _copy_if_exists(screenshot_path, batch_image.screenshot_out) + _copy_if_exists(state_path, batch_image.state_out) + result, reason = _validation_reason( + state_path=state_path, + expected_image=batch_image.path, + screenshot_path=screenshot_path, + batch_reason=batch_reason, + ) + results.append( + ( + batch_image.path, + result, + reason, + log_path, + batch_image.screenshot_out, + ) + ) + + return results, return_code + + +def main() -> int: + repo_root = _repo_root() + default_corpus_dir = repo_root / "build_u" / "testsuite" / "imiv" / "upload_corpus" / "images" + default_result_dir = repo_root / "build_u" / "testsuite" / "imiv" / "upload_corpus" / "results" + default_runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_bin = _default_binary(repo_root) + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--corpus-dir", default=str(default_corpus_dir), help="Corpus image directory") + ap.add_argument("--result-dir", default=str(default_result_dir), help="Output directory") + ap.add_argument("--runner", default=str(default_runner), help="imiv runner script") + ap.add_argument("--bin", default=str(default_bin), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv (default: binary dir)") + ap.add_argument("--env-script", default=str(default_env_script), help="Optional shell env setup script") + ap.add_argument( + "--per-case-timeout", + default="45s", + help="Baseline timeout per image; used to scale batch timeout", + ) + ap.add_argument( + "--batch-size", + type=int, + default=32, + help="Maximum number of images to verify in one imiv launch", + ) + ap.add_argument( + "--timeout-slop-seconds", + type=int, + default=15, + help="Extra seconds added per image when scaling batch timeout", + ) + ap.add_argument( + "--post-action-delay-frames", + type=int, + default=3, + help="Frames to wait after changing the active image before capture", + ) + ap.add_argument("--trace", action="store_true", help="Enable verbose runner trace") + args = ap.parse_args() + + corpus_dir = Path(args.corpus_dir).expanduser().resolve() + result_dir = Path(args.result_dir).expanduser().resolve() + runner = Path(args.runner).expanduser().resolve() + exe = Path(args.bin).expanduser().resolve() + env_script = Path(args.env_script).expanduser().resolve(strict=False) + + if not corpus_dir.is_dir(): + return _fail(f"corpus directory not found: {corpus_dir}") + if not runner.exists(): + return _fail(f"runner script not found: {runner}") + if not exe.exists(): + return _fail(f"imiv executable not found: {exe}") + if args.batch_size <= 0: + return _fail("batch-size must be greater than zero") + if args.timeout_slop_seconds < 0: + return _fail("timeout-slop-seconds must be non-negative") + + images = sorted( + path + for path in corpus_dir.iterdir() + if path.is_file() and path.suffix.lower() in {".tif", ".tiff", ".exr"} + ) + if not images: + return _fail(f"no corpus images found in {corpus_dir}") + + try: + per_case_timeout_seconds = _parse_duration_seconds(args.per_case_timeout) + except ValueError as exc: + return _fail(str(exc)) + + shutil.rmtree(result_dir, ignore_errors=True) + result_dir.mkdir(parents=True, exist_ok=True) + + summary_csv = result_dir / "summary.csv" + with summary_csv.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["image", "result", "reason", "log", "screenshot"]) + + env = _load_env_from_script(env_script if env_script.exists() else None) + run_cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + + pass_count = 0 + fail_count = 0 + + batches = _chunks(images, args.batch_size) + for batch_index, batch_images in enumerate(batches): + batch_env = dict(env) + batch_config_home = result_dir / "cfg" / f"batch_{batch_index:03d}" + batch_config_home.mkdir(parents=True, exist_ok=True) + batch_env["IMIV_CONFIG_HOME"] = str(batch_config_home) + batch_timeout_seconds = ( + per_case_timeout_seconds + args.timeout_slop_seconds * len(batch_images) + ) + + batch_results, _ = _run_batch( + repo_root=repo_root, + runner=runner, + exe=exe, + corpus_images=batch_images, + batch_index=batch_index, + result_dir=result_dir, + env=batch_env, + run_cwd=run_cwd, + trace=args.trace, + timeout_seconds=batch_timeout_seconds, + post_action_delay_frames=args.post_action_delay_frames, + ) + + with summary_csv.open("a", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + for image, result, reason, log_path, screenshot_path in batch_results: + writer.writerow( + [ + str(image), + result, + reason, + str(log_path), + str(screenshot_path), + ] + ) + print(f"{result}: {image.name} ({reason})") + if result == "PASS": + pass_count += 1 + else: + fail_count += 1 + + print("") + print(f"smoke test summary: pass={pass_count} fail={fail_count} total={len(images)}") + print(f"summary csv: {summary_csv}") + + return 0 if fail_count == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_upload_corpus_smoke_test.sh b/src/imiv/tools/imiv_upload_corpus_smoke_test.sh new file mode 100644 index 0000000000..f95192ae76 --- /dev/null +++ b/src/imiv/tools/imiv_upload_corpus_smoke_test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +corpus_dir="${1:-$repo_root/build_u/testsuite/imiv/upload_corpus/images}" +result_dir="${2:-$repo_root/build_u/testsuite/imiv/upload_corpus/results}" +driver_py="${DRIVER_PY:-$repo_root/src/imiv/tools/imiv_upload_corpus_smoke.py}" +runner_py="${RUNNER_PY:-$repo_root/src/imiv/tools/imiv_gui_test_run.py}" +python_bin="${PYTHON_BIN:-python3}" +env_script="${IMIV_ENV_SCRIPT:-$repo_root/build_u/imiv_env.sh}" +per_case_timeout="${PER_CASE_TIMEOUT:-45s}" +runner_trace="${RUNNER_TRACE:-0}" +screenshot_delay_frames="${SCREENSHOT_DELAY_FRAMES:-3}" +screenshot_frames="${SCREENSHOT_FRAMES:-1}" +batch_size="${BATCH_SIZE:-32}" +timeout_slop_seconds="${BATCH_TIMEOUT_SLOP_SECONDS:-15}" + +if [[ ! -d "$corpus_dir" ]]; then + echo "error: corpus directory not found: $corpus_dir" >&2 + exit 2 +fi +if [[ ! -f "$driver_py" ]]; then + echo "error: upload smoke driver script not found: $driver_py" >&2 + exit 2 +fi +if [[ ! -f "$runner_py" ]]; then + echo "error: imiv runner script not found: $runner_py" >&2 + exit 2 +fi +if [[ "$screenshot_frames" != "1" ]]; then + echo "warning: batched upload smoke uses one scenario capture per image; SCREENSHOT_FRAMES=${screenshot_frames} is ignored" >&2 +fi + +cmd=( + "$python_bin" "-u" "$driver_py" + "--corpus-dir" "$corpus_dir" + "--result-dir" "$result_dir" + "--runner" "$runner_py" + "--env-script" "$env_script" + "--per-case-timeout" "$per_case_timeout" + "--batch-size" "$batch_size" + "--timeout-slop-seconds" "$timeout_slop_seconds" + "--post-action-delay-frames" "$screenshot_delay_frames" +) + +if [[ "$runner_trace" == "1" ]]; then + cmd+=("--trace") +fi + +exec "${cmd[@]}" diff --git a/src/imiv/tools/imiv_ux_actions_regression.py b/src/imiv/tools/imiv_ux_actions_regression.py new file mode 100644 index 0000000000..2aa0813083 --- /dev/null +++ b/src/imiv/tools/imiv_ux_actions_regression.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python3 +"""Regression check for combined imiv UX actions in a single app run.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _default_oiiotool(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "oiiotool", + repo_root / "build" / "bin" / "oiiotool", + repo_root / "build_u" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "src" / "oiiotool" / "oiiotool", + repo_root / "build" / "Debug" / "oiiotool.exe", + repo_root / "build" / "Release" / "oiiotool.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + which = shutil.which("oiiotool") + return Path(which) if which else candidates[0] + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _run_checked(cmd: list[str], *, cwd: Path) -> None: + print("run:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(cwd), check=True) + + +def _generate_logo_fixture(oiiotool: Path, source_path: Path, out_path: Path) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + _run_checked( + [ + str(oiiotool), + str(source_path), + "--resize", + "2200x1547", + "-o", + str(out_path), + ], + cwd=out_path.parent, + ) + + +def _scenario_step( + root: ET.Element, name: str, **attrs: str | int | bool +) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + _scenario_step( + root, + "select_drag", + key_chord="ctrl+a", + mouse_pos_image_rel="0.18,0.25", + mouse_drag="180,120", + mouse_drag_button=0, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "reselect_drag", + mouse_pos_image_rel="0.58,0.52", + mouse_drag="160,110", + mouse_drag_button=0, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_all", + key_chord="ctrl+shift+a", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "deselect_shortcut", + key_chord="ctrl+d", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_all_again", + key_chord="ctrl+shift+a", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "deselect_outside_click", + mouse_pos_window_rel="0.98,0.50", + mouse_click_button=0, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "select_all_third", + key_chord="ctrl+shift+a", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "deselect_image_click", + mouse_pos_image_rel="0.50,0.50", + mouse_click_button=0, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "area_sample_off", + key_chord="ctrl+a", + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "normal_size", + key_chord="ctrl+0", + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "pan_left_drag", + mouse_pos_image_rel="0.50,0.50", + mouse_drag="-220,120", + mouse_drag_button=0, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "recenter", + key_chord="ctrl+period", + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "zoom_in_right_drag", + mouse_pos_image_rel="0.50,0.50", + mouse_drag="0,120", + mouse_drag_button=1, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "zoom_out_right_drag", + mouse_pos_image_rel="0.50,0.50", + mouse_drag="0,-120", + mouse_drag_button=1, + state=True, + post_action_delay_frames=2, + ) + _scenario_step( + root, + "fit_image_to_window", + key_chord="alt+f", + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "zoom_in_shortcut_after_fit", + key_chord="ctrl+shift+equal", + state=True, + post_action_delay_frames=3, + ) + _scenario_step( + root, + "zoom_in_wheel_after_fit", + mouse_pos_image_rel="0.50,0.50", + mouse_wheel="0,1", + state=True, + post_action_delay_frames=3, + ) + + tree = ET.ElementTree(root) + path.parent.mkdir(parents=True, exist_ok=True) + tree.write(path, encoding="utf-8", xml_declaration=True) + + +def _load_state(path: Path) -> dict: + if not path.exists(): + raise RuntimeError(f"state file not written: {path}") + state = json.loads(path.read_text(encoding="utf-8")) + state["_state_path"] = str(path) + return state + + +def _area_probe_is_initialized(state: dict) -> bool: + for line in state.get("area_probe_lines", []): + if "-----" in line: + return False + return True + + +def _area_probe_is_reset(state: dict) -> bool: + return all( + "-----" in line or line == "Area Probe:" + for line in state.get("area_probe_lines", []) + ) + + +def _selection_is_cleared(state: dict) -> bool: + return (not state.get("selection_active", False)) and all( + int(v) == 0 for v in state.get("selection_bounds", []) + ) + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build_u" / "imiv_env.sh" + default_out_dir = repo_root / "build_u" / "imiv_captures" / "ux_actions_regression" + default_image = repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png" + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--oiiotool", default=str(_default_oiiotool(repo_root)), help="oiiotool executable" + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--image", + default=str(default_image), + help="Generated panoramic fixture used for the UX scenario", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve() + if not exe.exists(): + return _fail(f"binary not found: {exe}") + oiiotool = Path(args.oiiotool).expanduser().resolve() + if not oiiotool.exists(): + return _fail(f"oiiotool not found: {oiiotool}") + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else exe.parent.resolve() + out_dir = Path(args.out_dir).expanduser().resolve() + runtime_dir = out_dir / "runtime" + source_image_path = Path(args.image).expanduser().resolve() + image_path = out_dir / "ux_actions_input.png" + scenario_path = out_dir / "ux_actions.scenario.xml" + log_path = out_dir / "ux_actions.log" + config_home = out_dir / "cfg" + + shutil.rmtree(runtime_dir, ignore_errors=True) + shutil.rmtree(config_home, ignore_errors=True) + out_dir.mkdir(parents=True, exist_ok=True) + + if not source_image_path.exists(): + return _fail(f"image not found: {source_image_path}") + try: + _generate_logo_fixture(oiiotool, source_image_path, image_path) + except subprocess.SubprocessError as exc: + return _fail(f"failed to generate logo fixture: {exc}") + + runtime_dir_rel = os.path.relpath(runtime_dir, cwd) + _write_scenario(scenario_path, runtime_dir_rel) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + if args.backend: + cmd.extend(["--backend", args.backend]) + cmd.extend([ + "--open", + str(image_path), + "--scenario", + str(scenario_path), + ]) + if args.trace: + cmd.append("--trace") + + env = _load_env_from_script(Path(args.env_script).expanduser()) + env["IMIV_CONFIG_HOME"] = str(config_home) + + with log_path.open("w", encoding="utf-8") as log_handle: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + check=False, + stdout=log_handle, + stderr=subprocess.STDOUT, + timeout=180, + ) + if proc.returncode != 0: + return _fail(f"runner exited with code {proc.returncode}: {log_path}") + + try: + select_drag = _load_state(runtime_dir / "select_drag.state.json") + reselect_drag = _load_state(runtime_dir / "reselect_drag.state.json") + select_all = _load_state(runtime_dir / "select_all.state.json") + deselect_shortcut = _load_state(runtime_dir / "deselect_shortcut.state.json") + select_all_again = _load_state(runtime_dir / "select_all_again.state.json") + deselect_image_click = _load_state( + runtime_dir / "deselect_image_click.state.json" + ) + select_all_third = _load_state(runtime_dir / "select_all_third.state.json") + deselect_outside_click = _load_state( + runtime_dir / "deselect_outside_click.state.json" + ) + area_sample_off = _load_state(runtime_dir / "area_sample_off.state.json") + normal_size = _load_state(runtime_dir / "normal_size.state.json") + pan_left_drag = _load_state(runtime_dir / "pan_left_drag.state.json") + recenter = _load_state(runtime_dir / "recenter.state.json") + zoom_in_right_drag = _load_state( + runtime_dir / "zoom_in_right_drag.state.json" + ) + zoom_out_right_drag = _load_state( + runtime_dir / "zoom_out_right_drag.state.json" + ) + fit_image_to_window = _load_state( + runtime_dir / "fit_image_to_window.state.json" + ) + zoom_in_shortcut_after_fit = _load_state( + runtime_dir / "zoom_in_shortcut_after_fit.state.json" + ) + zoom_in_wheel_after_fit = _load_state( + runtime_dir / "zoom_in_wheel_after_fit.state.json" + ) + except RuntimeError as exc: + return _fail(str(exc)) + + states = ( + ("select_drag", select_drag), + ("reselect_drag", reselect_drag), + ("select_all", select_all), + ("deselect_shortcut", deselect_shortcut), + ("select_all_again", select_all_again), + ("deselect_image_click", deselect_image_click), + ("select_all_third", select_all_third), + ("deselect_outside_click", deselect_outside_click), + ("area_sample_off", area_sample_off), + ("normal_size", normal_size), + ("pan_left_drag", pan_left_drag), + ("recenter", recenter), + ("zoom_in_right_drag", zoom_in_right_drag), + ("zoom_out_right_drag", zoom_out_right_drag), + ("fit_image_to_window", fit_image_to_window), + ("zoom_in_shortcut_after_fit", zoom_in_shortcut_after_fit), + ("zoom_in_wheel_after_fit", zoom_in_wheel_after_fit), + ) + for name, state in states: + if not state.get("image_loaded", False): + return _fail(f"{name}: image not loaded") + + if not select_drag.get("selection_active", False): + return _fail("select_drag: selection was not created") + first_bounds = [int(v) for v in select_drag.get("selection_bounds", [])] + if len(first_bounds) != 4 or not ( + first_bounds[2] > first_bounds[0] and first_bounds[3] > first_bounds[1] + ): + return _fail(f"select_drag: invalid selection bounds: {first_bounds}") + if not _area_probe_is_initialized(select_drag): + return _fail("select_drag: area probe statistics were not initialized") + + second_bounds = [int(v) for v in reselect_drag.get("selection_bounds", [])] + if second_bounds == first_bounds: + return _fail("reselect_drag: selection bounds did not change") + if not reselect_drag.get("selection_active", False): + return _fail("reselect_drag: selection is not active") + + image_size = select_all.get("image_size", []) + if len(image_size) != 2: + return _fail("select_all: image size missing") + expected_select_all = [0, 0, int(image_size[0]), int(image_size[1])] + if [int(v) for v in select_all.get("selection_bounds", [])] != expected_select_all: + return _fail( + "select_all: wrong selection bounds: " + f"expected {expected_select_all}, got {select_all.get('selection_bounds')}" + ) + if not _area_probe_is_initialized(select_all): + return _fail("select_all: area probe statistics were not initialized") + + if not _selection_is_cleared(deselect_shortcut): + return _fail("deselect_shortcut: selection was not cleared") + if not _area_probe_is_reset(deselect_shortcut): + return _fail("deselect_shortcut: area probe was not reset") + + if [int(v) for v in select_all_again.get("selection_bounds", [])] != expected_select_all: + return _fail("select_all_again: select all did not replace the selection") + + if not _selection_is_cleared(deselect_image_click): + return _fail("deselect_image_click: selection was not cleared") + if not _area_probe_is_reset(deselect_image_click): + return _fail("deselect_image_click: area probe was not reset") + + if [int(v) for v in select_all_third.get("selection_bounds", [])] != expected_select_all: + return _fail("select_all_third: select all did not replace the selection") + + if not _selection_is_cleared(deselect_outside_click): + return _fail("deselect_outside_click: selection was not cleared") + if not _area_probe_is_reset(deselect_outside_click): + return _fail("deselect_outside_click: area probe was not reset") + + if not _selection_is_cleared(area_sample_off): + return _fail("area_sample_off: selection remained active") + if not _area_probe_is_reset(area_sample_off): + return _fail("area_sample_off: area probe was not reset") + + normal_zoom = float(normal_size.get("zoom", 0.0)) + if abs(normal_zoom - 1.0) > 1.0e-3: + return _fail(f"normal_size: expected zoom 1.0, got {normal_zoom:.6f}") + if bool(normal_size.get("fit_image_to_window", False)): + return _fail("normal_size: fit_image_to_window remained enabled") + + normal_scroll = [float(v) for v in normal_size.get("scroll", [0.0, 0.0])] + panned_scroll = [float(v) for v in pan_left_drag.get("scroll", [0.0, 0.0])] + if len(normal_scroll) != 2 or len(panned_scroll) != 2: + return _fail("pan_left_drag: scroll data missing") + if abs(panned_scroll[0] - normal_scroll[0]) <= 1.0: + return _fail( + "pan_left_drag: left drag did not pan the image enough: " + f"baseline={normal_scroll}, panned={panned_scroll}" + ) + if pan_left_drag.get("selection_active", False): + return _fail("pan_left_drag: selection became active with area sample off") + + recenter_scroll = [float(v) for v in recenter.get("norm_scroll", [0.0, 0.0])] + if len(recenter_scroll) != 2: + return _fail("recenter: normalized scroll missing") + if abs(recenter_scroll[0] - 0.5) > 0.1 or abs(recenter_scroll[1] - 0.5) > 0.1: + return _fail( + f"recenter: expected normalized scroll near [0.5, 0.5], got {recenter_scroll}" + ) + + zoom_in = float(zoom_in_right_drag.get("zoom", 0.0)) + zoom_out = float(zoom_out_right_drag.get("zoom", 0.0)) + recenter_zoom = float(recenter.get("zoom", 0.0)) + if zoom_in <= recenter_zoom + 1.0e-3: + return _fail( + "zoom_in_right_drag: zoom did not increase: " + f"before={recenter_zoom:.6f}, after={zoom_in:.6f}" + ) + if zoom_out >= zoom_in - 1.0e-3: + return _fail( + "zoom_out_right_drag: zoom did not decrease: " + f"before={zoom_in:.6f}, after={zoom_out:.6f}" + ) + + fit_zoom = float(fit_image_to_window.get("zoom", 0.0)) + if not bool(fit_image_to_window.get("fit_image_to_window", False)): + return _fail("fit_image_to_window: fit flag was not enabled") + if fit_zoom >= normal_zoom - 1.0e-3: + return _fail( + "fit_image_to_window: expected fit zoom smaller than 1:1: " + f"fit={fit_zoom:.6f}, normal={normal_zoom:.6f}" + ) + + shortcut_zoom = float(zoom_in_shortcut_after_fit.get("zoom", 0.0)) + if shortcut_zoom <= fit_zoom + 1.0e-3: + return _fail( + "zoom_in_shortcut_after_fit: zoom did not increase from fit: " + f"fit={fit_zoom:.6f}, shortcut={shortcut_zoom:.6f}" + ) + if bool(zoom_in_shortcut_after_fit.get("fit_image_to_window", False)): + return _fail( + "zoom_in_shortcut_after_fit: fit_image_to_window remained enabled" + ) + + wheel_zoom = float(zoom_in_wheel_after_fit.get("zoom", 0.0)) + if wheel_zoom <= shortcut_zoom + 1.0e-3: + return _fail( + "zoom_in_wheel_after_fit: mouse wheel did not increase zoom: " + f"shortcut={shortcut_zoom:.6f}, wheel={wheel_zoom:.6f}" + ) + if bool(zoom_in_wheel_after_fit.get("fit_image_to_window", False)): + return _fail( + "zoom_in_wheel_after_fit: fit_image_to_window remained enabled" + ) + + print("select_drag:", select_drag["_state_path"]) + print("reselect_drag:", reselect_drag["_state_path"]) + print("normal_size:", normal_size["_state_path"]) + print("fit_image_to_window:", fit_image_to_window["_state_path"]) + print( + "zoom_in_shortcut_after_fit:", + zoom_in_shortcut_after_fit["_state_path"], + ) + print("zoom_in_wheel_after_fit:", zoom_in_wheel_after_fit["_state_path"]) + print("artifacts:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_view_recipe_regression.py b/src/imiv/tools/imiv_view_recipe_regression.py new file mode 100644 index 0000000000..b132856cf6 --- /dev/null +++ b/src/imiv/tools/imiv_view_recipe_regression.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Regression check for independent per-view preview recipe state.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _default_binary(repo_root: Path) -> Path: + candidates = [ + repo_root / "build_u" / "bin" / "imiv", + repo_root / "build" / "bin" / "imiv", + repo_root / "build_u" / "src" / "imiv" / "imiv", + repo_root / "build" / "src" / "imiv" / "imiv", + repo_root / "build" / "Debug" / "imiv.exe", + repo_root / "build" / "Release" / "imiv.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def _load_env_from_script(script_path: Path) -> dict[str, str]: + env = dict(os.environ) + if not script_path.exists() or shutil.which("bash") is None: + return env + + quoted = shlex.quote(str(script_path)) + proc = subprocess.run( + ["bash", "-lc", f"source {quoted} >/dev/null 2>&1; env -0"], + check=True, + stdout=subprocess.PIPE, + ) + for item in proc.stdout.split(b"\0"): + if not item: + continue + key, _, value = item.partition(b"=") + if not key: + continue + env[key.decode("utf-8", errors="ignore")] = value.decode( + "utf-8", errors="ignore" + ) + return env + + +def _fail(message: str) -> int: + print(f"error: {message}", file=sys.stderr) + return 1 + + +def _load_json(path: Path) -> dict: + if not path.exists(): + raise FileNotFoundError(path) + return json.loads(path.read_text(encoding="utf-8")) + + +def _scenario_step(root: ET.Element, name: str, **attrs: str | int | float | bool) -> None: + step = ET.SubElement(root, "step") + step.set("name", name) + for key, value in attrs.items(): + if isinstance(value, bool): + step.set(key, "true" if value else "false") + else: + step.set(key, str(value)) + + +def _write_scenario(path: Path, runtime_dir_rel: str) -> None: + root = ET.Element("imiv-scenario") + root.set("out_dir", runtime_dir_rel) + + _scenario_step( + root, + "open_second_in_new_view", + image_list_open_new_view_index=1, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "tune_second_view", + exposure=1.25, + gamma=1.75, + offset=0.125, + ocio_use=True, + linear_interpolation=True, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "activate_primary_view", + view_activate_index=0, + state=True, + post_action_delay_frames=4, + ) + _scenario_step( + root, + "activate_secondary_view", + view_activate_index=1, + state=True, + post_action_delay_frames=4, + ) + + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _assert_close(actual: float, expected: float, name: str) -> None: + if not math.isclose(actual, expected, rel_tol=1.0e-5, abs_tol=1.0e-5): + raise AssertionError(f"{name} mismatch: expected {expected}, got {actual}") + + +def main() -> int: + repo_root = _repo_root() + runner = repo_root / "src" / "imiv" / "tools" / "imiv_gui_test_run.py" + default_env_script = repo_root / "build" / "imiv_env.sh" + default_out_dir = repo_root / "build" / "imiv_captures" / "view_recipe_regression" + default_images = [ + repo_root / "ASWF" / "logos" / "openimageio-stacked-gradient.png", + repo_root / "testsuite" / "imiv" / "images" / "CC988_ACEScg.exr", + ] + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bin", default=str(_default_binary(repo_root)), help="imiv executable") + ap.add_argument("--cwd", default="", help="Working directory for imiv") + ap.add_argument( + "--backend", + default="", + help="Optional runtime backend override passed through to imiv", + ) + ap.add_argument( + "--env-script", + default=str(default_env_script), + help="Optional shell env setup script", + ) + ap.add_argument("--out-dir", default=str(default_out_dir), help="Output directory") + ap.add_argument( + "--image", + dest="images", + action="append", + default=[], + help="Startup image path; may be repeated", + ) + ap.add_argument("--trace", action="store_true", help="Enable test engine trace") + args = ap.parse_args() + + exe = Path(args.bin).expanduser().resolve(strict=False) + if not runner.exists(): + return _fail(f"runner not found: {runner}") + + images = [Path(p).expanduser().resolve() for p in args.images] if args.images else default_images + if len(images) < 2: + return _fail("regression requires at least two startup images") + for image in images: + if not image.exists(): + return _fail(f"image not found: {image}") + + out_dir = Path(args.out_dir).expanduser().resolve() + run_out_dir = Path(tempfile.mkdtemp(prefix="imiv_view_recipe_")) + runtime_dir = run_out_dir / "runtime" + cwd_dir = run_out_dir / "cwd" + scenario_path = run_out_dir / "view_recipe.scenario.xml" + step1_path = runtime_dir / "open_second_in_new_view.state.json" + step2_path = runtime_dir / "tune_second_view.state.json" + step3_path = runtime_dir / "activate_primary_view.state.json" + step4_path = runtime_dir / "activate_secondary_view.state.json" + log_path = run_out_dir / "view_recipe.log" + + shutil.rmtree(out_dir, ignore_errors=True) + run_out_dir.mkdir(parents=True, exist_ok=True) + cwd_dir.mkdir(parents=True, exist_ok=True) + cwd = Path(args.cwd).expanduser().resolve() if args.cwd else cwd_dir + _write_scenario(scenario_path, os.path.relpath(runtime_dir, cwd)) + + env = dict(os.environ) + env.update(_load_env_from_script(Path(args.env_script).expanduser())) + config_home = run_out_dir / "cfg" + config_home.mkdir(parents=True, exist_ok=True) + env["IMIV_CONFIG_HOME"] = str(config_home) + + cmd = [ + sys.executable, + str(runner), + "--bin", + str(exe), + "--cwd", + str(cwd), + ] + for image in images: + cmd.extend(["--open", str(image)]) + cmd.extend(["--scenario", str(scenario_path)]) + if args.backend: + cmd.extend(["--backend", args.backend]) + if args.trace: + cmd.append("--trace") + + print("run:", " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(repo_root), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + log_path.write_text(proc.stdout, encoding="utf-8") + shutil.copytree(run_out_dir, out_dir, dirs_exist_ok=True) + if proc.returncode != 0: + print(proc.stdout, end="") + return _fail(f"runner exited with code {proc.returncode}") + + try: + step1 = _load_json(step1_path) + step2 = _load_json(step2_path) + step3 = _load_json(step3_path) + step4 = _load_json(step4_path) + except FileNotFoundError as exc: + return _fail(f"state output not found: {exc}") + + if int(step1.get("view_count", 0)) < 2: + return _fail("new view was not created") + if step1.get("image_path") != str(images[1]): + return _fail("new view did not open the second image") + + recipe2 = step2.get("view_recipe", {}) + if step2.get("image_path") != str(images[1]): + return _fail("second view does not show the second image") + if not bool(recipe2.get("linear_interpolation", False)): + return _fail("second view did not keep linear interpolation override") + if not bool(step2.get("ocio", {}).get("use_ocio", False)): + return _fail("second view did not keep OCIO enabled") + try: + _assert_close(float(recipe2.get("exposure", 0.0)), 1.25, "second view exposure") + _assert_close(float(recipe2.get("gamma", 0.0)), 1.75, "second view gamma") + _assert_close(float(recipe2.get("offset", 0.0)), 0.125, "second view offset") + except (TypeError, ValueError, AssertionError) as exc: + return _fail(str(exc)) + + recipe3 = step3.get("view_recipe", {}) + if step3.get("image_path") != str(images[0]): + return _fail("primary view activation did not restore the first image") + if bool(recipe3.get("linear_interpolation", True)): + return _fail("primary view unexpectedly inherited interpolation override") + if bool(step3.get("ocio", {}).get("use_ocio", True)): + return _fail("primary view unexpectedly inherited OCIO state") + try: + _assert_close(float(recipe3.get("exposure", 99.0)), 0.0, "primary view exposure") + _assert_close(float(recipe3.get("gamma", 99.0)), 1.0, "primary view gamma") + _assert_close(float(recipe3.get("offset", 99.0)), 0.0, "primary view offset") + except (TypeError, ValueError, AssertionError) as exc: + return _fail(str(exc)) + + recipe4 = step4.get("view_recipe", {}) + if step4.get("image_path") != str(images[1]): + return _fail("secondary view activation did not restore the second image") + if not bool(recipe4.get("linear_interpolation", False)): + return _fail("secondary view did not preserve interpolation override") + if not bool(step4.get("ocio", {}).get("use_ocio", False)): + return _fail("secondary view did not preserve OCIO state") + try: + _assert_close(float(recipe4.get("exposure", 0.0)), 1.25, "restored second view exposure") + _assert_close(float(recipe4.get("gamma", 0.0)), 1.75, "restored second view gamma") + _assert_close(float(recipe4.get("offset", 0.0)), 0.125, "restored second view offset") + except (TypeError, ValueError, AssertionError) as exc: + return _fail(str(exc)) + + print(f"step1: {step1_path}") + print(f"step2: {step2_path}") + print(f"step3: {step3_path}") + print(f"step4: {step4_path}") + print(f"log: {log_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/imiv/tools/imiv_windows_backend_verify.py b/src/imiv/tools/imiv_windows_backend_verify.py new file mode 100644 index 0000000000..4acca7c365 --- /dev/null +++ b/src/imiv/tools/imiv_windows_backend_verify.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the shared imiv backend verification runner. + +Preferred direct entrypoint: + + python src\\imiv\\tools\\imiv_backend_verify.py --backend vulkan --build-dir build + +This wrapper preserves the previous Windows-specific command path. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def main() -> int: + script_dir = Path(__file__).resolve().parent + sys.path.insert(0, str(script_dir)) + from imiv_backend_verify import main as backend_main + + return backend_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/windows/oiio_exe.aps b/src/windows/oiio_exe.aps new file mode 100644 index 0000000000..af29bf6cbc Binary files /dev/null and b/src/windows/oiio_exe.aps differ diff --git a/testsuite/imiv/images/CC988_ACEScg.exr b/testsuite/imiv/images/CC988_ACEScg.exr new file mode 100644 index 0000000000..b7c8adf2eb Binary files /dev/null and b/testsuite/imiv/images/CC988_ACEScg.exr differ