diff --git a/.gitignore b/.gitignore index 76df258..65820ab 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,6 @@ build.ninja /runtime/* !/runtime/templates/ !/runtime/templates/** +!/runtime/README.md /ui/node_modules/ /ui/dist/ diff --git a/CMakeLists.txt b/CMakeLists.txt index cff965b..4412920 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/apps/LoopThroughWithOpenGLCompositing") -set(GPUDIRECT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/3rdParty/Blackmagic DeckLink SDK 16.0/Win/Samples/NVIDIA_GPUDirect") +set(GPUDIRECT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/3rdParty/Blackmagic DeckLink SDK 16.0/Win/Samples/NVIDIA_GPUDirect" CACHE PATH "Path to the NVIDIA_GPUDirect sample directory from the Blackmagic DeckLink SDK") if(NOT EXISTS "${APP_DIR}/LoopThroughWithOpenGLCompositing.cpp") message(FATAL_ERROR "Imported app sources were not found under ${APP_DIR}") @@ -35,8 +35,12 @@ set(APP_SOURCES "${APP_DIR}/RuntimeHost.h" "${APP_DIR}/RuntimeJson.cpp" "${APP_DIR}/RuntimeJson.h" + "${APP_DIR}/RuntimeParameterUtils.cpp" + "${APP_DIR}/RuntimeParameterUtils.h" "${APP_DIR}/ShaderCompiler.cpp" "${APP_DIR}/ShaderCompiler.h" + "${APP_DIR}/ShaderPackageRegistry.cpp" + "${APP_DIR}/ShaderPackageRegistry.h" "${APP_DIR}/ShaderTypes.h" "${APP_DIR}/stdafx.cpp" "${APP_DIR}/stdafx.h" @@ -90,6 +94,38 @@ endif() enable_testing() add_test(NAME RuntimeJsonTests COMMAND RuntimeJsonTests) +add_executable(RuntimeParameterUtilsTests + "${APP_DIR}/RuntimeJson.cpp" + "${APP_DIR}/RuntimeParameterUtils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeParameterUtilsTests.cpp" +) + +target_include_directories(RuntimeParameterUtilsTests PRIVATE + "${APP_DIR}" +) + +if(MSVC) + target_compile_options(RuntimeParameterUtilsTests PRIVATE /W3) +endif() + +add_test(NAME RuntimeParameterUtilsTests COMMAND RuntimeParameterUtilsTests) + +add_executable(ShaderPackageRegistryTests + "${APP_DIR}/RuntimeJson.cpp" + "${APP_DIR}/ShaderPackageRegistry.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/ShaderPackageRegistryTests.cpp" +) + +target_include_directories(ShaderPackageRegistryTests PRIVATE + "${APP_DIR}" +) + +if(MSVC) + target_compile_options(ShaderPackageRegistryTests PRIVATE /W3) +endif() + +add_test(NAME ShaderPackageRegistryTests COMMAND ShaderPackageRegistryTests) + add_custom_command(TARGET LoopThroughWithOpenGLCompositing POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${GPUDIRECT_DIR}/bin/x64/dvp.dll" diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp index 041071b..aac3f62 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp @@ -106,6 +106,60 @@ void CopyErrorMessage(const std::string& message, int errorMessageSize, char* er strncpy_s(errorMessage, errorMessageSize, message.c_str(), _TRUNCATE); } +class ScopedGlShader +{ +public: + explicit ScopedGlShader(GLuint shader = 0) : mShader(shader) {} + ~ScopedGlShader() { reset(); } + + ScopedGlShader(const ScopedGlShader&) = delete; + ScopedGlShader& operator=(const ScopedGlShader&) = delete; + + GLuint get() const { return mShader; } + GLuint release() + { + GLuint shader = mShader; + mShader = 0; + return shader; + } + void reset(GLuint shader = 0) + { + if (mShader != 0) + glDeleteShader(mShader); + mShader = shader; + } + +private: + GLuint mShader; +}; + +class ScopedGlProgram +{ +public: + explicit ScopedGlProgram(GLuint program = 0) : mProgram(program) {} + ~ScopedGlProgram() { reset(); } + + ScopedGlProgram(const ScopedGlProgram&) = delete; + ScopedGlProgram& operator=(const ScopedGlProgram&) = delete; + + GLuint get() const { return mProgram; } + GLuint release() + { + GLuint program = mProgram; + mProgram = 0; + return program; + } + void reset(GLuint program = 0) + { + if (mProgram != 0) + glDeleteProgram(mProgram); + mProgram = program; + } + +private: + GLuint mProgram; +}; + std::size_t AlignStd140(std::size_t offset, std::size_t alignment) { const std::size_t mask = alignment - 1; @@ -1076,40 +1130,34 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, const char* fragmentSource = fragmentShaderSource.c_str(); - GLuint newVertexShader = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(newVertexShader, 1, (const GLchar**)&vertexSource, NULL); - glCompileShader(newVertexShader); - glGetShaderiv(newVertexShader, GL_COMPILE_STATUS, &compileResult); + ScopedGlShader newVertexShader(glCreateShader(GL_VERTEX_SHADER)); + glShaderSource(newVertexShader.get(), 1, (const GLchar**)&vertexSource, NULL); + glCompileShader(newVertexShader.get()); + glGetShaderiv(newVertexShader.get(), GL_COMPILE_STATUS, &compileResult); if (compileResult == GL_FALSE) { - glGetShaderInfoLog(newVertexShader, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteShader(newVertexShader); + glGetShaderInfoLog(newVertexShader.get(), errorMessageSize, &errorBufferSize, errorMessage); return false; } - GLuint newFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(newFragmentShader, 1, (const GLchar**)&fragmentSource, NULL); - glCompileShader(newFragmentShader); - glGetShaderiv(newFragmentShader, GL_COMPILE_STATUS, &compileResult); + ScopedGlShader newFragmentShader(glCreateShader(GL_FRAGMENT_SHADER)); + glShaderSource(newFragmentShader.get(), 1, (const GLchar**)&fragmentSource, NULL); + glCompileShader(newFragmentShader.get()); + glGetShaderiv(newFragmentShader.get(), GL_COMPILE_STATUS, &compileResult); if (compileResult == GL_FALSE) { - glGetShaderInfoLog(newFragmentShader, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); + glGetShaderInfoLog(newFragmentShader.get(), errorMessageSize, &errorBufferSize, errorMessage); return false; } - GLuint newProgram = glCreateProgram(); - glAttachShader(newProgram, newVertexShader); - glAttachShader(newProgram, newFragmentShader); - glLinkProgram(newProgram); - glGetProgramiv(newProgram, GL_LINK_STATUS, &linkResult); + ScopedGlProgram newProgram(glCreateProgram()); + glAttachShader(newProgram.get(), newVertexShader.get()); + glAttachShader(newProgram.get(), newFragmentShader.get()); + glLinkProgram(newProgram.get()); + glGetProgramiv(newProgram.get(), GL_LINK_STATUS, &linkResult); if (linkResult == GL_FALSE) { - glGetProgramInfoLog(newProgram, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteProgram(newProgram); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); + glGetProgramInfoLog(newProgram.get(), errorMessageSize, &errorBufferSize, errorMessage); return false; } @@ -1126,39 +1174,36 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, glDeleteTextures(1, &loadedTexture.texture); } CopyErrorMessage(loadError, errorMessageSize, errorMessage); - glDeleteProgram(newProgram); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); return false; } textureBindings.push_back(textureBinding); } - const GLuint globalParamsIndex = glGetUniformBlockIndex(newProgram, "GlobalParams"); + const GLuint globalParamsIndex = glGetUniformBlockIndex(newProgram.get(), "GlobalParams"); if (globalParamsIndex != GL_INVALID_INDEX) - glUniformBlockBinding(newProgram, globalParamsIndex, kGlobalParamsBindingPoint); + glUniformBlockBinding(newProgram.get(), globalParamsIndex, kGlobalParamsBindingPoint); const unsigned historyCap = mRuntimeHost ? mRuntimeHost->GetMaxTemporalHistoryFrames() : 0; const GLuint shaderTextureBase = kSourceHistoryTextureUnitBase + historyCap + historyCap; - glUseProgram(newProgram); - const GLint videoInputLocation = glGetUniformLocation(newProgram, "gVideoInput"); + glUseProgram(newProgram.get()); + const GLint videoInputLocation = glGetUniformLocation(newProgram.get(), "gVideoInput"); if (videoInputLocation >= 0) glUniform1i(videoInputLocation, static_cast(kDecodedVideoTextureUnit)); for (unsigned index = 0; index < historyCap; ++index) { const std::string sourceSamplerName = "gSourceHistory" + std::to_string(index); - const GLint sourceSamplerLocation = glGetUniformLocation(newProgram, sourceSamplerName.c_str()); + const GLint sourceSamplerLocation = glGetUniformLocation(newProgram.get(), sourceSamplerName.c_str()); if (sourceSamplerLocation >= 0) glUniform1i(sourceSamplerLocation, static_cast(kSourceHistoryTextureUnitBase + index)); const std::string temporalSamplerName = "gTemporalHistory" + std::to_string(index); - const GLint temporalSamplerLocation = glGetUniformLocation(newProgram, temporalSamplerName.c_str()); + const GLint temporalSamplerLocation = glGetUniformLocation(newProgram.get(), temporalSamplerName.c_str()); if (temporalSamplerLocation >= 0) glUniform1i(temporalSamplerLocation, static_cast(kSourceHistoryTextureUnitBase + historyCap + index)); } for (std::size_t index = 0; index < textureBindings.size(); ++index) { - const GLint textureSamplerLocation = glGetUniformLocation(newProgram, textureBindings[index].samplerName.c_str()); + const GLint textureSamplerLocation = glGetUniformLocation(newProgram.get(), textureBindings[index].samplerName.c_str()); if (textureSamplerLocation >= 0) glUniform1i(textureSamplerLocation, static_cast(shaderTextureBase + static_cast(index))); } @@ -1166,9 +1211,9 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, layerProgram.layerId = state.layerId; layerProgram.shaderId = state.shaderId; - layerProgram.program = newProgram; - layerProgram.vertexShader = newVertexShader; - layerProgram.fragmentShader = newFragmentShader; + layerProgram.program = newProgram.release(); + layerProgram.vertexShader = newVertexShader.release(); + layerProgram.fragmentShader = newFragmentShader.release(); layerProgram.textureBindings.swap(textureBindings); return true; } @@ -1222,47 +1267,41 @@ bool OpenGLComposite::compileDecodeShader(int errorMessageSize, char* errorMessa const char* vertexSource = kVertexShaderSource; const char* fragmentSource = kDecodeFragmentShaderSource; - GLuint newVertexShader = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(newVertexShader, 1, (const GLchar**)&vertexSource, NULL); - glCompileShader(newVertexShader); - glGetShaderiv(newVertexShader, GL_COMPILE_STATUS, &compileResult); + ScopedGlShader newVertexShader(glCreateShader(GL_VERTEX_SHADER)); + glShaderSource(newVertexShader.get(), 1, (const GLchar**)&vertexSource, NULL); + glCompileShader(newVertexShader.get()); + glGetShaderiv(newVertexShader.get(), GL_COMPILE_STATUS, &compileResult); if (compileResult == GL_FALSE) { - glGetShaderInfoLog(newVertexShader, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteShader(newVertexShader); + glGetShaderInfoLog(newVertexShader.get(), errorMessageSize, &errorBufferSize, errorMessage); return false; } - GLuint newFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(newFragmentShader, 1, (const GLchar**)&fragmentSource, NULL); - glCompileShader(newFragmentShader); - glGetShaderiv(newFragmentShader, GL_COMPILE_STATUS, &compileResult); + ScopedGlShader newFragmentShader(glCreateShader(GL_FRAGMENT_SHADER)); + glShaderSource(newFragmentShader.get(), 1, (const GLchar**)&fragmentSource, NULL); + glCompileShader(newFragmentShader.get()); + glGetShaderiv(newFragmentShader.get(), GL_COMPILE_STATUS, &compileResult); if (compileResult == GL_FALSE) { - glGetShaderInfoLog(newFragmentShader, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); + glGetShaderInfoLog(newFragmentShader.get(), errorMessageSize, &errorBufferSize, errorMessage); return false; } - GLuint newProgram = glCreateProgram(); - glAttachShader(newProgram, newVertexShader); - glAttachShader(newProgram, newFragmentShader); - glLinkProgram(newProgram); - glGetProgramiv(newProgram, GL_LINK_STATUS, &linkResult); + ScopedGlProgram newProgram(glCreateProgram()); + glAttachShader(newProgram.get(), newVertexShader.get()); + glAttachShader(newProgram.get(), newFragmentShader.get()); + glLinkProgram(newProgram.get()); + glGetProgramiv(newProgram.get(), GL_LINK_STATUS, &linkResult); if (linkResult == GL_FALSE) { - glGetProgramInfoLog(newProgram, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteProgram(newProgram); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); + glGetProgramInfoLog(newProgram.get(), errorMessageSize, &errorBufferSize, errorMessage); return false; } destroyDecodeShaderProgram(); - mDecodeProgram = newProgram; - mDecodeVertexShader = newVertexShader; - mDecodeFragmentShader = newFragmentShader; + mDecodeProgram = newProgram.release(); + mDecodeVertexShader = newVertexShader.release(); + mDecodeFragmentShader = newFragmentShader.release(); return true; } diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp index 71fef1c..375d195 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp @@ -1,6 +1,8 @@ #include "stdafx.h" #include "RuntimeHost.h" +#include "RuntimeParameterUtils.h" #include "ShaderCompiler.h" +#include "ShaderPackageRegistry.h" #include #include @@ -1250,37 +1252,10 @@ bool RuntimeHost::ScanShaderPackages(std::string& error) { std::map packagesById; std::vector packageOrder; - - if (!std::filesystem::exists(mShaderRoot)) - { - error = "Shader library directory does not exist: " + mShaderRoot.string(); + ShaderPackageRegistry registry(mConfig.maxTemporalHistoryFrames); + if (!registry.Scan(mShaderRoot, packagesById, packageOrder, error)) return false; - } - for (const auto& entry : std::filesystem::directory_iterator(mShaderRoot)) - { - if (!entry.is_directory()) - continue; - - std::filesystem::path manifestPath = entry.path() / "shader.json"; - if (!std::filesystem::exists(manifestPath)) - continue; - - ShaderPackage shaderPackage; - if (!ParseShaderManifest(manifestPath, shaderPackage, error)) - return false; - - if (packagesById.find(shaderPackage.id) != packagesById.end()) - { - error = "Duplicate shader id found: " + shaderPackage.id; - return false; - } - - packageOrder.push_back(shaderPackage.id); - packagesById[shaderPackage.id] = shaderPackage; - } - - std::sort(packageOrder.begin(), packageOrder.end()); mPackagesById.swap(packagesById); mPackageOrder.swap(packageOrder); @@ -1329,109 +1304,12 @@ bool RuntimeHost::ParseShaderManifest(const std::filesystem::path& manifestPath, bool RuntimeHost::NormalizeAndValidateValue(const ShaderParameterDefinition& definition, const JsonValue& value, ShaderParameterValue& normalizedValue, std::string& error) const { - normalizedValue = DefaultValueForDefinition(definition); - - switch (definition.type) - { - case ShaderParameterType::Float: - { - if (!value.isNumber()) - { - error = "Expected numeric value for float parameter '" + definition.id + "'."; - return false; - } - double number = value.asNumber(); - if (!IsFiniteNumber(number)) - { - error = "Float parameter '" + definition.id + "' must be finite."; - return false; - } - if (!definition.minNumbers.empty()) - number = std::max(number, definition.minNumbers.front()); - if (!definition.maxNumbers.empty()) - number = std::min(number, definition.maxNumbers.front()); - normalizedValue.numberValues = { number }; - return true; - } - case ShaderParameterType::Vec2: - case ShaderParameterType::Color: - { - std::vector numbers = JsonArrayToNumbers(value); - const std::size_t expectedSize = definition.type == ShaderParameterType::Vec2 ? 2 : 4; - if (numbers.size() != expectedSize) - { - error = "Expected array value of size " + std::to_string(expectedSize) + " for parameter '" + definition.id + "'."; - return false; - } - for (std::size_t index = 0; index < numbers.size(); ++index) - { - if (!IsFiniteNumber(numbers[index])) - { - error = "Parameter '" + definition.id + "' contains a non-finite value."; - return false; - } - if (index < definition.minNumbers.size()) - numbers[index] = std::max(numbers[index], definition.minNumbers[index]); - if (index < definition.maxNumbers.size()) - numbers[index] = std::min(numbers[index], definition.maxNumbers[index]); - } - normalizedValue.numberValues = numbers; - return true; - } - case ShaderParameterType::Boolean: - if (!value.isBoolean()) - { - error = "Expected boolean value for parameter '" + definition.id + "'."; - return false; - } - normalizedValue.booleanValue = value.asBoolean(); - return true; - case ShaderParameterType::Enum: - { - if (!value.isString()) - { - error = "Expected string value for enum parameter '" + definition.id + "'."; - return false; - } - const std::string selectedValue = value.asString(); - for (const ShaderParameterOption& option : definition.enumOptions) - { - if (option.value == selectedValue) - { - normalizedValue.enumValue = selectedValue; - return true; - } - } - error = "Enum parameter '" + definition.id + "' received unsupported option '" + selectedValue + "'."; - return false; - } - } - - return false; + return NormalizeAndValidateParameterValue(definition, value, normalizedValue, error); } ShaderParameterValue RuntimeHost::DefaultValueForDefinition(const ShaderParameterDefinition& definition) const { - ShaderParameterValue value; - switch (definition.type) - { - case ShaderParameterType::Float: - value.numberValues = definition.defaultNumbers.empty() ? std::vector{ 0.0 } : definition.defaultNumbers; - break; - case ShaderParameterType::Vec2: - value.numberValues = definition.defaultNumbers.size() == 2 ? definition.defaultNumbers : std::vector{ 0.0, 0.0 }; - break; - case ShaderParameterType::Color: - value.numberValues = definition.defaultNumbers.size() == 4 ? definition.defaultNumbers : std::vector{ 1.0, 1.0, 1.0, 1.0 }; - break; - case ShaderParameterType::Boolean: - value.booleanValue = definition.defaultBoolean; - break; - case ShaderParameterType::Enum: - value.enumValue = definition.defaultEnumValue; - break; - } - return value; + return ::DefaultValueForDefinition(definition); } void RuntimeHost::EnsureLayerDefaultsLocked(LayerPersistentState& layerState, const ShaderPackage& shaderPackage) const @@ -1737,26 +1615,7 @@ std::vector RuntimeHost::GetStackPresetNamesLocked() const std::string RuntimeHost::MakeSafePresetFileStem(const std::string& presetName) const { - std::string trimmed = Trim(presetName); - std::string safe; - safe.reserve(trimmed.size()); - - for (unsigned char ch : trimmed) - { - if (std::isalnum(ch)) - safe.push_back(static_cast(std::tolower(ch))); - else if (ch == ' ' || ch == '-' || ch == '_') - { - if (safe.empty() || safe.back() == '-') - continue; - safe.push_back('-'); - } - } - - while (!safe.empty() && safe.back() == '-') - safe.pop_back(); - - return safe; + return ::MakeSafePresetFileStem(presetName); } JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) const diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp new file mode 100644 index 0000000..59de344 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp @@ -0,0 +1,170 @@ +#include "stdafx.h" +#include "RuntimeParameterUtils.h" + +#include +#include +#include +#include + +namespace +{ +std::string TrimText(const std::string& text) +{ + std::size_t start = 0; + while (start < text.size() && std::isspace(static_cast(text[start]))) + ++start; + + std::size_t end = text.size(); + while (end > start && std::isspace(static_cast(text[end - 1]))) + --end; + + return text.substr(start, end - start); +} + +bool IsFiniteNumber(double value) +{ + return std::isfinite(value) != 0; +} + +std::vector JsonArrayToNumbers(const JsonValue& value) +{ + std::vector numbers; + for (const JsonValue& item : value.asArray()) + { + if (item.isNumber()) + numbers.push_back(item.asNumber()); + } + return numbers; +} +} + +std::string MakeSafePresetFileStem(const std::string& presetName) +{ + std::string trimmed = TrimText(presetName); + std::string safe; + safe.reserve(trimmed.size()); + + for (unsigned char ch : trimmed) + { + if (std::isalnum(ch)) + safe.push_back(static_cast(std::tolower(ch))); + else if (ch == ' ' || ch == '-' || ch == '_') + { + if (safe.empty() || safe.back() == '-') + continue; + safe.push_back('-'); + } + } + + while (!safe.empty() && safe.back() == '-') + safe.pop_back(); + + return safe; +} + +ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& definition) +{ + ShaderParameterValue value; + switch (definition.type) + { + case ShaderParameterType::Float: + value.numberValues = definition.defaultNumbers.empty() ? std::vector{ 0.0 } : definition.defaultNumbers; + break; + case ShaderParameterType::Vec2: + value.numberValues = definition.defaultNumbers.size() == 2 ? definition.defaultNumbers : std::vector{ 0.0, 0.0 }; + break; + case ShaderParameterType::Color: + value.numberValues = definition.defaultNumbers.size() == 4 ? definition.defaultNumbers : std::vector{ 1.0, 1.0, 1.0, 1.0 }; + break; + case ShaderParameterType::Boolean: + value.booleanValue = definition.defaultBoolean; + break; + case ShaderParameterType::Enum: + value.enumValue = definition.defaultEnumValue; + break; + } + return value; +} + +bool NormalizeAndValidateParameterValue(const ShaderParameterDefinition& definition, const JsonValue& value, ShaderParameterValue& normalizedValue, std::string& error) +{ + normalizedValue = DefaultValueForDefinition(definition); + + switch (definition.type) + { + case ShaderParameterType::Float: + { + if (!value.isNumber()) + { + error = "Expected numeric value for float parameter '" + definition.id + "'."; + return false; + } + double number = value.asNumber(); + if (!IsFiniteNumber(number)) + { + error = "Float parameter '" + definition.id + "' must be finite."; + return false; + } + if (!definition.minNumbers.empty()) + number = std::max(number, definition.minNumbers.front()); + if (!definition.maxNumbers.empty()) + number = std::min(number, definition.maxNumbers.front()); + normalizedValue.numberValues = { number }; + return true; + } + case ShaderParameterType::Vec2: + case ShaderParameterType::Color: + { + std::vector numbers = JsonArrayToNumbers(value); + const std::size_t expectedSize = definition.type == ShaderParameterType::Vec2 ? 2 : 4; + if (numbers.size() != expectedSize) + { + error = "Expected array value of size " + std::to_string(expectedSize) + " for parameter '" + definition.id + "'."; + return false; + } + for (std::size_t index = 0; index < numbers.size(); ++index) + { + if (!IsFiniteNumber(numbers[index])) + { + error = "Parameter '" + definition.id + "' contains a non-finite value."; + return false; + } + if (index < definition.minNumbers.size()) + numbers[index] = std::max(numbers[index], definition.minNumbers[index]); + if (index < definition.maxNumbers.size()) + numbers[index] = std::min(numbers[index], definition.maxNumbers[index]); + } + normalizedValue.numberValues = numbers; + return true; + } + case ShaderParameterType::Boolean: + if (!value.isBoolean()) + { + error = "Expected boolean value for parameter '" + definition.id + "'."; + return false; + } + normalizedValue.booleanValue = value.asBoolean(); + return true; + case ShaderParameterType::Enum: + { + if (!value.isString()) + { + error = "Expected string value for enum parameter '" + definition.id + "'."; + return false; + } + const std::string selectedValue = value.asString(); + for (const ShaderParameterOption& option : definition.enumOptions) + { + if (option.value == selectedValue) + { + normalizedValue.enumValue = selectedValue; + return true; + } + } + error = "Enum parameter '" + definition.id + "' received unsupported option '" + selectedValue + "'."; + return false; + } + } + + return false; +} diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.h b/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.h new file mode 100644 index 0000000..d812ea2 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.h @@ -0,0 +1,10 @@ +#pragma once + +#include "RuntimeJson.h" +#include "ShaderTypes.h" + +#include + +std::string MakeSafePresetFileStem(const std::string& presetName); +ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& definition); +bool NormalizeAndValidateParameterValue(const ShaderParameterDefinition& definition, const JsonValue& value, ShaderParameterValue& normalizedValue, std::string& error); diff --git a/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp b/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp new file mode 100644 index 0000000..de84943 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp @@ -0,0 +1,549 @@ +#include "stdafx.h" +#include "ShaderPackageRegistry.h" + +#include "RuntimeJson.h" + +#include +#include +#include +#include +#include + +namespace +{ +std::string Trim(const std::string& text) +{ + std::size_t start = 0; + while (start < text.size() && std::isspace(static_cast(text[start]))) + ++start; + + std::size_t end = text.size(); + while (end > start && std::isspace(static_cast(text[end - 1]))) + --end; + + return text.substr(start, end - start); +} + +bool IsFiniteNumber(double value) +{ + return std::isfinite(value) != 0; +} + +std::vector JsonArrayToNumbers(const JsonValue& value) +{ + std::vector numbers; + for (const JsonValue& item : value.asArray()) + { + if (item.isNumber()) + numbers.push_back(item.asNumber()); + } + return numbers; +} + +bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type) +{ + if (typeName == "float") + { + type = ShaderParameterType::Float; + return true; + } + if (typeName == "vec2") + { + type = ShaderParameterType::Vec2; + return true; + } + if (typeName == "color") + { + type = ShaderParameterType::Color; + return true; + } + if (typeName == "bool") + { + type = ShaderParameterType::Boolean; + return true; + } + if (typeName == "enum") + { + type = ShaderParameterType::Enum; + return true; + } + return false; +} + +bool ParseTemporalHistorySource(const std::string& sourceName, TemporalHistorySource& source) +{ + if (sourceName == "source") + { + source = TemporalHistorySource::Source; + return true; + } + if (sourceName == "preLayerInput") + { + source = TemporalHistorySource::PreLayerInput; + return true; + } + if (sourceName == "none") + { + source = TemporalHistorySource::None; + return true; + } + return false; +} + +std::string ReadTextFile(const std::filesystem::path& path, std::string& error) +{ + std::ifstream input(path, std::ios::binary); + if (!input) + { + error = "Could not open file: " + path.string(); + return std::string(); + } + + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +std::string ManifestPathMessage(const std::filesystem::path& manifestPath) +{ + return manifestPath.string(); +} + +bool RequireStringField(const JsonValue& object, const char* fieldName, std::string& value, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* fieldValue = object.find(fieldName); + if (!fieldValue || !fieldValue->isString()) + { + error = "Shader manifest is missing required string '" + std::string(fieldName) + "' field: " + ManifestPathMessage(manifestPath); + return false; + } + + value = fieldValue->asString(); + return true; +} + +bool RequireNonEmptyStringField(const JsonValue& object, const char* fieldName, std::string& value, const std::filesystem::path& manifestPath, std::string& error) +{ + if (!RequireStringField(object, fieldName, value, manifestPath, error)) + return false; + if (Trim(value).empty()) + { + error = "Shader manifest string '" + std::string(fieldName) + "' must not be empty: " + ManifestPathMessage(manifestPath); + return false; + } + return true; +} + +bool OptionalStringField(const JsonValue& object, const char* fieldName, std::string& value, const std::string& fallback, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* fieldValue = object.find(fieldName); + if (!fieldValue) + { + value = fallback; + return true; + } + if (!fieldValue->isString()) + { + error = "Shader manifest field '" + std::string(fieldName) + "' must be a string in: " + ManifestPathMessage(manifestPath); + return false; + } + value = fieldValue->asString(); + return true; +} + +bool OptionalArrayField(const JsonValue& object, const char* fieldName, const JsonValue*& value, const std::filesystem::path& manifestPath, std::string& error) +{ + value = object.find(fieldName); + if (!value) + return true; + if (!value->isArray()) + { + error = "Shader manifest '" + std::string(fieldName) + "' field must be an array in: " + ManifestPathMessage(manifestPath); + return false; + } + return true; +} + +bool OptionalObjectField(const JsonValue& object, const char* fieldName, const JsonValue*& value, const std::filesystem::path& manifestPath, std::string& error) +{ + value = object.find(fieldName); + if (!value) + return true; + if (!value->isObject()) + { + error = "Shader manifest '" + std::string(fieldName) + "' field must be an object in: " + ManifestPathMessage(manifestPath); + return false; + } + return true; +} + +bool NumberListFromJsonValue(const JsonValue& value, std::vector& numbers, const std::string& fieldName, const std::filesystem::path& manifestPath, std::string& error) +{ + if (value.isNumber()) + { + numbers.push_back(value.asNumber()); + return true; + } + if (value.isArray()) + { + numbers = JsonArrayToNumbers(value); + if (numbers.size() != value.asArray().size()) + { + error = "Shader parameter field '" + fieldName + "' must contain only numbers in: " + ManifestPathMessage(manifestPath); + return false; + } + return true; + } + + error = "Shader parameter field '" + fieldName + "' must be a number or array of numbers in: " + ManifestPathMessage(manifestPath); + return false; +} + +bool ValidateShaderIdentifier(const std::string& identifier, const std::string& fieldName, const std::filesystem::path& manifestPath, std::string& error) +{ + if (identifier.empty() || !(std::isalpha(static_cast(identifier.front())) || identifier.front() == '_')) + { + error = "Shader manifest field '" + fieldName + "' must be a valid shader identifier in: " + ManifestPathMessage(manifestPath); + return false; + } + + for (char ch : identifier) + { + const unsigned char unsignedCh = static_cast(ch); + if (!(std::isalnum(unsignedCh) || ch == '_')) + { + error = "Shader manifest field '" + fieldName + "' must be a valid shader identifier in: " + ManifestPathMessage(manifestPath); + return false; + } + } + + return true; +} + +bool ParseShaderMetadata(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error) +{ + if (!RequireStringField(manifestJson, "id", shaderPackage.id, manifestPath, error) || + !RequireStringField(manifestJson, "name", shaderPackage.displayName, manifestPath, error) || + !OptionalStringField(manifestJson, "description", shaderPackage.description, "", manifestPath, error) || + !OptionalStringField(manifestJson, "category", shaderPackage.category, "", manifestPath, error) || + !OptionalStringField(manifestJson, "entryPoint", shaderPackage.entryPoint, "shadeVideo", manifestPath, error)) + { + return false; + } + + if (!ValidateShaderIdentifier(shaderPackage.entryPoint, "entryPoint", manifestPath, error)) + return false; + + shaderPackage.directoryPath = manifestPath.parent_path(); + shaderPackage.shaderPath = shaderPackage.directoryPath / "shader.slang"; + shaderPackage.manifestPath = manifestPath; + return true; +} + +bool ParseTextureAssets(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* texturesValue = nullptr; + if (!OptionalArrayField(manifestJson, "textures", texturesValue, manifestPath, error)) + return false; + if (!texturesValue) + return true; + + for (const JsonValue& textureJson : texturesValue->asArray()) + { + if (!textureJson.isObject()) + { + error = "Shader texture entry must be an object in: " + ManifestPathMessage(manifestPath); + return false; + } + + std::string textureId; + std::string texturePath; + if (!RequireNonEmptyStringField(textureJson, "id", textureId, manifestPath, error) || + !RequireNonEmptyStringField(textureJson, "path", texturePath, manifestPath, error)) + { + error = "Shader texture is missing required 'id' or 'path' in: " + ManifestPathMessage(manifestPath); + return false; + } + if (!ValidateShaderIdentifier(textureId, "textures[].id", manifestPath, error)) + return false; + + ShaderTextureAsset textureAsset; + textureAsset.id = textureId; + textureAsset.path = shaderPackage.directoryPath / texturePath; + if (!std::filesystem::exists(textureAsset.path)) + { + error = "Shader texture asset not found for package " + shaderPackage.id + ": " + textureAsset.path.string(); + return false; + } + + textureAsset.writeTime = std::filesystem::last_write_time(textureAsset.path); + shaderPackage.textureAssets.push_back(textureAsset); + } + + return true; +} + +bool ParseTemporalSettings(const JsonValue& manifestJson, ShaderPackage& shaderPackage, unsigned maxTemporalHistoryFrames, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* temporalValue = nullptr; + if (!OptionalObjectField(manifestJson, "temporal", temporalValue, manifestPath, error)) + return false; + if (!temporalValue) + return true; + + const JsonValue* enabledValue = temporalValue->find("enabled"); + if (!enabledValue || !enabledValue->asBoolean(false)) + return true; + + std::string historySourceName; + if (!RequireNonEmptyStringField(*temporalValue, "historySource", historySourceName, manifestPath, error)) + { + error = "Temporal shader is missing required 'historySource' in: " + ManifestPathMessage(manifestPath); + return false; + } + + const JsonValue* historyLengthValue = temporalValue->find("historyLength"); + if (!historyLengthValue || !historyLengthValue->isNumber()) + { + error = "Temporal shader is missing required numeric 'historyLength' in: " + ManifestPathMessage(manifestPath); + return false; + } + + TemporalHistorySource historySource = TemporalHistorySource::None; + if (!ParseTemporalHistorySource(historySourceName, historySource)) + { + error = "Unsupported temporal historySource '" + historySourceName + "' in: " + ManifestPathMessage(manifestPath); + return false; + } + + const double requestedHistoryLength = historyLengthValue->asNumber(); + if (!IsFiniteNumber(requestedHistoryLength) || requestedHistoryLength <= 0.0 || std::floor(requestedHistoryLength) != requestedHistoryLength) + { + error = "Temporal shader 'historyLength' must be a positive integer in: " + ManifestPathMessage(manifestPath); + return false; + } + + shaderPackage.temporal.enabled = true; + shaderPackage.temporal.historySource = historySource; + shaderPackage.temporal.requestedHistoryLength = static_cast(requestedHistoryLength); + shaderPackage.temporal.effectiveHistoryLength = std::min(shaderPackage.temporal.requestedHistoryLength, maxTemporalHistoryFrames); + return true; +} + +bool ParseParameterNumberField(const JsonValue& parameterJson, const char* fieldName, std::vector& values, const std::filesystem::path& manifestPath, std::string& error) +{ + if (const JsonValue* fieldValue = parameterJson.find(fieldName)) + return NumberListFromJsonValue(*fieldValue, values, fieldName, manifestPath, error); + return true; +} + +bool ParseParameterDefault(const JsonValue& parameterJson, ShaderParameterDefinition& definition, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* defaultValue = parameterJson.find("default"); + if (!defaultValue) + return true; + + if (definition.type == ShaderParameterType::Boolean) + { + if (!defaultValue->isBoolean()) + { + error = "Boolean parameter default must be a boolean for: " + definition.id; + return false; + } + definition.defaultBoolean = defaultValue->asBoolean(false); + return true; + } + + if (definition.type == ShaderParameterType::Enum) + { + if (!defaultValue->isString()) + { + error = "Enum parameter default must be a string for: " + definition.id; + return false; + } + definition.defaultEnumValue = defaultValue->asString(); + return true; + } + + return NumberListFromJsonValue(*defaultValue, definition.defaultNumbers, "default", manifestPath, error); +} + +bool ParseParameterOptions(const JsonValue& parameterJson, ShaderParameterDefinition& definition, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* optionsValue = nullptr; + if (!OptionalArrayField(parameterJson, "options", optionsValue, manifestPath, error) || !optionsValue) + { + error = "Enum parameter is missing 'options' in: " + ManifestPathMessage(manifestPath); + return false; + } + + for (const JsonValue& optionJson : optionsValue->asArray()) + { + if (!optionJson.isObject()) + { + error = "Enum parameter option must be an object in: " + ManifestPathMessage(manifestPath); + return false; + } + + ShaderParameterOption option; + if (!RequireStringField(optionJson, "value", option.value, manifestPath, error) || + !RequireStringField(optionJson, "label", option.label, manifestPath, error)) + { + error = "Enum parameter option is missing 'value' or 'label' in: " + ManifestPathMessage(manifestPath); + return false; + } + definition.enumOptions.push_back(option); + } + + bool defaultFound = definition.defaultEnumValue.empty(); + for (const ShaderParameterOption& option : definition.enumOptions) + { + if (option.value == definition.defaultEnumValue) + { + defaultFound = true; + break; + } + } + + if (!defaultFound) + { + error = "Enum parameter default is not present in its option list for: " + definition.id; + return false; + } + + return true; +} + +bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDefinition& definition, const std::filesystem::path& manifestPath, std::string& error) +{ + if (!parameterJson.isObject()) + { + error = "Shader parameter entry must be an object in: " + ManifestPathMessage(manifestPath); + return false; + } + + std::string typeName; + if (!RequireStringField(parameterJson, "id", definition.id, manifestPath, error) || + !RequireStringField(parameterJson, "label", definition.label, manifestPath, error) || + !RequireStringField(parameterJson, "type", typeName, manifestPath, error)) + { + error = "Shader parameter is missing required fields in: " + ManifestPathMessage(manifestPath); + return false; + } + + if (!ParseShaderParameterType(typeName, definition.type)) + { + error = "Unsupported parameter type '" + typeName + "' in: " + ManifestPathMessage(manifestPath); + return false; + } + if (!ValidateShaderIdentifier(definition.id, "parameters[].id", manifestPath, error)) + return false; + + if (!ParseParameterDefault(parameterJson, definition, manifestPath, error) || + !ParseParameterNumberField(parameterJson, "min", definition.minNumbers, manifestPath, error) || + !ParseParameterNumberField(parameterJson, "max", definition.maxNumbers, manifestPath, error) || + !ParseParameterNumberField(parameterJson, "step", definition.stepNumbers, manifestPath, error)) + { + return false; + } + + if (definition.type == ShaderParameterType::Enum) + return ParseParameterOptions(parameterJson, definition, manifestPath, error); + + return true; +} + +bool ParseParameterDefinitions(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* parametersValue = nullptr; + if (!OptionalArrayField(manifestJson, "parameters", parametersValue, manifestPath, error)) + return false; + if (!parametersValue) + return true; + + for (const JsonValue& parameterJson : parametersValue->asArray()) + { + ShaderParameterDefinition definition; + if (!ParseParameterDefinition(parameterJson, definition, manifestPath, error)) + return false; + shaderPackage.parameters.push_back(definition); + } + + return true; +} +} + +ShaderPackageRegistry::ShaderPackageRegistry(unsigned maxTemporalHistoryFrames) + : mMaxTemporalHistoryFrames(maxTemporalHistoryFrames) +{ +} + +bool ShaderPackageRegistry::Scan(const std::filesystem::path& shaderRoot, std::map& packagesById, std::vector& packageOrder, std::string& error) const +{ + packagesById.clear(); + packageOrder.clear(); + + if (!std::filesystem::exists(shaderRoot)) + { + error = "Shader library directory does not exist: " + shaderRoot.string(); + return false; + } + + for (const auto& entry : std::filesystem::directory_iterator(shaderRoot)) + { + if (!entry.is_directory()) + continue; + + std::filesystem::path manifestPath = entry.path() / "shader.json"; + if (!std::filesystem::exists(manifestPath)) + continue; + + ShaderPackage shaderPackage; + if (!ParseManifest(manifestPath, shaderPackage, error)) + return false; + + if (packagesById.find(shaderPackage.id) != packagesById.end()) + { + error = "Duplicate shader id found: " + shaderPackage.id; + return false; + } + + packageOrder.push_back(shaderPackage.id); + packagesById[shaderPackage.id] = shaderPackage; + } + + std::sort(packageOrder.begin(), packageOrder.end()); + return true; +} + +bool ShaderPackageRegistry::ParseManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const +{ + const std::string manifestText = ReadTextFile(manifestPath, error); + if (manifestText.empty()) + return false; + + JsonValue manifestJson; + if (!ParseJson(manifestText, manifestJson, error)) + return false; + if (!manifestJson.isObject()) + { + error = "Shader manifest root must be an object: " + manifestPath.string(); + return false; + } + + if (!ParseShaderMetadata(manifestJson, shaderPackage, manifestPath, error)) + return false; + + if (!std::filesystem::exists(shaderPackage.shaderPath)) + { + error = "Shader source not found for package " + shaderPackage.id + ": " + shaderPackage.shaderPath.string(); + return false; + } + + shaderPackage.shaderWriteTime = std::filesystem::last_write_time(shaderPackage.shaderPath); + shaderPackage.manifestWriteTime = std::filesystem::last_write_time(shaderPackage.manifestPath); + + return ParseTextureAssets(manifestJson, shaderPackage, manifestPath, error) && + ParseTemporalSettings(manifestJson, shaderPackage, mMaxTemporalHistoryFrames, manifestPath, error) && + ParseParameterDefinitions(manifestJson, shaderPackage, manifestPath, error); +} diff --git a/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.h b/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.h new file mode 100644 index 0000000..2ed3a00 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.h @@ -0,0 +1,20 @@ +#pragma once + +#include "ShaderTypes.h" + +#include +#include +#include +#include + +class ShaderPackageRegistry +{ +public: + explicit ShaderPackageRegistry(unsigned maxTemporalHistoryFrames); + + bool Scan(const std::filesystem::path& shaderRoot, std::map& packagesById, std::vector& packageOrder, std::string& error) const; + bool ParseManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const; + +private: + unsigned mMaxTemporalHistoryFrames; +}; diff --git a/runtime/README.md b/runtime/README.md new file mode 100644 index 0000000..99f108c --- /dev/null +++ b/runtime/README.md @@ -0,0 +1,21 @@ +# Runtime Files + +This directory is used by the native host for local runtime output. + +Tracked files: + +- `templates/`: source templates used to generate shader runtime code. + +Generated files: + +- `shader_cache/active_shader_wrapper.slang`: generated Slang wrapper for the active shader/layer. +- `shader_cache/active_shader.raw.frag`: raw GLSL emitted by `slangc`. +- `shader_cache/active_shader.frag`: patched GLSL consumed by the OpenGL path. +- `runtime_state.json`: persisted layer stack and parameter values. +- `stack_presets/*.json`: user-saved layer stack presets. + +Git policy: + +- Runtime cache/state/preset output is ignored by default. +- Template files are source files and should stay tracked. +- If a project wants shared stack presets, move selected preset JSON files into a tracked fixtures or presets directory intentionally rather than committing the whole runtime output tree. diff --git a/tests/RuntimeParameterUtilsTests.cpp b/tests/RuntimeParameterUtilsTests.cpp new file mode 100644 index 0000000..7a6ea57 --- /dev/null +++ b/tests/RuntimeParameterUtilsTests.cpp @@ -0,0 +1,120 @@ +#include "RuntimeParameterUtils.h" + +#include +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +ShaderParameterDefinition MakeFloatDefinition() +{ + ShaderParameterDefinition definition; + definition.id = "gain"; + definition.type = ShaderParameterType::Float; + definition.defaultNumbers = { 0.5 }; + definition.minNumbers = { 0.0 }; + definition.maxNumbers = { 1.0 }; + return definition; +} + +void TestSafePresetFileStems() +{ + Expect(MakeSafePresetFileStem(" Show Look 01 ") == "show-look-01", "preset names are trimmed and dashed"); + Expect(MakeSafePresetFileStem("A__B---C") == "a-b-c", "duplicate separators collapse"); + Expect(MakeSafePresetFileStem("../Unsafe Name!") == "unsafe-name", "unsafe punctuation is dropped"); + Expect(MakeSafePresetFileStem("!!!") == "", "names without alphanumeric characters become empty"); +} + +void TestFloatNormalization() +{ + ShaderParameterDefinition definition = MakeFloatDefinition(); + ShaderParameterValue value; + std::string error; + + Expect(NormalizeAndValidateParameterValue(definition, JsonValue(2.0), value, error), "float parameter accepts numeric values"); + Expect(value.numberValues.size() == 1 && value.numberValues.front() == 1.0, "float parameter clamps to max"); + + error.clear(); + Expect(NormalizeAndValidateParameterValue(definition, JsonValue(-10.0), value, error), "float parameter accepts low numeric values"); + Expect(value.numberValues.size() == 1 && value.numberValues.front() == 0.0, "float parameter clamps to min"); + + error.clear(); + Expect(!NormalizeAndValidateParameterValue(definition, JsonValue("bad"), value, error), "float parameter rejects non-numeric values"); + Expect(!error.empty(), "float rejection includes an error"); +} + +void TestVectorNormalization() +{ + ShaderParameterDefinition definition; + definition.id = "offset"; + definition.type = ShaderParameterType::Vec2; + definition.defaultNumbers = { 0.0, 0.0 }; + definition.minNumbers = { -1.0, -2.0 }; + definition.maxNumbers = { 1.0, 2.0 }; + + JsonValue input = JsonValue::MakeArray(); + input.pushBack(JsonValue(5.0)); + input.pushBack(JsonValue(-5.0)); + + ShaderParameterValue value; + std::string error; + Expect(NormalizeAndValidateParameterValue(definition, input, value, error), "vec2 parameter accepts arrays"); + Expect(value.numberValues.size() == 2 && value.numberValues[0] == 1.0 && value.numberValues[1] == -2.0, "vec2 parameter clamps each component"); + + JsonValue shortInput = JsonValue::MakeArray(); + shortInput.pushBack(JsonValue(0.0)); + error.clear(); + Expect(!NormalizeAndValidateParameterValue(definition, shortInput, value, error), "vec2 parameter rejects wrong component count"); +} + +void TestEnumAndDefaults() +{ + ShaderParameterDefinition definition; + definition.id = "mode"; + definition.type = ShaderParameterType::Enum; + definition.defaultEnumValue = "soft"; + definition.enumOptions = { + { "soft", "Soft" }, + { "hard", "Hard" } + }; + + ShaderParameterValue defaultValue = DefaultValueForDefinition(definition); + Expect(defaultValue.enumValue == "soft", "enum default is copied from definition"); + + ShaderParameterValue value; + std::string error; + Expect(NormalizeAndValidateParameterValue(definition, JsonValue("hard"), value, error), "enum accepts listed options"); + Expect(value.enumValue == "hard", "enum stores selected option"); + + error.clear(); + Expect(!NormalizeAndValidateParameterValue(definition, JsonValue("other"), value, error), "enum rejects unknown options"); +} +} + +int main() +{ + TestSafePresetFileStems(); + TestFloatNormalization(); + TestVectorNormalization(); + TestEnumAndDefaults(); + + if (gFailures != 0) + { + std::cerr << gFailures << " RuntimeParameterUtils test failure(s).\n"; + return 1; + } + + std::cout << "RuntimeParameterUtils tests passed.\n"; + return 0; +} diff --git a/tests/ShaderPackageRegistryTests.cpp b/tests/ShaderPackageRegistryTests.cpp new file mode 100644 index 0000000..1804dab --- /dev/null +++ b/tests/ShaderPackageRegistryTests.cpp @@ -0,0 +1,129 @@ +#include "ShaderPackageRegistry.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +std::filesystem::path MakeTestRoot() +{ + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + std::filesystem::path root = std::filesystem::temp_directory_path() / ("video-shader-registry-tests-" + std::to_string(stamp)); + std::filesystem::create_directories(root); + return root; +} + +void WriteFile(const std::filesystem::path& path, const std::string& contents) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + output << contents; +} + +void WriteShaderPackage(const std::filesystem::path& root, const std::string& directoryName, const std::string& manifest) +{ + const std::filesystem::path packageRoot = root / directoryName; + WriteFile(packageRoot / "shader.json", manifest); + WriteFile(packageRoot / "shader.slang", "float4 shadeVideo(float2 uv) { return float4(uv, 0.0, 1.0); }\n"); +} + +void TestValidManifest() +{ + const std::filesystem::path root = MakeTestRoot(); + WriteFile(root / "look" / "mask.png", "not a real png, but enough for existence checks"); + WriteShaderPackage(root, "look", R"({ + "id": "look-01", + "name": "Look 01", + "description": "Test package", + "category": "Tests", + "entryPoint": "shadeVideo", + "textures": [{ "id": "maskTex", "path": "mask.png" }], + "temporal": { "enabled": true, "historySource": "source", "historyLength": 8 }, + "parameters": [ + { "id": "gain", "label": "Gain", "type": "float", "default": 0.5, "min": 0, "max": 1 }, + { "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [ + { "value": "soft", "label": "Soft" }, + { "value": "hard", "label": "Hard" } + ] } + ] + })"); + + ShaderPackageRegistry registry(4); + ShaderPackage package; + std::string error; + Expect(registry.ParseManifest(root / "look" / "shader.json", package, error), "valid manifest parses"); + Expect(package.id == "look-01", "manifest id is preserved"); + Expect(package.textureAssets.size() == 1 && package.textureAssets[0].id == "maskTex", "texture assets parse"); + Expect(package.temporal.enabled && package.temporal.effectiveHistoryLength == 4, "temporal history is capped"); + Expect(package.parameters.size() == 2, "parameters parse"); + + std::filesystem::remove_all(root); +} + +void TestInvalidManifest() +{ + const std::filesystem::path root = MakeTestRoot(); + WriteShaderPackage(root, "bad", R"({ + "id": "bad", + "name": "Bad", + "entryPoint": "not-valid!", + "parameters": [] + })"); + + ShaderPackageRegistry registry(4); + ShaderPackage package; + std::string error; + Expect(!registry.ParseManifest(root / "bad" / "shader.json", package, error), "invalid shader identifier is rejected"); + Expect(error.find("entryPoint") != std::string::npos, "invalid manifest error names the bad field"); + + std::filesystem::remove_all(root); +} + +void TestDuplicateScan() +{ + const std::filesystem::path root = MakeTestRoot(); + WriteShaderPackage(root, "a", R"({ "id": "dupe", "name": "A", "parameters": [] })"); + WriteShaderPackage(root, "b", R"({ "id": "dupe", "name": "B", "parameters": [] })"); + + ShaderPackageRegistry registry(4); + std::map packages; + std::vector order; + std::string error; + Expect(!registry.Scan(root, packages, order, error), "duplicate package ids are rejected"); + Expect(error.find("Duplicate shader id") != std::string::npos, "duplicate scan error is clear"); + + std::filesystem::remove_all(root); +} +} + +int main() +{ + TestValidManifest(); + TestInvalidManifest(); + TestDuplicateScan(); + + if (gFailures != 0) + { + std::cerr << gFailures << " ShaderPackageRegistry test failure(s).\n"; + return 1; + } + + std::cout << "ShaderPackageRegistry tests passed.\n"; + return 0; +}