diff --git a/.gitignore b/.gitignore index 2c75e12..7ef9afa 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ build.ninja *.dmp *.tmp /runtime/ +/ui/node_modules/ +/ui/dist/ diff --git a/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp b/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp index d582d2d..c746e71 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp @@ -34,6 +34,38 @@ std::string ToLower(std::string text) [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return text; } + +bool IsSafeUiPath(const std::filesystem::path& relativePath) +{ + for (const std::filesystem::path& part : relativePath) + { + if (part == "..") + return false; + } + return !relativePath.empty(); +} + +std::string GuessContentType(const std::filesystem::path& assetPath) +{ + const std::string extension = ToLower(assetPath.extension().string()); + if (extension == ".js" || extension == ".mjs") + return "text/javascript"; + if (extension == ".css") + return "text/css"; + if (extension == ".json") + return "application/json"; + if (extension == ".svg") + return "image/svg+xml"; + if (extension == ".png") + return "image/png"; + if (extension == ".jpg" || extension == ".jpeg") + return "image/jpeg"; + if (extension == ".ico") + return "image/x-icon"; + if (extension == ".map") + return "application/json"; + return "text/html"; +} } ControlServer::ControlServer() @@ -204,20 +236,21 @@ bool ControlServer::HandleHttpRequest(SOCKET clientSocket, const std::string& re closesocket(clientSocket); return true; } - if (path == "/app.js" || path == "/styles.css") - { - std::string contentType; - std::string body = LoadUiAsset(path.substr(1), contentType); - SendHttpResponse(clientSocket, "200 OK", contentType, body); - closesocket(clientSocket); - return true; - } if (path == "/api/state") { SendHttpResponse(clientSocket, "200 OK", "application/json", mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}"); closesocket(clientSocket); return true; } + + std::string contentType; + std::string body = LoadUiAsset(path.substr(1), contentType); + if (!body.empty()) + { + SendHttpResponse(clientSocket, "200 OK", contentType, body); + closesocket(clientSocket); + return true; + } } else if (method == "POST") { @@ -234,36 +267,69 @@ bool ControlServer::HandleHttpRequest(SOCKET clientSocket, const std::string& re bool success = false; std::string actionError; - if (path == "/api/select-shader") + if (path == "/api/layers/add") { const JsonValue* shaderId = root.find("shaderId"); - success = shaderId && mCallbacks.selectShader && mCallbacks.selectShader(shaderId->asString(), actionError); + success = shaderId && mCallbacks.addLayer && mCallbacks.addLayer(shaderId->asString(), actionError); } - else if (path == "/api/update-parameter") + else if (path == "/api/layers/remove") { + const JsonValue* layerId = root.find("layerId"); + success = layerId && mCallbacks.removeLayer && mCallbacks.removeLayer(layerId->asString(), actionError); + } + else if (path == "/api/layers/move") + { + const JsonValue* layerId = root.find("layerId"); + const JsonValue* direction = root.find("direction"); + if (layerId && direction && mCallbacks.moveLayer) + success = mCallbacks.moveLayer(layerId->asString(), static_cast(direction->asNumber()), actionError); + } + else if (path == "/api/layers/reorder") + { + const JsonValue* layerId = root.find("layerId"); + const JsonValue* targetIndex = root.find("targetIndex"); + if (layerId && targetIndex && mCallbacks.moveLayerToIndex) + success = mCallbacks.moveLayerToIndex(layerId->asString(), static_cast(targetIndex->asNumber()), actionError); + } + else if (path == "/api/layers/set-bypass") + { + const JsonValue* layerId = root.find("layerId"); + const JsonValue* bypass = root.find("bypass"); + if (layerId && bypass && mCallbacks.setLayerBypass) + success = mCallbacks.setLayerBypass(layerId->asString(), bypass->asBoolean(), actionError); + } + else if (path == "/api/layers/set-shader") + { + const JsonValue* layerId = root.find("layerId"); const JsonValue* shaderId = root.find("shaderId"); + if (layerId && shaderId && mCallbacks.setLayerShader) + success = mCallbacks.setLayerShader(layerId->asString(), shaderId->asString(), actionError); + } + else if (path == "/api/layers/update-parameter") + { + const JsonValue* layerId = root.find("layerId"); const JsonValue* parameterId = root.find("parameterId"); const JsonValue* value = root.find("value"); - if (shaderId && parameterId && value && mCallbacks.updateParameter) - success = mCallbacks.updateParameter(shaderId->asString(), parameterId->asString(), SerializeJson(*value, false), actionError); + if (layerId && parameterId && value && mCallbacks.updateLayerParameter) + success = mCallbacks.updateLayerParameter(layerId->asString(), parameterId->asString(), SerializeJson(*value, false), actionError); } - else if (path == "/api/reset-parameters") + else if (path == "/api/layers/reset-parameters") { - const JsonValue* shaderId = root.find("shaderId"); - if (shaderId && mCallbacks.resetParameters) - success = mCallbacks.resetParameters(shaderId->asString(), actionError); + const JsonValue* layerId = root.find("layerId"); + if (layerId && mCallbacks.resetLayerParameters) + success = mCallbacks.resetLayerParameters(layerId->asString(), actionError); } - else if (path == "/api/set-bypass") + else if (path == "/api/stack-presets/save") { - const JsonValue* bypass = root.find("bypass"); - if (bypass && mCallbacks.setBypass) - success = mCallbacks.setBypass(bypass->asBoolean(), actionError); + const JsonValue* presetName = root.find("presetName"); + if (presetName && mCallbacks.saveStackPreset) + success = mCallbacks.saveStackPreset(presetName->asString(), actionError); } - else if (path == "/api/set-mix") + else if (path == "/api/stack-presets/load") { - const JsonValue* mixAmount = root.find("mixAmount"); - if (mixAmount && mCallbacks.setMixAmount) - success = mCallbacks.setMixAmount(mixAmount->asNumber(), actionError); + const JsonValue* presetName = root.find("presetName"); + if (presetName && mCallbacks.loadStackPreset) + success = mCallbacks.loadStackPreset(presetName->asString(), actionError); } else if (path == "/api/reload") { @@ -357,17 +423,16 @@ void ControlServer::BroadcastStateLocked() std::string ControlServer::LoadUiAsset(const std::string& relativePath, std::string& contentType) const { - const std::filesystem::path assetPath = mUiRoot / relativePath; + const std::filesystem::path sanitizedPath = std::filesystem::path(relativePath).lexically_normal(); + if (!IsSafeUiPath(sanitizedPath)) + return std::string(); + + const std::filesystem::path assetPath = mUiRoot / sanitizedPath; std::ifstream input(assetPath, std::ios::binary); if (!input) - return "Missing UI asset

UI asset missing.

"; + return std::string(); - if (assetPath.extension() == ".js") - contentType = "text/javascript"; - else if (assetPath.extension() == ".css") - contentType = "text/css"; - else - contentType = "text/html"; + contentType = GuessContentType(assetPath); std::ostringstream buffer; buffer << input.rdbuf(); diff --git a/apps/LoopThroughWithOpenGLCompositing/ControlServer.h b/apps/LoopThroughWithOpenGLCompositing/ControlServer.h index c0ec059..755bc33 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ControlServer.h +++ b/apps/LoopThroughWithOpenGLCompositing/ControlServer.h @@ -16,11 +16,16 @@ public: struct Callbacks { std::function getStateJson; - std::function selectShader; - std::function updateParameter; - std::function resetParameters; - std::function setBypass; - std::function setMixAmount; + std::function addLayer; + std::function removeLayer; + std::function moveLayer; + std::function moveLayerToIndex; + std::function setLayerBypass; + std::function setLayerShader; + std::function updateLayerParameter; + std::function resetLayerParameters; + std::function saveStackPreset; + std::function loadStackPreset; std::function reloadShader; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp index 001127e..8c3db7e 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp @@ -156,16 +156,15 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) : mFastTransferExtensionAvailable(false), mCaptureTexture(0), mDecodedTexture(0), + mLayerTempTexture(0), mFBOTexture(0), mDecodeFrameBuf(0), + mLayerTempFrameBuf(0), mFullscreenVAO(0), mGlobalParamsUBO(0), mDecodeProgram(0), mDecodeVertexShader(0), mDecodeFragmentShader(0), - mProgram(0), - mVertexShader(0), - mFragmentShader(0), mGlobalParamsUBOSize(0) { InitializeCriticalSection(&pMutex); @@ -228,6 +227,8 @@ OpenGLComposite::~OpenGLComposite() glDeleteBuffers(1, &mGlobalParamsUBO); if (mDecodeFrameBuf != 0) glDeleteFramebuffers(1, &mDecodeFrameBuf); + if (mLayerTempFrameBuf != 0) + glDeleteFramebuffers(1, &mLayerTempFrameBuf); if (mIdFrameBuf != 0) glDeleteFramebuffers(1, &mIdFrameBuf); if (mIdColorBuf != 0) @@ -238,12 +239,14 @@ OpenGLComposite::~OpenGLComposite() glDeleteTextures(1, &mCaptureTexture); if (mDecodedTexture != 0) glDeleteTextures(1, &mDecodedTexture); + if (mLayerTempTexture != 0) + glDeleteTextures(1, &mLayerTempTexture); if (mFBOTexture != 0) glDeleteTextures(1, &mFBOTexture); if (mUnpinnedTextureBuffer != 0) glDeleteBuffers(1, &mUnpinnedTextureBuffer); - destroyShaderProgram(); + destroyLayerPrograms(); destroyDecodeShaderProgram(); if (mControlServer) mControlServer->Stop(); @@ -536,13 +539,18 @@ bool OpenGLComposite::InitOpenGLState() ControlServer::Callbacks callbacks; callbacks.getStateJson = [this]() { return GetRuntimeStateJson(); }; - callbacks.selectShader = [this](const std::string& shaderId, std::string& error) { return SelectShader(shaderId, error); }; - callbacks.updateParameter = [this](const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error) { - return UpdateParameterJson(shaderId, parameterId, valueJson, error); + callbacks.addLayer = [this](const std::string& shaderId, std::string& error) { return AddLayer(shaderId, error); }; + callbacks.removeLayer = [this](const std::string& layerId, std::string& error) { return RemoveLayer(layerId, error); }; + callbacks.moveLayer = [this](const std::string& layerId, int direction, std::string& error) { return MoveLayer(layerId, direction, error); }; + callbacks.moveLayerToIndex = [this](const std::string& layerId, std::size_t targetIndex, std::string& error) { return MoveLayerToIndex(layerId, targetIndex, error); }; + callbacks.setLayerBypass = [this](const std::string& layerId, bool bypassed, std::string& error) { return SetLayerBypass(layerId, bypassed, error); }; + callbacks.setLayerShader = [this](const std::string& layerId, const std::string& shaderId, std::string& error) { return SetLayerShader(layerId, shaderId, error); }; + callbacks.updateLayerParameter = [this](const std::string& layerId, const std::string& parameterId, const std::string& valueJson, std::string& error) { + return UpdateLayerParameterJson(layerId, parameterId, valueJson, error); }; - callbacks.resetParameters = [this](const std::string& shaderId, std::string& error) { return ResetShaderParameters(shaderId, error); }; - callbacks.setBypass = [this](bool bypassEnabled, std::string& error) { return SetBypassEnabled(bypassEnabled, error); }; - callbacks.setMixAmount = [this](double mixAmount, std::string& error) { return SetMixAmount(mixAmount, error); }; + callbacks.resetLayerParameters = [this](const std::string& layerId, std::string& error) { return ResetLayerParameters(layerId, error); }; + callbacks.saveStackPreset = [this](const std::string& presetName, std::string& error) { return SaveStackPreset(presetName, error); }; + callbacks.loadStackPreset = [this](const std::string& presetName, std::string& error) { return LoadStackPreset(presetName, error); }; callbacks.reloadShader = [this](std::string& error) { if (!ReloadShader()) { @@ -567,7 +575,7 @@ bool OpenGLComposite::InitOpenGLState() return false; } - if (! compileFragmentShader(sizeof(compilerErrorMessage), compilerErrorMessage)) + if (! compileLayerPrograms(sizeof(compilerErrorMessage), compilerErrorMessage)) { MessageBoxA(NULL, compilerErrorMessage, "OpenGL shader failed to load or compile", MB_OK); return false; @@ -606,10 +614,20 @@ bool OpenGLComposite::InitOpenGLState() glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL); glBindTexture(GL_TEXTURE_2D, 0); + glGenTextures(1, &mLayerTempTexture); + glBindTexture(GL_TEXTURE_2D, mLayerTempTexture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL); + glBindTexture(GL_TEXTURE_2D, 0); + // Create Frame Buffer Object (FBO) to perform off-screen rendering of scene. // This allows the render to be done on a framebuffer with width and height exactly matching the video format. glGenFramebuffers(1, &mDecodeFrameBuf); + glGenFramebuffers(1, &mLayerTempFrameBuf); glGenFramebuffers(1, &mIdFrameBuf); glGenRenderbuffers(1, &mIdColorBuf); glGenRenderbuffers(1, &mIdDepthBuf); @@ -625,6 +643,15 @@ bool OpenGLComposite::InitOpenGLState() return false; } + glBindFramebuffer(GL_FRAMEBUFFER, mLayerTempFrameBuf); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mLayerTempTexture, 0); + glStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (glStatus != GL_FRAMEBUFFER_COMPLETE) + { + MessageBox(NULL, _T("Cannot initialize layer framebuffer."), _T("OpenGL initialization error."), MB_OK); + return false; + } + glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf); // Texture for FBO @@ -872,7 +899,7 @@ bool OpenGLComposite::ReloadShader() EnterCriticalSection(&pMutex); wglMakeCurrent(hGLDC, hGLRC); - bool success = compileFragmentShader(sizeof(compilerErrorMessage), compilerErrorMessage); + bool success = compileLayerPrograms(sizeof(compilerErrorMessage), compilerErrorMessage); if (mRuntimeHost) mRuntimeHost->ClearReloadRequest(); @@ -895,6 +922,98 @@ bool OpenGLComposite::ReloadShader() return success; } +bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage) +{ + GLsizei errorBufferSize = 0; + GLint compileResult = GL_FALSE; + GLint linkResult = GL_FALSE; + std::string fragmentShaderSource; + std::string loadError; + const char* vertexSource = kVertexShaderSource; + + if (!mRuntimeHost->BuildLayerFragmentShaderSource(state.layerId, fragmentShaderSource, loadError)) + { + CopyErrorMessage(loadError, errorMessageSize, errorMessage); + return false; + } + + 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); + if (compileResult == GL_FALSE) + { + glGetShaderInfoLog(newVertexShader, errorMessageSize, &errorBufferSize, errorMessage); + glDeleteShader(newVertexShader); + return false; + } + + GLuint newFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(newFragmentShader, 1, (const GLchar**)&fragmentSource, NULL); + glCompileShader(newFragmentShader); + glGetShaderiv(newFragmentShader, GL_COMPILE_STATUS, &compileResult); + if (compileResult == GL_FALSE) + { + glGetShaderInfoLog(newFragmentShader, errorMessageSize, &errorBufferSize, errorMessage); + glDeleteShader(newVertexShader); + glDeleteShader(newFragmentShader); + return false; + } + + GLuint newProgram = glCreateProgram(); + glAttachShader(newProgram, newVertexShader); + glAttachShader(newProgram, newFragmentShader); + glLinkProgram(newProgram); + glGetProgramiv(newProgram, GL_LINK_STATUS, &linkResult); + if (linkResult == GL_FALSE) + { + glGetProgramInfoLog(newProgram, errorMessageSize, &errorBufferSize, errorMessage); + glDeleteProgram(newProgram); + glDeleteShader(newVertexShader); + glDeleteShader(newFragmentShader); + return false; + } + + layerProgram.layerId = state.layerId; + layerProgram.shaderId = state.shaderId; + layerProgram.program = newProgram; + layerProgram.vertexShader = newVertexShader; + layerProgram.fragmentShader = newFragmentShader; + return true; +} + +bool OpenGLComposite::compileLayerPrograms(int errorMessageSize, char* errorMessage) +{ + const std::vector layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mFrameWidth, mFrameHeight) : std::vector(); + std::vector newPrograms; + newPrograms.reserve(layerStates.size()); + + for (const RuntimeRenderState& state : layerStates) + { + LayerProgram layerProgram; + if (!compileSingleLayerProgram(state, layerProgram, errorMessageSize, errorMessage)) + { + for (LayerProgram& program : newPrograms) + destroySingleLayerProgram(program); + return false; + } + newPrograms.push_back(layerProgram); + } + + destroyLayerPrograms(); + mLayerPrograms.swap(newPrograms); + + if (mRuntimeHost) + { + mRuntimeHost->SetCompileStatus(true, "Shader layers compiled successfully."); + mRuntimeHost->ClearReloadRequest(); + } + + return true; +} + bool OpenGLComposite::compileDecodeShader(int errorMessageSize, char* errorMessage) { GLsizei errorBufferSize = 0; @@ -947,27 +1066,34 @@ bool OpenGLComposite::compileDecodeShader(int errorMessageSize, char* errorMessa return true; } -void OpenGLComposite::destroyShaderProgram() +void OpenGLComposite::destroySingleLayerProgram(LayerProgram& layerProgram) { - if (mProgram != 0) + if (layerProgram.program != 0) { - glDeleteProgram(mProgram); - mProgram = 0; + glDeleteProgram(layerProgram.program); + layerProgram.program = 0; } - if (mFragmentShader != 0) + if (layerProgram.fragmentShader != 0) { - glDeleteShader(mFragmentShader); - mFragmentShader = 0; + glDeleteShader(layerProgram.fragmentShader); + layerProgram.fragmentShader = 0; } - if (mVertexShader != 0) + if (layerProgram.vertexShader != 0) { - glDeleteShader(mVertexShader); - mVertexShader = 0; + glDeleteShader(layerProgram.vertexShader); + layerProgram.vertexShader = 0; } } +void OpenGLComposite::destroyLayerPrograms() +{ + for (LayerProgram& layerProgram : mLayerPrograms) + destroySingleLayerProgram(layerProgram); + mLayerPrograms.clear(); +} + void OpenGLComposite::destroyDecodeShaderProgram() { if (mDecodeProgram != 0) @@ -1006,29 +1132,45 @@ void OpenGLComposite::renderEffect() glDisable(GL_DEPTH_TEST); renderDecodePass(); - glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf); + const std::vector layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mFrameWidth, mFrameHeight) : std::vector(); + if (layerStates.empty() || mLayerPrograms.empty()) + { + glBindFramebuffer(GL_READ_FRAMEBUFFER, mDecodeFrameBuf); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, mIdFrameBuf); + glBlitFramebuffer(0, 0, mFrameWidth, mFrameHeight, 0, 0, mFrameWidth, mFrameHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR); + glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf); + } + else + { + GLuint sourceTexture = mDecodedTexture; + for (std::size_t index = 0; index < layerStates.size() && index < mLayerPrograms.size(); ++index) + { + const std::size_t remaining = layerStates.size() - index; + const bool writeToMain = (remaining % 2) == 1; + renderShaderProgram(sourceTexture, writeToMain ? mIdFrameBuf : mLayerTempFrameBuf, mLayerPrograms[index], layerStates[index]); + sourceTexture = writeToMain ? mFBOTexture : mLayerTempTexture; + } + } + + if (mFastTransferExtensionAvailable) + VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU); +} + +void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state) +{ + glBindFramebuffer(GL_FRAMEBUFFER, destinationFrameBuffer); glViewport(0, 0, mFrameWidth, mFrameHeight); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glActiveTexture(GL_TEXTURE0 + kDecodedVideoTextureUnit); - glBindTexture(GL_TEXTURE_2D, mDecodedTexture); + glBindTexture(GL_TEXTURE_2D, sourceTexture); glBindVertexArray(mFullscreenVAO); - glUseProgram(mProgram); - - if (mRuntimeHost) - { - const RuntimeRenderState state = mRuntimeHost->GetRenderState(mFrameWidth, mFrameHeight); - updateGlobalParamsBuffer(state); - } - + glUseProgram(layerProgram.program); + updateGlobalParamsBuffer(state); glDrawArrays(GL_TRIANGLES, 0, 3); - glUseProgram(0); glBindVertexArray(0); glBindTexture(GL_TEXTURE_2D, 0); glActiveTexture(GL_TEXTURE0); - - if (mFastTransferExtensionAvailable) - VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU); } void OpenGLComposite::renderDecodePass() @@ -1056,82 +1198,6 @@ void OpenGLComposite::renderDecodePass() glActiveTexture(GL_TEXTURE0); } -// Compile a fullscreen shader pass from the runtime Slang source into a core-profile -// GLSL program. The renderer owns the fullscreen pass and parameter UBO layout. -bool OpenGLComposite::compileFragmentShader(int errorMessageSize, char* errorMessage) -{ - GLsizei errorBufferSize = 0; - GLint compileResult = GL_FALSE; - GLint linkResult = GL_FALSE; - std::string fragmentShaderSource; - std::string loadError; - const char* vertexSource = kVertexShaderSource; - - if (!mRuntimeHost->BuildActiveFragmentShaderSource(fragmentShaderSource, loadError)) - { - mRuntimeHost->SetCompileStatus(false, loadError); - CopyErrorMessage(loadError, errorMessageSize, errorMessage); - return false; - } - - 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); - if (compileResult == GL_FALSE) - { - glGetShaderInfoLog(newVertexShader, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteShader(newVertexShader); - return false; - } - - GLuint newFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(newFragmentShader, 1, (const GLchar**)&fragmentSource, NULL); - glCompileShader(newFragmentShader); - glGetShaderiv(newFragmentShader, GL_COMPILE_STATUS, &compileResult); - if (compileResult == GL_FALSE) - { - glGetShaderInfoLog(newFragmentShader, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); - return false; - } - - GLuint newProgram = glCreateProgram(); - glAttachShader(newProgram, newVertexShader); - glAttachShader(newProgram, newFragmentShader); - glLinkProgram(newProgram); - glGetProgramiv(newProgram, GL_LINK_STATUS, &linkResult); - if (linkResult == GL_FALSE) - { - glGetProgramInfoLog(newProgram, errorMessageSize, &errorBufferSize, errorMessage); - glDeleteProgram(newProgram); - glDeleteShader(newVertexShader); - glDeleteShader(newFragmentShader); - return false; - } - - destroyShaderProgram(); - - mProgram = newProgram; - mVertexShader = newVertexShader; - mFragmentShader = newFragmentShader; - const RuntimeRenderState state = mRuntimeHost->GetRenderState(mFrameWidth, mFrameHeight); - if (!updateGlobalParamsBuffer(state)) - { - CopyErrorMessage("Failed to allocate the runtime parameter UBO.", errorMessageSize, errorMessage); - destroyShaderProgram(); - return false; - } - - mRuntimeHost->SetCompileStatus(true, "Shader compiled successfully."); - mRuntimeHost->ClearReloadRequest(); - - return true; -} - bool OpenGLComposite::PollRuntimeChanges() { if (!mRuntimeHost) @@ -1154,7 +1220,7 @@ bool OpenGLComposite::PollRuntimeChanges() return true; char compilerErrorMessage[1024] = {}; - if (!compileFragmentShader(sizeof(compilerErrorMessage), compilerErrorMessage)) + if (!compileLayerPrograms(sizeof(compilerErrorMessage), compilerErrorMessage)) { mRuntimeHost->SetCompileStatus(false, compilerErrorMessage); mRuntimeHost->ClearReloadRequest(); @@ -1251,9 +1317,9 @@ std::string OpenGLComposite::GetRuntimeStateJson() const return mRuntimeHost ? mRuntimeHost->BuildStateJson() : "{}"; } -bool OpenGLComposite::SelectShader(const std::string& shaderId, std::string& error) +bool OpenGLComposite::AddLayer(const std::string& shaderId, std::string& error) { - if (!mRuntimeHost->SelectShader(shaderId, error)) + if (!mRuntimeHost->AddLayer(shaderId, error)) return false; ReloadShader(); @@ -1261,40 +1327,93 @@ bool OpenGLComposite::SelectShader(const std::string& shaderId, std::string& err return true; } -bool OpenGLComposite::UpdateParameterJson(const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error) +bool OpenGLComposite::RemoveLayer(const std::string& layerId, std::string& error) +{ + if (!mRuntimeHost->RemoveLayer(layerId, error)) + return false; + + ReloadShader(); + broadcastRuntimeState(); + return true; +} + +bool OpenGLComposite::MoveLayer(const std::string& layerId, int direction, std::string& error) +{ + if (!mRuntimeHost->MoveLayer(layerId, direction, error)) + return false; + + ReloadShader(); + broadcastRuntimeState(); + return true; +} + +bool OpenGLComposite::MoveLayerToIndex(const std::string& layerId, std::size_t targetIndex, std::string& error) +{ + if (!mRuntimeHost->MoveLayerToIndex(layerId, targetIndex, error)) + return false; + + ReloadShader(); + broadcastRuntimeState(); + return true; +} + +bool OpenGLComposite::SetLayerBypass(const std::string& layerId, bool bypassed, std::string& error) +{ + if (!mRuntimeHost->SetLayerBypass(layerId, bypassed, error)) + return false; + + ReloadShader(); + broadcastRuntimeState(); + return true; +} + +bool OpenGLComposite::SetLayerShader(const std::string& layerId, const std::string& shaderId, std::string& error) +{ + if (!mRuntimeHost->SetLayerShader(layerId, shaderId, error)) + return false; + + ReloadShader(); + broadcastRuntimeState(); + return true; +} + +bool OpenGLComposite::UpdateLayerParameterJson(const std::string& layerId, const std::string& parameterId, const std::string& valueJson, std::string& error) { JsonValue parsedValue; if (!ParseJson(valueJson, parsedValue, error)) return false; - if (!mRuntimeHost->UpdateParameter(shaderId, parameterId, parsedValue, error)) + if (!mRuntimeHost->UpdateLayerParameter(layerId, parameterId, parsedValue, error)) return false; broadcastRuntimeState(); return true; } -bool OpenGLComposite::ResetShaderParameters(const std::string& shaderId, std::string& error) +bool OpenGLComposite::ResetLayerParameters(const std::string& layerId, std::string& error) { - if (!mRuntimeHost->ResetParameters(shaderId, error)) + if (!mRuntimeHost->ResetLayerParameters(layerId, error)) return false; broadcastRuntimeState(); return true; } -bool OpenGLComposite::SetBypassEnabled(bool bypassEnabled, std::string& error) +bool OpenGLComposite::SaveStackPreset(const std::string& presetName, std::string& error) { - if (!mRuntimeHost->SetBypass(bypassEnabled, error)) + if (!mRuntimeHost->SaveStackPreset(presetName, error)) return false; + broadcastRuntimeState(); return true; } -bool OpenGLComposite::SetMixAmount(double mixAmount, std::string& error) +bool OpenGLComposite::LoadStackPreset(const std::string& presetName, std::string& error) { - if (!mRuntimeHost->SetMixAmount(mixAmount, error)) + if (!mRuntimeHost->LoadStackPreset(presetName, error)) return false; + + ReloadShader(); broadcastRuntimeState(); return true; } diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h index 3699113..be0c9ec 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h @@ -79,11 +79,16 @@ public: bool Stop(); bool ReloadShader(); std::string GetRuntimeStateJson() const; - bool SelectShader(const std::string& shaderId, std::string& error); - bool UpdateParameterJson(const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error); - bool ResetShaderParameters(const std::string& shaderId, std::string& error); - bool SetBypassEnabled(bool bypassEnabled, std::string& error); - bool SetMixAmount(double mixAmount, std::string& error); + bool AddLayer(const std::string& shaderId, std::string& error); + bool RemoveLayer(const std::string& layerId, std::string& error); + bool MoveLayer(const std::string& layerId, int direction, std::string& error); + bool MoveLayerToIndex(const std::string& layerId, std::size_t targetIndex, std::string& error); + bool SetLayerBypass(const std::string& layerId, bool bypassed, std::string& error); + bool SetLayerShader(const std::string& layerId, const std::string& shaderId, std::string& error); + bool UpdateLayerParameterJson(const std::string& layerId, const std::string& parameterId, const std::string& valueJson, std::string& error); + bool ResetLayerParameters(const std::string& layerId, std::string& error); + bool SaveStackPreset(const std::string& presetName, std::string& error); + bool LoadStackPreset(const std::string& presetName, std::string& error); void resizeGL(WORD width, WORD height); void paintGL(); @@ -118,9 +123,11 @@ private: bool mFastTransferExtensionAvailable; GLuint mCaptureTexture; GLuint mDecodedTexture; + GLuint mLayerTempTexture; GLuint mFBOTexture; GLuint mUnpinnedTextureBuffer; GLuint mDecodeFrameBuf; + GLuint mLayerTempFrameBuf; GLuint mIdFrameBuf; GLuint mIdColorBuf; GLuint mIdDepthBuf; @@ -129,21 +136,31 @@ private: GLuint mDecodeProgram; GLuint mDecodeVertexShader; GLuint mDecodeFragmentShader; - GLuint mProgram; - GLuint mVertexShader; - GLuint mFragmentShader; GLsizeiptr mGlobalParamsUBOSize; int mViewWidth; int mViewHeight; std::unique_ptr mRuntimeHost; std::unique_ptr mControlServer; + struct LayerProgram + { + std::string layerId; + std::string shaderId; + GLuint program = 0; + GLuint vertexShader = 0; + GLuint fragmentShader = 0; + }; + std::vector mLayerPrograms; + bool InitOpenGLState(); - bool compileFragmentShader(int errorMessageSize, char* errorMessage); + bool compileLayerPrograms(int errorMessageSize, char* errorMessage); + bool compileSingleLayerProgram(const RuntimeRenderState& state, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage); bool compileDecodeShader(int errorMessageSize, char* errorMessage); - void destroyShaderProgram(); + void destroyLayerPrograms(); + void destroySingleLayerProgram(LayerProgram& layerProgram); void destroyDecodeShaderProgram(); void renderDecodePass(); + void renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state); void renderEffect(); bool PollRuntimeChanges(); void broadcastRuntimeState(); diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp index f8130c5..8d986af 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp @@ -39,6 +39,13 @@ bool IsFiniteNumber(double value) return std::isfinite(value) != 0; } +std::string ToLowerCopy(std::string text) +{ + std::transform(text.begin(), text.end(), text.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return text; +} + std::vector JsonArrayToNumbers(const JsonValue& value) { std::vector numbers; @@ -107,11 +114,6 @@ std::string SlangTypeForParameter(ShaderParameterType type) return "uniform float"; } -std::string GlslTypeForUniformDeclaration(const std::string& declaration) -{ - return Trim(declaration); -} - bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type) { if (typeName == "float") @@ -154,11 +156,10 @@ RuntimeHost::RuntimeHost() mSmoothedRenderMilliseconds(0.0), mServerPort(8080), mAutoReloadEnabled(true), - mMixAmount(1.0), - mBypass(false), mStartTime(std::chrono::steady_clock::now()), mLastScanTime(std::chrono::steady_clock::time_point::min()), - mFrameCounter(0) + mFrameCounter(0), + mNextLayerId(0) { } @@ -178,8 +179,22 @@ bool RuntimeHost::Initialize(std::string& error) if (!ScanShaderPackages(error)) return false; - if (mActiveShaderId.empty() && !mPackageOrder.empty()) - mActiveShaderId = mPackageOrder.front(); + for (LayerPersistentState& layer : mPersistentState.layers) + { + auto shaderIt = mPackagesById.find(layer.shaderId); + if (shaderIt != mPackagesById.end()) + EnsureLayerDefaultsLocked(layer, shaderIt->second); + } + + if (mPersistentState.layers.empty() && !mPackageOrder.empty()) + { + LayerPersistentState layer; + layer.id = GenerateLayerId(); + layer.shaderId = mPackageOrder.front(); + layer.bypass = false; + EnsureLayerDefaultsLocked(layer, mPackagesById[layer.shaderId]); + mPersistentState.layers.push_back(layer); + } mServerPort = mConfig.serverPort; mAutoReloadEnabled = mConfig.autoReload; @@ -226,7 +241,13 @@ bool RuntimeHost::PollFileChanges(bool& registryChanged, bool& reloadRequested, std::string scanError; std::map previousPackages = mPackagesById; std::vector previousOrder = mPackageOrder; - const std::string previousActive = mActiveShaderId; + std::map> previousLayerShaderTimes; + for (const LayerPersistentState& layer : mPersistentState.layers) + { + auto previous = previousPackages.find(layer.shaderId); + if (previous != previousPackages.end()) + previousLayerShaderTimes[layer.id] = std::make_pair(previous->second.shaderWriteTime, previous->second.manifestWriteTime); + } if (!ScanShaderPackages(scanError)) { @@ -254,20 +275,23 @@ bool RuntimeHost::PollFileChanges(bool& registryChanged, bool& reloadRequested, } } - auto previousActiveIt = previousPackages.find(previousActive); - auto activeIt = mPackagesById.find(mActiveShaderId); - if (previousActiveIt != previousPackages.end() && activeIt != mPackagesById.end()) + for (LayerPersistentState& layer : mPersistentState.layers) { - if (previousActiveIt->second.shaderWriteTime != activeIt->second.shaderWriteTime || - previousActiveIt->second.manifestWriteTime != activeIt->second.manifestWriteTime) + auto active = mPackagesById.find(layer.shaderId); + auto previous = previousLayerShaderTimes.find(layer.id); + if (active == mPackagesById.end()) + continue; + EnsureLayerDefaultsLocked(layer, active->second); + if (previous != previousLayerShaderTimes.end()) { - mReloadRequested = true; + if (previous->second.first != active->second.shaderWriteTime || + previous->second.second != active->second.manifestWriteTime) + { + mReloadRequested = true; + } } } - if (previousActive != mActiveShaderId) - mReloadRequested = true; - reloadRequested = mReloadRequested; return true; } @@ -295,24 +319,115 @@ void RuntimeHost::ClearReloadRequest() mReloadRequested = false; } -bool RuntimeHost::SelectShader(const std::string& shaderId, std::string& error) +bool RuntimeHost::AddLayer(const std::string& shaderId, std::string& error) { std::lock_guard lock(mMutex); - if (mPackagesById.find(shaderId) == mPackagesById.end()) + auto shaderIt = mPackagesById.find(shaderId); + if (shaderIt == mPackagesById.end()) { error = "Unknown shader id: " + shaderId; return false; } - mActiveShaderId = shaderId; - mPersistentState.activeShaderId = shaderId; + LayerPersistentState layer; + layer.id = GenerateLayerId(); + layer.shaderId = shaderId; + layer.bypass = false; + EnsureLayerDefaultsLocked(layer, shaderIt->second); + mPersistentState.layers.push_back(layer); mReloadRequested = true; return SavePersistentState(error); } -bool RuntimeHost::UpdateParameter(const std::string& shaderId, const std::string& parameterId, const JsonValue& newValue, std::string& error) +bool RuntimeHost::RemoveLayer(const std::string& layerId, std::string& error) { std::lock_guard lock(mMutex); + auto it = std::find_if(mPersistentState.layers.begin(), mPersistentState.layers.end(), + [&layerId](const LayerPersistentState& layer) { return layer.id == layerId; }); + if (it == mPersistentState.layers.end()) + { + error = "Unknown layer id: " + layerId; + return false; + } + + mPersistentState.layers.erase(it); + mReloadRequested = true; + return SavePersistentState(error); +} + +bool RuntimeHost::MoveLayer(const std::string& layerId, int direction, std::string& error) +{ + std::lock_guard lock(mMutex); + auto it = std::find_if(mPersistentState.layers.begin(), mPersistentState.layers.end(), + [&layerId](const LayerPersistentState& layer) { return layer.id == layerId; }); + if (it == mPersistentState.layers.end()) + { + error = "Unknown layer id: " + layerId; + return false; + } + + const std::ptrdiff_t index = std::distance(mPersistentState.layers.begin(), it); + const std::ptrdiff_t newIndex = index + direction; + if (newIndex < 0 || newIndex >= static_cast(mPersistentState.layers.size())) + return true; + + std::swap(mPersistentState.layers[index], mPersistentState.layers[newIndex]); + mReloadRequested = true; + return SavePersistentState(error); +} + +bool RuntimeHost::MoveLayerToIndex(const std::string& layerId, std::size_t targetIndex, std::string& error) +{ + std::lock_guard lock(mMutex); + auto it = std::find_if(mPersistentState.layers.begin(), mPersistentState.layers.end(), + [&layerId](const LayerPersistentState& layer) { return layer.id == layerId; }); + if (it == mPersistentState.layers.end()) + { + error = "Unknown layer id: " + layerId; + return false; + } + + if (mPersistentState.layers.empty()) + return true; + + if (targetIndex >= mPersistentState.layers.size()) + targetIndex = mPersistentState.layers.size() - 1; + + const std::size_t sourceIndex = static_cast(std::distance(mPersistentState.layers.begin(), it)); + if (sourceIndex == targetIndex) + return true; + + LayerPersistentState movedLayer = *it; + mPersistentState.layers.erase(mPersistentState.layers.begin() + static_cast(sourceIndex)); + mPersistentState.layers.insert(mPersistentState.layers.begin() + static_cast(targetIndex), movedLayer); + mReloadRequested = true; + return SavePersistentState(error); +} + +bool RuntimeHost::SetLayerBypass(const std::string& layerId, bool bypassed, std::string& error) +{ + std::lock_guard lock(mMutex); + LayerPersistentState* layer = FindLayerById(layerId); + if (!layer) + { + error = "Unknown layer id: " + layerId; + return false; + } + + layer->bypass = bypassed; + mReloadRequested = true; + return SavePersistentState(error); +} + +bool RuntimeHost::SetLayerShader(const std::string& layerId, const std::string& shaderId, std::string& error) +{ + std::lock_guard lock(mMutex); + LayerPersistentState* layer = FindLayerById(layerId); + if (!layer) + { + error = "Unknown layer id: " + layerId; + return false; + } auto shaderIt = mPackagesById.find(shaderId); if (shaderIt == mPackagesById.end()) @@ -321,6 +436,31 @@ bool RuntimeHost::UpdateParameter(const std::string& shaderId, const std::string return false; } + layer->shaderId = shaderId; + layer->parameterValues.clear(); + EnsureLayerDefaultsLocked(*layer, shaderIt->second); + mReloadRequested = true; + return SavePersistentState(error); +} + +bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::string& parameterId, const JsonValue& newValue, std::string& error) +{ + std::lock_guard lock(mMutex); + + LayerPersistentState* layer = FindLayerById(layerId); + if (!layer) + { + error = "Unknown layer id: " + layerId; + return false; + } + + auto shaderIt = mPackagesById.find(layer->shaderId); + if (shaderIt == mPackagesById.end()) + { + error = "Unknown shader id: " + layer->shaderId; + return false; + } + const ShaderPackage& shaderPackage = shaderIt->second; auto parameterIt = std::find_if(shaderPackage.parameters.begin(), shaderPackage.parameters.end(), [¶meterId](const ShaderParameterDefinition& definition) { return definition.id == parameterId; }); @@ -334,49 +474,89 @@ bool RuntimeHost::UpdateParameter(const std::string& shaderId, const std::string if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error)) return false; - mPersistentState.parameterValuesByShader[shaderId][parameterId] = normalized; - + layer->parameterValues[parameterId] = normalized; return SavePersistentState(error); } -bool RuntimeHost::ResetParameters(const std::string& shaderId, std::string& error) +bool RuntimeHost::ResetLayerParameters(const std::string& layerId, std::string& error) { std::lock_guard lock(mMutex); - auto shaderIt = mPackagesById.find(shaderId); + LayerPersistentState* layer = FindLayerById(layerId); + if (!layer) + { + error = "Unknown layer id: " + layerId; + return false; + } + + auto shaderIt = mPackagesById.find(layer->shaderId); if (shaderIt == mPackagesById.end()) { - error = "Unknown shader id: " + shaderId; + error = "Unknown shader id: " + layer->shaderId; return false; } - std::map& shaderValues = mPersistentState.parameterValuesByShader[shaderId]; - shaderValues.clear(); - for (const ShaderParameterDefinition& definition : shaderIt->second.parameters) - shaderValues[definition.id] = DefaultValueForDefinition(definition); - + layer->parameterValues.clear(); + EnsureLayerDefaultsLocked(*layer, shaderIt->second); return SavePersistentState(error); } -bool RuntimeHost::SetBypass(bool bypassEnabled, std::string& error) +bool RuntimeHost::SaveStackPreset(const std::string& presetName, std::string& error) const { std::lock_guard lock(mMutex); - mBypass = bypassEnabled; - mPersistentState.bypass = bypassEnabled; - return SavePersistentState(error); -} - -bool RuntimeHost::SetMixAmount(double mixAmount, std::string& error) -{ - std::lock_guard lock(mMutex); - if (!IsFiniteNumber(mixAmount)) + const std::string safeStem = MakeSafePresetFileStem(presetName); + if (safeStem.empty()) { - error = "Mix amount must be a finite number."; + error = "Preset name must include at least one letter or number."; return false; } - mMixAmount = std::clamp(mixAmount, 0.0, 1.0); - mPersistentState.mixAmount = mMixAmount; + JsonValue root = JsonValue::MakeObject(); + root.set("version", JsonValue(1.0)); + root.set("name", JsonValue(Trim(presetName))); + root.set("layers", SerializeLayerStackLocked()); + + return WriteTextFile(mPresetRoot / (safeStem + ".json"), SerializeJson(root, true), error); +} + +bool RuntimeHost::LoadStackPreset(const std::string& presetName, std::string& error) +{ + std::lock_guard lock(mMutex); + const std::string safeStem = MakeSafePresetFileStem(presetName); + if (safeStem.empty()) + { + error = "Preset name must include at least one letter or number."; + return false; + } + + const std::filesystem::path presetPath = mPresetRoot / (safeStem + ".json"); + std::string presetText = ReadTextFile(presetPath, error); + if (presetText.empty()) + return false; + + JsonValue root; + if (!ParseJson(presetText, root, error)) + return false; + + const JsonValue* layersValue = root.find("layers"); + if (!layersValue || !layersValue->isArray()) + { + error = "Preset file is missing a valid 'layers' array."; + return false; + } + + std::vector nextLayers; + if (!DeserializeLayerStackLocked(*layersValue, nextLayers, error)) + return false; + + if (nextLayers.empty()) + { + error = "Preset does not contain any valid layers."; + return false; + } + + mPersistentState.layers = nextLayers; + mReloadRequested = true; return SavePersistentState(error); } @@ -413,17 +593,24 @@ void RuntimeHost::AdvanceFrame() ++mFrameCounter; } -bool RuntimeHost::BuildActiveFragmentShaderSource(std::string& fragmentShaderSource, std::string& error) +bool RuntimeHost::BuildLayerFragmentShaderSource(const std::string& layerId, std::string& fragmentShaderSource, std::string& error) { try { ShaderPackage shaderPackage; { std::lock_guard lock(mMutex); - auto it = mPackagesById.find(mActiveShaderId); + const LayerPersistentState* layer = FindLayerById(layerId); + if (!layer) + { + error = "Unknown layer id: " + layerId; + return false; + } + + auto it = mPackagesById.find(layer->shaderId); if (it == mPackagesById.end()) { - error = "No active shader is selected."; + error = "Unknown shader id: " + layer->shaderId; return false; } shaderPackage = it->second; @@ -450,49 +637,53 @@ bool RuntimeHost::BuildActiveFragmentShaderSource(std::string& fragmentShaderSou } catch (const std::exception& exception) { - error = std::string("RuntimeHost::BuildActiveFragmentShaderSource exception: ") + exception.what(); + error = std::string("RuntimeHost::BuildLayerFragmentShaderSource exception: ") + exception.what(); return false; } catch (...) { - error = "RuntimeHost::BuildActiveFragmentShaderSource threw a non-standard exception."; + error = "RuntimeHost::BuildLayerFragmentShaderSource threw a non-standard exception."; return false; } } -RuntimeRenderState RuntimeHost::GetRenderState(unsigned outputWidth, unsigned outputHeight) const +std::vector RuntimeHost::GetLayerRenderStates(unsigned outputWidth, unsigned outputHeight) const { std::lock_guard lock(mMutex); - RuntimeRenderState state; - state.activeShaderId = mActiveShaderId; - state.timeSeconds = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); - state.frameCount = static_cast(mFrameCounter); - state.mixAmount = mMixAmount; - state.bypass = mBypass ? 1.0 : 0.0; - state.inputWidth = mSignalWidth; - state.inputHeight = mSignalHeight; - state.outputWidth = outputWidth; - state.outputHeight = outputHeight; + std::vector states; - auto shaderIt = mPackagesById.find(mActiveShaderId); - if (shaderIt != mPackagesById.end()) + for (const LayerPersistentState& layer : mPersistentState.layers) { + auto shaderIt = mPackagesById.find(layer.shaderId); + if (shaderIt == mPackagesById.end()) + continue; + + RuntimeRenderState state; + state.layerId = layer.id; + state.shaderId = layer.shaderId; + state.timeSeconds = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); + state.frameCount = static_cast(mFrameCounter); + state.mixAmount = 1.0; + state.bypass = layer.bypass ? 1.0 : 0.0; + state.inputWidth = mSignalWidth; + state.inputHeight = mSignalHeight; + state.outputWidth = outputWidth; + state.outputHeight = outputHeight; state.parameterDefinitions = shaderIt->second.parameters; - auto persistedIt = mPersistentState.parameterValuesByShader.find(mActiveShaderId); + for (const ShaderParameterDefinition& definition : shaderIt->second.parameters) { ShaderParameterValue value = DefaultValueForDefinition(definition); - if (persistedIt != mPersistentState.parameterValuesByShader.end()) - { - auto valueIt = persistedIt->second.find(definition.id); - if (valueIt != persistedIt->second.end()) - value = valueIt->second; - } + auto valueIt = layer.parameterValues.find(definition.id); + if (valueIt != layer.parameterValues.end()) + value = valueIt->second; state.parameterValues[definition.id] = value; } + + states.push_back(state); } - return state; + return states; } std::string RuntimeHost::BuildStateJson() const @@ -543,62 +734,102 @@ bool RuntimeHost::LoadPersistentState(std::string& error) if (!ParseJson(stateText, root, error)) return false; - if (const JsonValue* activeShaderValue = root.find("activeShaderId")) - mPersistentState.activeShaderId = activeShaderValue->asString(); - if (const JsonValue* mixAmountValue = root.find("mixAmount")) - mPersistentState.mixAmount = mixAmountValue->asNumber(1.0); - if (const JsonValue* bypassValue = root.find("bypass")) - mPersistentState.bypass = bypassValue->asBoolean(false); - - if (const JsonValue* valuesByShader = root.find("parameterValuesByShader")) + if (const JsonValue* layersValue = root.find("layers")) { - for (const auto& shaderItem : valuesByShader->asObject()) + for (const JsonValue& layerValue : layersValue->asArray()) { - std::map& shaderValues = mPersistentState.parameterValuesByShader[shaderItem.first]; - for (const auto& parameterItem : shaderItem.second.asObject()) + if (!layerValue.isObject()) + continue; + LayerPersistentState layer; + if (const JsonValue* idValue = layerValue.find("id")) + layer.id = idValue->asString(); + if (const JsonValue* shaderIdValue = layerValue.find("shaderId")) + layer.shaderId = shaderIdValue->asString(); + if (const JsonValue* bypassValue = layerValue.find("bypass")) + layer.bypass = bypassValue->asBoolean(false); + else if (const JsonValue* enabledValue = layerValue.find("enabled")) + layer.bypass = !enabledValue->asBoolean(true); + + if (const JsonValue* parameterValues = layerValue.find("parameterValues")) { - ShaderParameterValue value; - const JsonValue& jsonValue = parameterItem.second; - if (jsonValue.isBoolean()) + for (const auto& parameterItem : parameterValues->asObject()) { - value.booleanValue = jsonValue.asBoolean(); + ShaderParameterValue value; + const JsonValue& jsonValue = parameterItem.second; + if (jsonValue.isBoolean()) + value.booleanValue = jsonValue.asBoolean(); + else if (jsonValue.isString()) + value.enumValue = jsonValue.asString(); + else if (jsonValue.isNumber()) + value.numberValues.push_back(jsonValue.asNumber()); + else if (jsonValue.isArray()) + value.numberValues = JsonArrayToNumbers(jsonValue); + layer.parameterValues[parameterItem.first] = value; } - else if (jsonValue.isString()) - { - value.enumValue = jsonValue.asString(); - } - else if (jsonValue.isNumber()) - { - value.numberValues.push_back(jsonValue.asNumber()); - } - else if (jsonValue.isArray()) - { - value.numberValues = JsonArrayToNumbers(jsonValue); - } - shaderValues[parameterItem.first] = value; } + + if (!layer.shaderId.empty()) + mPersistentState.layers.push_back(layer); + } + } + else + { + // Migrate from the older single-shader state shape. + std::string activeShaderId; + if (const JsonValue* activeShaderValue = root.find("activeShaderId")) + activeShaderId = activeShaderValue->asString(); + + if (!activeShaderId.empty()) + { + LayerPersistentState layer; + layer.id = GenerateLayerId(); + layer.shaderId = activeShaderId; + layer.bypass = false; + + if (const JsonValue* valuesByShader = root.find("parameterValuesByShader")) + { + const JsonValue* shaderValues = valuesByShader->find(activeShaderId); + if (shaderValues) + { + for (const auto& parameterItem : shaderValues->asObject()) + { + ShaderParameterValue value; + const JsonValue& jsonValue = parameterItem.second; + if (jsonValue.isBoolean()) + value.booleanValue = jsonValue.asBoolean(); + else if (jsonValue.isString()) + value.enumValue = jsonValue.asString(); + else if (jsonValue.isNumber()) + value.numberValues.push_back(jsonValue.asNumber()); + else if (jsonValue.isArray()) + value.numberValues = JsonArrayToNumbers(jsonValue); + layer.parameterValues[parameterItem.first] = value; + } + } + } + + mPersistentState.layers.push_back(layer); } } - mActiveShaderId = mPersistentState.activeShaderId; - mMixAmount = std::clamp(mPersistentState.mixAmount, 0.0, 1.0); - mBypass = mPersistentState.bypass; return true; } bool RuntimeHost::SavePersistentState(std::string& error) const { JsonValue root = JsonValue::MakeObject(); - root.set("activeShaderId", JsonValue(mActiveShaderId)); - root.set("mixAmount", JsonValue(mMixAmount)); - root.set("bypass", JsonValue(mBypass)); - JsonValue valuesByShader = JsonValue::MakeObject(); - for (const auto& shaderItem : mPersistentState.parameterValuesByShader) + JsonValue layers = JsonValue::MakeArray(); + for (const LayerPersistentState& layer : mPersistentState.layers) { - JsonValue shaderValues = JsonValue::MakeObject(); - auto packageIt = mPackagesById.find(shaderItem.first); - for (const auto& parameterItem : shaderItem.second) + JsonValue layerValue = JsonValue::MakeObject(); + layerValue.set("id", JsonValue(layer.id)); + layerValue.set("shaderId", JsonValue(layer.shaderId)); + layerValue.set("bypass", JsonValue(layer.bypass)); + + JsonValue parameterValues = JsonValue::MakeObject(); + auto packageIt = mPackagesById.find(layer.shaderId); + for (const auto& parameterItem : layer.parameterValues) { const ShaderParameterDefinition* definition = nullptr; if (packageIt != mPackagesById.end()) @@ -614,11 +845,13 @@ bool RuntimeHost::SavePersistentState(std::string& error) const } if (definition) - shaderValues.set(parameterItem.first, SerializeParameterValue(*definition, parameterItem.second)); + parameterValues.set(parameterItem.first, SerializeParameterValue(*definition, parameterItem.second)); } - valuesByShader.set(shaderItem.first, shaderValues); + + layerValue.set("parameterValues", parameterValues); + layers.pushBack(layerValue); } - root.set("parameterValuesByShader", valuesByShader); + root.set("layers", layers); return WriteTextFile(mRuntimeStatePath, SerializeJson(root, true), error); } @@ -653,7 +886,6 @@ bool RuntimeHost::ScanShaderPackages(std::string& error) return false; } - EnsureParameterDefaultsLocked(shaderPackage); packageOrder.push_back(shaderPackage.id); packagesById[shaderPackage.id] = shaderPackage; } @@ -662,16 +894,12 @@ bool RuntimeHost::ScanShaderPackages(std::string& error) mPackagesById.swap(packagesById); mPackageOrder.swap(packageOrder); - if (!mActiveShaderId.empty() && mPackagesById.find(mActiveShaderId) == mPackagesById.end()) + for (auto it = mPersistentState.layers.begin(); it != mPersistentState.layers.end();) { - mActiveShaderId.clear(); - mPersistentState.activeShaderId.clear(); - } - - if (mActiveShaderId.empty() && !mPackageOrder.empty()) - { - mActiveShaderId = mPackageOrder.front(); - mPersistentState.activeShaderId = mActiveShaderId; + if (mPackagesById.find(it->shaderId) == mPackagesById.end()) + it = mPersistentState.layers.erase(it); + else + ++it; } return true; @@ -739,21 +967,13 @@ bool RuntimeHost::ParseShaderManifest(const std::filesystem::path& manifestPath, if (const JsonValue* defaultValue = parameterJson.find("default")) { if (definition.type == ShaderParameterType::Boolean) - { definition.defaultBoolean = defaultValue->asBoolean(false); - } else if (definition.type == ShaderParameterType::Enum) - { definition.defaultEnumValue = defaultValue->asString(); - } else if (defaultValue->isNumber()) - { definition.defaultNumbers.push_back(defaultValue->asNumber()); - } else if (defaultValue->isArray()) - { definition.defaultNumbers = JsonArrayToNumbers(*defaultValue); - } } if (const JsonValue* minValue = parameterJson.find("min")) @@ -934,13 +1154,12 @@ ShaderParameterValue RuntimeHost::DefaultValueForDefinition(const ShaderParamete return value; } -void RuntimeHost::EnsureParameterDefaultsLocked(ShaderPackage& shaderPackage) +void RuntimeHost::EnsureLayerDefaultsLocked(LayerPersistentState& layerState, const ShaderPackage& shaderPackage) const { for (const ShaderParameterDefinition& definition : shaderPackage.parameters) { - auto& shaderValues = mPersistentState.parameterValuesByShader[shaderPackage.id]; - if (shaderValues.find(definition.id) == shaderValues.end()) - shaderValues[definition.id] = DefaultValueForDefinition(definition); + if (layerState.parameterValues.find(definition.id) == layerState.parameterValues.end()) + layerState.parameterValues[definition.id] = DefaultValueForDefinition(definition); } } @@ -1127,10 +1346,12 @@ bool RuntimeHost::ResolvePaths(std::string& error) return false; } - mUiRoot = mRepoRoot / "ui"; + const std::filesystem::path builtUiRoot = mRepoRoot / "ui" / "dist"; + mUiRoot = std::filesystem::exists(builtUiRoot) ? builtUiRoot : (mRepoRoot / "ui"); mConfigPath = mRepoRoot / "config" / "runtime-host.json"; mShaderRoot = mRepoRoot / mConfig.shaderLibrary; mRuntimeRoot = mRepoRoot / "runtime"; + mPresetRoot = mRuntimeRoot / "stack_presets"; mRuntimeStatePath = mRuntimeRoot / "runtime_state.json"; mWrapperPath = mRuntimeRoot / "shader_cache" / "active_shader_wrapper.slang"; mGeneratedGlslPath = mRuntimeRoot / "shader_cache" / "active_shader.raw.frag"; @@ -1138,6 +1359,7 @@ bool RuntimeHost::ResolvePaths(std::string& error) std::error_code fsError; std::filesystem::create_directories(mRuntimeRoot / "shader_cache", fsError); + std::filesystem::create_directories(mPresetRoot, fsError); return true; } @@ -1153,11 +1375,9 @@ JsonValue RuntimeHost::BuildStateValue() const root.set("app", app); JsonValue runtime = JsonValue::MakeObject(); - runtime.set("activeShaderId", JsonValue(mActiveShaderId)); + runtime.set("layerCount", JsonValue(static_cast(mPersistentState.layers.size()))); runtime.set("compileSucceeded", JsonValue(mCompileSucceeded)); runtime.set("compileMessage", JsonValue(mCompileMessage)); - runtime.set("mixAmount", JsonValue(mMixAmount)); - runtime.set("bypass", JsonValue(mBypass)); root.set("runtime", runtime); JsonValue video = JsonValue::MakeObject(); @@ -1174,23 +1394,49 @@ JsonValue RuntimeHost::BuildStateValue() const performance.set("budgetUsedPercent", JsonValue(mFrameBudgetMilliseconds > 0.0 ? (mSmoothedRenderMilliseconds / mFrameBudgetMilliseconds) * 100.0 : 0.0)); root.set("performance", performance); - JsonValue shaders = JsonValue::MakeArray(); + JsonValue shaderLibrary = JsonValue::MakeArray(); for (const std::string& shaderId : mPackageOrder) { auto shaderIt = mPackagesById.find(shaderId); if (shaderIt == mPackagesById.end()) continue; - const ShaderPackage& shaderPackage = shaderIt->second; JsonValue shader = JsonValue::MakeObject(); - shader.set("id", JsonValue(shaderPackage.id)); - shader.set("name", JsonValue(shaderPackage.displayName)); - shader.set("description", JsonValue(shaderPackage.description)); - shader.set("category", JsonValue(shaderPackage.category)); + shader.set("id", JsonValue(shaderIt->second.id)); + shader.set("name", JsonValue(shaderIt->second.displayName)); + shader.set("description", JsonValue(shaderIt->second.description)); + shader.set("category", JsonValue(shaderIt->second.category)); + shaderLibrary.pushBack(shader); + } + root.set("shaders", shaderLibrary); + + JsonValue stackPresets = JsonValue::MakeArray(); + for (const std::string& presetName : GetStackPresetNamesLocked()) + stackPresets.pushBack(JsonValue(presetName)); + root.set("stackPresets", stackPresets); + + root.set("layers", SerializeLayerStackLocked()); + + return root; +} + +JsonValue RuntimeHost::SerializeLayerStackLocked() const +{ + JsonValue layers = JsonValue::MakeArray(); + for (const LayerPersistentState& layer : mPersistentState.layers) + { + auto shaderIt = mPackagesById.find(layer.shaderId); + if (shaderIt == mPackagesById.end()) + continue; + + JsonValue layerValue = JsonValue::MakeObject(); + layerValue.set("id", JsonValue(layer.id)); + layerValue.set("shaderId", JsonValue(layer.shaderId)); + layerValue.set("shaderName", JsonValue(shaderIt->second.displayName)); + layerValue.set("bypass", JsonValue(layer.bypass)); JsonValue parameters = JsonValue::MakeArray(); - auto persistedIt = mPersistentState.parameterValuesByShader.find(shaderPackage.id); - for (const ShaderParameterDefinition& definition : shaderPackage.parameters) + for (const ShaderParameterDefinition& definition : shaderIt->second.parameters) { JsonValue parameter = JsonValue::MakeObject(); parameter.set("id", JsonValue(definition.id)); @@ -1218,7 +1464,6 @@ JsonValue RuntimeHost::BuildStateValue() const stepValue.pushBack(JsonValue(number)); parameter.set("step", stepValue); } - if (definition.type == ShaderParameterType::Enum) { JsonValue options = JsonValue::MakeArray(); @@ -1233,22 +1478,119 @@ JsonValue RuntimeHost::BuildStateValue() const } ShaderParameterValue value = DefaultValueForDefinition(definition); - if (persistedIt != mPersistentState.parameterValuesByShader.end()) - { - auto valueIt = persistedIt->second.find(definition.id); - if (valueIt != persistedIt->second.end()) - value = valueIt->second; - } + auto valueIt = layer.parameterValues.find(definition.id); + if (valueIt != layer.parameterValues.end()) + value = valueIt->second; parameter.set("value", SerializeParameterValue(definition, value)); parameters.pushBack(parameter); } - shader.set("parameters", parameters); - shaders.pushBack(shader); + layerValue.set("parameters", parameters); + layers.pushBack(layerValue); } - root.set("shaders", shaders); + return layers; +} - return root; +bool RuntimeHost::DeserializeLayerStackLocked(const JsonValue& layersValue, std::vector& layers, std::string& error) +{ + for (const JsonValue& layerValue : layersValue.asArray()) + { + if (!layerValue.isObject()) + continue; + + const JsonValue* shaderIdValue = layerValue.find("shaderId"); + if (!shaderIdValue) + continue; + + const std::string shaderId = shaderIdValue->asString(); + auto shaderIt = mPackagesById.find(shaderId); + if (shaderIt == mPackagesById.end()) + { + error = "Preset references unknown shader id: " + shaderId; + return false; + } + + LayerPersistentState layer; + layer.id = GenerateLayerId(); + layer.shaderId = shaderId; + if (const JsonValue* bypassValue = layerValue.find("bypass")) + layer.bypass = bypassValue->asBoolean(false); + + if (const JsonValue* parametersValue = layerValue.find("parameters")) + { + for (const JsonValue& parameterValue : parametersValue->asArray()) + { + if (!parameterValue.isObject()) + continue; + + const JsonValue* parameterIdValue = parameterValue.find("id"); + const JsonValue* valueValue = parameterValue.find("value"); + if (!parameterIdValue || !valueValue) + continue; + + const std::string parameterId = parameterIdValue->asString(); + auto definitionIt = std::find_if(shaderIt->second.parameters.begin(), shaderIt->second.parameters.end(), + [¶meterId](const ShaderParameterDefinition& definition) { return definition.id == parameterId; }); + if (definitionIt == shaderIt->second.parameters.end()) + continue; + + ShaderParameterValue normalizedValue; + if (!NormalizeAndValidateValue(*definitionIt, *valueValue, normalizedValue, error)) + return false; + + layer.parameterValues[parameterId] = normalizedValue; + } + } + + EnsureLayerDefaultsLocked(layer, shaderIt->second); + layers.push_back(layer); + } + + return true; +} + +std::vector RuntimeHost::GetStackPresetNamesLocked() const +{ + std::vector presetNames; + std::error_code fsError; + if (!std::filesystem::exists(mPresetRoot, fsError)) + return presetNames; + + for (const auto& entry : std::filesystem::directory_iterator(mPresetRoot, fsError)) + { + if (!entry.is_regular_file()) + continue; + if (ToLowerCopy(entry.path().extension().string()) != ".json") + continue; + presetNames.push_back(entry.path().stem().string()); + } + + std::sort(presetNames.begin(), presetNames.end()); + return presetNames; +} + +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; } JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) const @@ -1272,3 +1614,28 @@ JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition& } return JsonValue(); } + +RuntimeHost::LayerPersistentState* RuntimeHost::FindLayerById(const std::string& layerId) +{ + auto it = std::find_if(mPersistentState.layers.begin(), mPersistentState.layers.end(), + [&layerId](const LayerPersistentState& layer) { return layer.id == layerId; }); + return it == mPersistentState.layers.end() ? nullptr : &*it; +} + +const RuntimeHost::LayerPersistentState* RuntimeHost::FindLayerById(const std::string& layerId) const +{ + auto it = std::find_if(mPersistentState.layers.begin(), mPersistentState.layers.end(), + [&layerId](const LayerPersistentState& layer) { return layer.id == layerId; }); + return it == mPersistentState.layers.end() ? nullptr : &*it; +} + +std::string RuntimeHost::GenerateLayerId() +{ + while (true) + { + ++mNextLayerId; + const std::string candidate = "layer-" + std::to_string(mNextLayerId); + if (!FindLayerById(candidate)) + return candidate; + } +} diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h index 3d329c2..090d7ca 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h @@ -62,7 +62,8 @@ struct ShaderPackage struct RuntimeRenderState { - std::string activeShaderId; + std::string layerId; + std::string shaderId; std::vector parameterDefinitions; std::map parameterValues; double timeSeconds = 0.0; @@ -86,19 +87,24 @@ public: bool ManualReloadRequested(); void ClearReloadRequest(); - bool SelectShader(const std::string& shaderId, std::string& error); - bool UpdateParameter(const std::string& shaderId, const std::string& parameterId, const JsonValue& newValue, std::string& error); - bool ResetParameters(const std::string& shaderId, std::string& error); - bool SetBypass(bool bypassEnabled, std::string& error); - bool SetMixAmount(double mixAmount, std::string& error); + bool AddLayer(const std::string& shaderId, std::string& error); + bool RemoveLayer(const std::string& layerId, std::string& error); + bool MoveLayer(const std::string& layerId, int direction, std::string& error); + bool MoveLayerToIndex(const std::string& layerId, std::size_t targetIndex, std::string& error); + bool SetLayerBypass(const std::string& layerId, bool bypassed, std::string& error); + bool SetLayerShader(const std::string& layerId, const std::string& shaderId, std::string& error); + bool UpdateLayerParameter(const std::string& layerId, const std::string& parameterId, const JsonValue& newValue, std::string& error); + bool ResetLayerParameters(const std::string& layerId, std::string& error); + bool SaveStackPreset(const std::string& presetName, std::string& error) const; + bool LoadStackPreset(const std::string& presetName, std::string& error); void SetCompileStatus(bool succeeded, const std::string& message); void SetSignalStatus(bool hasSignal, unsigned width, unsigned height, const std::string& modeName); void SetPerformanceStats(double frameBudgetMilliseconds, double renderMilliseconds); void AdvanceFrame(); - bool BuildActiveFragmentShaderSource(std::string& fragmentShaderSource, std::string& error); - RuntimeRenderState GetRenderState(unsigned outputWidth, unsigned outputHeight) const; + bool BuildLayerFragmentShaderSource(const std::string& layerId, std::string& fragmentShaderSource, std::string& error); + std::vector GetLayerRenderStates(unsigned outputWidth, unsigned outputHeight) const; std::string BuildStateJson() const; const std::filesystem::path& GetRepoRoot() const { return mRepoRoot; } @@ -116,12 +122,17 @@ private: bool autoReload = true; }; + struct LayerPersistentState + { + std::string id; + std::string shaderId; + bool bypass = false; + std::map parameterValues; + }; + struct PersistentState { - std::string activeShaderId; - double mixAmount = 1.0; - bool bypass = false; - std::map> parameterValuesByShader; + std::vector layers; }; bool LoadConfig(std::string& error); @@ -131,7 +142,7 @@ private: bool ParseShaderManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const; bool NormalizeAndValidateValue(const ShaderParameterDefinition& definition, const JsonValue& value, ShaderParameterValue& normalizedValue, std::string& error) const; ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& definition) const; - void EnsureParameterDefaultsLocked(ShaderPackage& shaderPackage); + void EnsureLayerDefaultsLocked(LayerPersistentState& layerState, const ShaderPackage& shaderPackage) const; std::string BuildWrapperSlangSource(const ShaderPackage& shaderPackage) const; bool FindSlangCompiler(std::filesystem::path& compilerPath, std::string& error) const; bool RunSlangCompiler(const std::filesystem::path& wrapperPath, const std::filesystem::path& outputPath, std::string& error) const; @@ -140,7 +151,14 @@ private: bool WriteTextFile(const std::filesystem::path& path, const std::string& contents, std::string& error) const; bool ResolvePaths(std::string& error); JsonValue BuildStateValue() const; + JsonValue SerializeLayerStackLocked() const; + bool DeserializeLayerStackLocked(const JsonValue& layersValue, std::vector& layers, std::string& error); + std::vector GetStackPresetNamesLocked() const; + std::string MakeSafePresetFileStem(const std::string& presetName) const; JsonValue SerializeParameterValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) const; + LayerPersistentState* FindLayerById(const std::string& layerId); + const LayerPersistentState* FindLayerById(const std::string& layerId) const; + std::string GenerateLayerId(); private: mutable std::mutex mMutex; @@ -150,6 +168,7 @@ private: std::filesystem::path mUiRoot; std::filesystem::path mShaderRoot; std::filesystem::path mRuntimeRoot; + std::filesystem::path mPresetRoot; std::filesystem::path mRuntimeStatePath; std::filesystem::path mConfigPath; std::filesystem::path mWrapperPath; @@ -157,7 +176,6 @@ private: std::filesystem::path mPatchedGlslPath; std::map mPackagesById; std::vector mPackageOrder; - std::string mActiveShaderId; bool mReloadRequested; bool mCompileSucceeded; std::string mCompileMessage; @@ -170,9 +188,8 @@ private: double mSmoothedRenderMilliseconds; unsigned short mServerPort; bool mAutoReloadEnabled; - double mMixAmount; - bool mBypass; std::chrono::steady_clock::time_point mStartTime; std::chrono::steady_clock::time_point mLastScanTime; uint64_t mFrameCounter; + uint64_t mNextLayerId; }; diff --git a/shaders/vhs/shader.json b/shaders/vhs/shader.json index ad252d2..4049603 100644 --- a/shaders/vhs/shader.json +++ b/shaders/vhs/shader.json @@ -67,6 +67,33 @@ "min": 0.0, "max": 0.5, "step": 0.01 + }, + { + "id": "bloomAmount", + "label": "Bloom", + "type": "float", + "default": 0.18, + "min": 0.0, + "max": 0.6, + "step": 0.01 + }, + { + "id": "fadeAmount", + "label": "Fade", + "type": "float", + "default": 0.22, + "min": 0.0, + "max": 0.75, + "step": 0.01 + }, + { + "id": "noiseAmount", + "label": "Noise", + "type": "float", + "default": 0.055, + "min": 0.0, + "max": 0.2, + "step": 0.005 } ] } diff --git a/shaders/vhs/shader.slang b/shaders/vhs/shader.slang index f6d1925..785f807 100644 --- a/shaders/vhs/shader.slang +++ b/shaders/vhs/shader.slang @@ -39,6 +39,38 @@ float3 yiq2rgb(float3 c) ); } +float noiseHash(float2 p) +{ + return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123); +} + +float3 chromaSpeckle(float2 uv, float framecount) +{ + float2 coarseUv = floor(uv); + float r = noiseHash(coarseUv + float2(framecount * 19.0, framecount * 11.0)); + float g = noiseHash(coarseUv + float2(framecount * 23.0 + 17.0, framecount * 7.0 + 31.0)); + float b = noiseHash(coarseUv + float2(framecount * 13.0 + 47.0, framecount * 29.0 + 9.0)); + return float3(r, g, b) - 0.5; +} + +float3 softBloom(float2 uv, float2 outputResolution, float radius) +{ + float2 pixel = 1.0 / max(outputResolution, float2(1.0, 1.0)); + float2 dx = float2(pixel.x * radius, 0.0); + float2 dy = float2(0.0, pixel.y * radius); + + float3 sum = sampleVideo(frac(uv)).rgb * 0.28; + sum += sampleVideo(frac(uv + dx)).rgb * 0.14; + sum += sampleVideo(frac(uv - dx)).rgb * 0.14; + sum += sampleVideo(frac(uv + dy)).rgb * 0.14; + sum += sampleVideo(frac(uv - dy)).rgb * 0.14; + sum += sampleVideo(frac(uv + dx + dy)).rgb * 0.075; + sum += sampleVideo(frac(uv + dx - dy)).rgb * 0.075; + sum += sampleVideo(frac(uv - dx + dy)).rgb * 0.075; + sum += sampleVideo(frac(uv - dx - dy)).rgb * 0.075; + return sum; +} + float3 blurVhs(float2 uv, float d, int sampleCount) { float3 sum = float3(0.0, 0.0, 0.0); @@ -107,6 +139,26 @@ float4 shadeVideo(ShaderContext context) float halationMask = smoothstep(0.45, 1.0, halationLuma) * halationAmount; color += halationSource * float3(1.0, 0.38, 0.24) * halationMask * 0.35; + float3 bloomSource = softBloom(context.uv, context.outputResolution, 2.0 + smear * 2.5); + float bloomLuma = dot(bloomSource, float3(0.299, 0.587, 0.114)); + float bloomMask = smoothstep(0.32, 1.0, bloomLuma) * bloomAmount; + color = lerp(color, bloomSource, bloomAmount * 0.18); + color += bloomSource * float3(1.0, 0.96, 0.92) * bloomMask * 0.24; + + float2 noiseUv = context.uv * context.outputResolution * float2(0.55, 1.1); + float3 speckle = chromaSpeckle(noiseUv, framecount); + float luma = dot(color, float3(0.299, 0.587, 0.114)); + float noiseMask = lerp(0.65, 1.0, 1.0 - saturate(luma)); + float3 chromaNoise = float3(speckle.x * 1.1, speckle.y * 0.45, speckle.z * 1.2); + color += chromaNoise * noiseAmount * noiseMask; + color.rg = lerp(color.rg, float2(color.r, color.g) + speckle.xy * noiseAmount * 0.18, 0.35); + color.b = lerp(color.b, color.b + speckle.z * noiseAmount * 0.22, 0.45); + + float3 grayscale = float3(luma, luma, luma); + color = lerp(color, grayscale, fadeAmount * 0.18); + color = color * (1.0 - fadeAmount * 0.08) + float3(0.055, 0.055, 0.065) * fadeAmount; + color = lerp(color, softBloom(context.uv, context.outputResolution, 1.0 + smear), fadeAmount * 0.12); + float vignetteBase = context.uv.x * (1.0 - context.uv.x) * context.uv.y * (1.0 - context.uv.y); float vignette = saturate(pow(vignetteBase * 16.0, 0.22)); color *= lerp(1.0 - vignetteAmount, 1.0, vignette); diff --git a/ui/app.js b/ui/app.js deleted file mode 100644 index d8338fb..0000000 --- a/ui/app.js +++ /dev/null @@ -1,232 +0,0 @@ -const shaderSelect = document.getElementById("shader-select"); -const mixSlider = document.getElementById("mix-slider"); -const bypassToggle = document.getElementById("bypass-toggle"); -const reloadButton = document.getElementById("reload-button"); -const resetParametersButton = document.getElementById("reset-parameters-button"); -const runtimeStatus = document.getElementById("runtime-status"); -const videoStatus = document.getElementById("video-status"); -const compileStatus = document.getElementById("compile-status"); -const parameterForm = document.getElementById("parameter-form"); - -let appState = null; -let websocket = null; - -function createKv(target, values) { - target.innerHTML = ""; - values.forEach(([key, value]) => { - const dt = document.createElement("dt"); - dt.textContent = key; - const dd = document.createElement("dd"); - dd.textContent = value; - target.append(dt, dd); - }); -} - -function postJson(path, payload) { - return fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -function renderParameters(shader) { - parameterForm.innerHTML = ""; - if (!shader) { - return; - } - - shader.parameters.forEach((parameter) => { - const section = document.createElement("section"); - section.className = "parameter"; - - const label = document.createElement("label"); - label.textContent = parameter.label; - section.appendChild(label); - - const valueLabel = document.createElement("div"); - valueLabel.className = "parameter__value"; - - const sendValue = (value) => { - postJson("/api/update-parameter", { - shaderId: shader.id, - parameterId: parameter.id, - value, - }); - }; - - if (parameter.type === "float") { - const range = document.createElement("input"); - range.type = "range"; - range.min = parameter.min?.[0] ?? 0; - range.max = parameter.max?.[0] ?? 1; - range.step = parameter.step?.[0] ?? 0.01; - range.value = parameter.value; - const number = document.createElement("input"); - number.type = "number"; - number.min = range.min; - number.max = range.max; - number.step = range.step; - number.value = parameter.value; - const pair = document.createElement("div"); - pair.className = "parameter__pair"; - pair.append(range, number); - section.append(pair, valueLabel); - const update = (value) => { - valueLabel.textContent = Number(value).toFixed(3); - range.value = value; - number.value = value; - }; - update(parameter.value); - range.addEventListener("input", () => update(range.value)); - range.addEventListener("change", () => sendValue(Number(range.value))); - number.addEventListener("change", () => { - update(number.value); - sendValue(Number(number.value)); - }); - } else if (parameter.type === "vec2" || parameter.type === "color") { - const pair = document.createElement("div"); - pair.className = "parameter__pair"; - const values = parameter.value.slice(); - values.forEach((component, index) => { - const input = document.createElement("input"); - input.type = "number"; - input.step = parameter.step?.[index] ?? 0.01; - input.min = parameter.min?.[index] ?? ""; - input.max = parameter.max?.[index] ?? ""; - input.value = component; - input.addEventListener("change", () => { - values[index] = Number(input.value); - valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", "); - sendValue(values); - }); - pair.appendChild(input); - }); - valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", "); - section.append(pair, valueLabel); - } else if (parameter.type === "bool") { - const toggle = document.createElement("input"); - toggle.type = "checkbox"; - toggle.checked = parameter.value; - valueLabel.textContent = parameter.value ? "Enabled" : "Disabled"; - toggle.addEventListener("change", () => { - valueLabel.textContent = toggle.checked ? "Enabled" : "Disabled"; - sendValue(toggle.checked); - }); - section.append(toggle, valueLabel); - } else if (parameter.type === "enum") { - const select = document.createElement("select"); - parameter.options.forEach((option) => { - const item = document.createElement("option"); - item.value = option.value; - item.textContent = option.label; - if (option.value === parameter.value) { - item.selected = true; - } - select.appendChild(item); - }); - valueLabel.textContent = parameter.value; - select.addEventListener("change", () => { - valueLabel.textContent = select.value; - sendValue(select.value); - }); - section.append(select, valueLabel); - } - - parameterForm.appendChild(section); - }); -} - -function renderState(state) { - appState = state; - const shaders = state.shaders || []; - const activeShaderId = state.runtime.activeShaderId; - const activeShader = shaders.find((shader) => shader.id === activeShaderId) || shaders[0]; - const performance = state.performance || {}; - const frameBudgetMs = Number(performance.frameBudgetMs || 0); - const renderMs = Number(performance.renderMs || 0); - const smoothedRenderMs = Number(performance.smoothedRenderMs || 0); - const budgetUsedPercent = Number(performance.budgetUsedPercent || 0); - - shaderSelect.innerHTML = ""; - shaders.forEach((shader) => { - const option = document.createElement("option"); - option.value = shader.id; - option.textContent = shader.name; - if (shader.id === activeShaderId) { - option.selected = true; - } - shaderSelect.appendChild(option); - }); - - mixSlider.value = state.runtime.mixAmount ?? 1; - bypassToggle.checked = Boolean(state.runtime.bypass); - compileStatus.textContent = state.runtime.compileMessage || "No compiler output."; - resetParametersButton.disabled = !activeShader || activeShader.parameters.length === 0; - - createKv(runtimeStatus, [ - ["Active Shader", activeShader?.name || "None"], - ["Auto Reload", state.app.autoReload ? "On" : "Off"], - ["Control URL", `http://127.0.0.1:${state.app.serverPort}`], - ["Compile Status", state.runtime.compileSucceeded ? "Ready" : "Error"], - ["Render Time", `${renderMs.toFixed(2)} ms`], - ["Smoothed Time", `${smoothedRenderMs.toFixed(2)} ms`], - ["Frame Budget", `${frameBudgetMs.toFixed(2)} ms`], - ["Budget Used", `${budgetUsedPercent.toFixed(1)}%`], - ]); - - createKv(videoStatus, [ - ["Signal", state.video.hasSignal ? "Present" : "Missing"], - ["Mode", state.video.modeName || "Unknown"], - ["Resolution", `${state.video.width || 0} x ${state.video.height || 0}`], - ]); - - renderParameters(activeShader); -} - -async function loadInitialState() { - const response = await fetch("/api/state"); - renderState(await response.json()); -} - -function connectWebSocket() { - const protocol = location.protocol === "https:" ? "wss" : "ws"; - websocket = new WebSocket(`${protocol}://${location.host}/ws`); - websocket.onmessage = (event) => { - try { - renderState(JSON.parse(event.data)); - } catch (error) { - console.error("Failed to parse state update", error); - } - }; - websocket.onclose = () => { - setTimeout(connectWebSocket, 1000); - }; -} - -shaderSelect.addEventListener("change", () => { - postJson("/api/select-shader", { shaderId: shaderSelect.value }); -}); - -mixSlider.addEventListener("change", () => { - postJson("/api/set-mix", { mixAmount: Number(mixSlider.value) }); -}); - -bypassToggle.addEventListener("change", () => { - postJson("/api/set-bypass", { bypass: bypassToggle.checked }); -}); - -reloadButton.addEventListener("click", () => { - postJson("/api/reload", {}); -}); - -resetParametersButton.addEventListener("click", () => { - const activeShaderId = appState?.runtime?.activeShaderId; - if (!activeShaderId) { - return; - } - - postJson("/api/reset-parameters", { shaderId: activeShaderId }); -}); - -loadInitialState().then(connectWebSocket); diff --git a/ui/index.html b/ui/index.html index f5c6a82..ece7c77 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,53 +1,12 @@ - - - - Video Shader Host - - - -
-
-
- - -
-
- - -
- - -
- -
-
-

Runtime

-
-
-
-

Video

-
-
-
-

Compiler

-

-      
-
- -
-
-

Parameters

- -
-
-
-
- - - + + + + Video Shader Host + + +
+ + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..e223cf9 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1726 @@ +{ + "name": "video-shader-control-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-shader-control-ui", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.511.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..7946c2b --- /dev/null +++ b/ui/package.json @@ -0,0 +1,20 @@ +{ + "name": "video-shader-control-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.511.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx new file mode 100644 index 0000000..6095320 --- /dev/null +++ b/ui/src/App.jsx @@ -0,0 +1,639 @@ +import { useEffect, useMemo, useState } from "react"; +import { GripVertical, Trash2 } from "lucide-react"; + +function KvList({ values }) { + return ( +
+ {values.map(([key, value]) => ( + + ))} +
+ ); +} + +function FragmentRow({ label, value }) { + return ( + <> +
{label}
+
{value}
+ + ); +} + +function postJson(path, payload) { + return fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +function moveItem(array, fromIndex, toIndex) { + if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) { + return array; + } + + const copy = [...array]; + const [item] = copy.splice(fromIndex, 1); + copy.splice(toIndex, 0, item); + return copy; +} + +function formatNumber(value, digits = 3) { + return Number(value ?? 0).toFixed(digits); +} + +function formatParameterValue(parameterType, value) { + if (parameterType === "float") { + return formatNumber(value); + } + if (parameterType === "vec2" || parameterType === "color") { + return (value ?? []).map((item) => formatNumber(item)).join(", "); + } + if (parameterType === "bool") { + return value ? "Enabled" : "Disabled"; + } + return `${value ?? ""}`; +} + +function valuesMatch(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function ParameterField({ parameter, onParameterChange }) { + const [draftValue, setDraftValue] = useState(parameter.value); + + useEffect(() => { + setDraftValue(parameter.value); + }, [parameter.value]); + + const sendValue = (value) => { + setDraftValue(value); + onParameterChange(parameter.id, value); + }; + + const label = ; + const isPending = !valuesMatch(draftValue, parameter.value); + const appliedValueText = formatParameterValue(parameter.type, parameter.value); + + if (parameter.type === "float") { + return ( +
+ {label} +
+ sendValue(Number(event.target.value))} + /> + sendValue(Number(event.target.value))} + /> +
+
+ {isPending ? `Applied: ${appliedValueText}` : appliedValueText} +
+
+ ); + } + + if (parameter.type === "vec2" || parameter.type === "color") { + const componentCount = parameter.type === "color" ? 4 : 2; + const values = [...(draftValue ?? [])]; + while (values.length < componentCount) { + values.push(0); + } + + return ( +
+ {label} +
+ {Array.from({ length: componentCount }, (_, index) => ( + { + const next = [...values]; + next[index] = Number(event.target.value); + sendValue(next); + }} + /> + ))} +
+
+ {isPending ? `Applied: ${appliedValueText}` : appliedValueText} +
+
+ ); + } + + if (parameter.type === "bool") { + return ( +
+ {label} + +
+ {isPending ? `Applied: ${appliedValueText}` : appliedValueText} +
+
+ ); + } + + if (parameter.type === "enum") { + return ( +
+ {label} + +
+ {isPending ? `Applied: ${appliedValueText}` : appliedValueText} +
+
+ ); + } + + return null; +} + +function LayerCard({ + layer, + index, + shaders, + expanded, + isDragging, + isDropTarget, + onToggleExpanded, + onDragStart, + onDragEnd, + onDragOver, + onDrop, + onRemove, +}) { + return ( +
{ + event.preventDefault(); + onDragOver(layer.id); + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(event, layer.id, index); + }} + > +
+
+ + {index + 1} + +
+ +
+ + + + +
+
+ + {expanded ? ( +
+
+ + +
+ +
+

Parameters

+ +
+ + {layer.parameters.length > 0 ? ( +
+ {layer.parameters.map((parameter) => ( + + updateLayerParameterOptimistically(layer.id, parameterId, value) + } + /> + ))} +
+ ) : ( +

This shader does not expose any user parameters.

+ )} +
+ ) : null} +
+ ); +} + +function App() { + const [appState, setAppState] = useState(null); + const [pendingShaderId, setPendingShaderId] = useState(""); + const [presetName, setPresetName] = useState(""); + const [selectedPresetName, setSelectedPresetName] = useState(""); + const [expandedLayerIds, setExpandedLayerIds] = useState([]); + const [dragLayerId, setDragLayerId] = useState(null); + const [dropTargetLayerId, setDropTargetLayerId] = useState(null); + + useEffect(() => { + let mounted = true; + let socket; + let retryTimer; + + const connectWebSocket = () => { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + socket = new WebSocket(`${protocol}://${window.location.host}/ws`); + socket.onmessage = (event) => { + try { + const nextState = JSON.parse(event.data); + if (mounted) { + setAppState(nextState); + } + } catch (error) { + console.error("Failed to parse state update", error); + } + }; + socket.onclose = () => { + if (mounted) { + retryTimer = window.setTimeout(connectWebSocket, 1000); + } + }; + }; + + fetch("/api/state") + .then((response) => response.json()) + .then((state) => { + if (mounted) { + setAppState(state); + connectWebSocket(); + } + }) + .catch((error) => { + console.error("Failed to load initial state", error); + }); + + return () => { + mounted = false; + if (retryTimer) { + window.clearTimeout(retryTimer); + } + if (socket) { + socket.close(); + } + }; + }, []); + + const layers = appState?.layers ?? []; + const shaders = appState?.shaders ?? []; + const performance = appState?.performance ?? {}; + const runtime = appState?.runtime ?? {}; + const video = appState?.video ?? {}; + const app = appState?.app ?? {}; + const stackPresets = appState?.stackPresets ?? []; + + useEffect(() => { + if (!pendingShaderId && shaders.length > 0) { + setPendingShaderId(shaders[0].id); + } else if (pendingShaderId && !shaders.some((shader) => shader.id === pendingShaderId)) { + setPendingShaderId(shaders[0]?.id ?? ""); + } + }, [pendingShaderId, shaders]); + + useEffect(() => { + if (!selectedPresetName && stackPresets.length > 0) { + setSelectedPresetName(stackPresets[0]); + } else if (selectedPresetName && !stackPresets.includes(selectedPresetName)) { + setSelectedPresetName(stackPresets[0] ?? ""); + } + }, [selectedPresetName, stackPresets]); + + useEffect(() => { + const layerIds = new Set(layers.map((layer) => layer.id)); + setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId))); + }, [layers]); + + const expandedSet = useMemo(() => new Set(expandedLayerIds), [expandedLayerIds]); + + function updateLayerParameterOptimistically(layerId, parameterId, value) { + postJson("/api/layers/update-parameter", { + layerId, + parameterId, + value, + }); + } + + function toggleExpanded(layerId) { + setExpandedLayerIds((current) => + current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId], + ); + } + + function removeLayer(layerId) { + setExpandedLayerIds((current) => current.filter((id) => id !== layerId)); + postJson("/api/layers/remove", { layerId }); + } + + function handleDrop(event, targetLayerId, targetIndex) { + const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId; + if (!sourceLayerId || sourceLayerId === targetLayerId) { + setDragLayerId(null); + setDropTargetLayerId(null); + return; + } + + setAppState((current) => { + if (!current?.layers) { + return current; + } + + const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId); + const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId); + if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) { + return current; + } + + return { + ...current, + layers: moveItem(current.layers, sourceIndex, destinationIndex), + }; + }); + + postJson("/api/layers/reorder", { + layerId: sourceLayerId, + targetIndex, + }); + setDragLayerId(null); + setDropTargetLayerId(null); + } + + if (!appState) { + return ( +
+
+

Loading

+

Waiting for control state from the native host.

+
+
+ ); + } + + return ( +
+
+
+ +
+ setPresetName(event.target.value)} + /> + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+
+

Runtime

+ +
+ +
+

Video

+ +
+ +
+

Compiler

+
{runtime.compileMessage || "No compiler output."}
+
+
+ +
+
+

Layers

+

Drag layers to reorder them. Each layer processes the output of the one above it.

+
+ +
+ {layers.map((layer, index) => ( + { + setDragLayerId(null); + setDropTargetLayerId(null); + }} + onDragOver={setDropTargetLayerId} + onDrop={handleDrop} + onRemove={removeLayer} + /> + ))} + +
+
+
+ + +
Add Layer
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+ ); +} + +export default App; diff --git a/ui/src/main.jsx b/ui/src/main.jsx new file mode 100644 index 0000000..1202ac7 --- /dev/null +++ b/ui/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + , +); diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..c2a0702 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,325 @@ +:root { + color-scheme: dark; + font-family: "Segoe UI", sans-serif; + background: #111318; + color: #edf1f7; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + background: #111318; +} + +button, +input, +select, +pre { + font: inherit; +} + +label, +h2, +h3 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +input[type="range"] { + width: 100%; +} + +input[type="number"], +select, +button { + width: 100%; + min-height: 36px; + border-radius: 6px; + border: 1px solid #38445b; + background: #0f131a; + color: inherit; + padding: 8px 10px; +} + +button { + cursor: pointer; + background: #22314a; +} + +button:disabled { + cursor: default; + opacity: 0.55; +} + +pre { + margin: 0; + white-space: pre-wrap; + color: #c9d5ea; +} + +.layout { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + display: grid; + gap: 20px; +} + +.toolbar, +.status-grid, +.parameter-grid { + display: grid; + gap: 16px; +} + +.toolbar { + justify-content: end; +} + +.panel { + background: #181c24; + border: 1px solid #2a3140; + border-radius: 8px; + padding: 16px; +} + +.panel--full { + grid-column: 1 / -1; +} + +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.panel__header button { + width: auto; + min-width: 160px; +} + +.status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.kv { + display: grid; + grid-template-columns: 160px 1fr; + gap: 8px 12px; + margin: 0; +} + +.kv dt { + color: #94a4c2; +} + +.kv dd { + margin: 0; +} + +.layer-stack { + display: grid; + gap: 12px; +} + +.layer-card { + display: grid; + gap: 14px; + padding: 14px; + border: 1px solid #2a3140; + border-radius: 8px; + background: #131720; + transition: border-color 120ms ease, background 120ms ease, transform 120ms ease; +} + +.layer-card:hover { + border-color: #42516b; +} + +.layer-card--expanded { + border-color: #5d81c3; + background: #151d2a; + box-shadow: inset 0 0 0 1px rgba(93, 129, 195, 0.25); +} + +.layer-card--dragging { + opacity: 0.55; +} + +.layer-card--drop-target { + border-color: #8db0ee; + box-shadow: 0 0 0 2px rgba(141, 176, 238, 0.2); +} + +.layer-card__header, +.layer-card__meta, +.layer-card__actions, +.layer-card__subheader { + display: flex; + align-items: center; + gap: 10px; +} + +.layer-card__header, +.layer-card__subheader { + justify-content: space-between; +} + +.layer-card__meta, +.layer-card__actions { + min-width: 0; + flex-wrap: wrap; +} + +.icon-button { + width: 36px; + min-width: 36px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.layer-card__index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 999px; + background: #22314a; + color: #c9d5ea; + font-size: 12px; + font-weight: 700; +} + +.layer-card__title { + width: auto; + min-width: 0; + flex: 1 1 auto; + text-align: left; + background: #1e2a3f; +} + +.layer-card__title--static { + cursor: default; +} + +.layer-card__drag-handle { + color: #94a4c2; + cursor: grab; + user-select: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +.layer-card__actions { + justify-content: flex-end; +} + +.layer-card__actions button { + width: auto; + min-width: 72px; +} + +.layer-card__body { + display: grid; + gap: 14px; +} + +.layer-card--add { + border-style: dashed; +} + +.layer-card__field { + display: grid; + gap: 8px; +} + +.layer-card__subheader button { + width: auto; + min-width: 96px; +} + +.parameter-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.parameter { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #2a3140; + border-radius: 8px; + background: #131720; +} + +.parameter__value { + color: #94a4c2; + font-size: 12px; +} + +.parameter__value--pending { + color: #d3b26a; +} + +.parameter__pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 36px; +} + +.toggle--compact { + min-height: auto; +} + +.toggle--field { + justify-content: flex-start; +} + +.muted { + margin: 0; + color: #94a4c2; +} + +@media (max-width: 900px) { + .toolbar, + .status-grid { + grid-template-columns: 1fr; + } + + .layer-card__header, + .layer-card__subheader { + align-items: flex-start; + flex-direction: column; + } + + .layer-card__actions, + .layer-card__actions button, + .layer-card__field select { + width: 100%; + } +} diff --git a/ui/styles.css b/ui/styles.css deleted file mode 100644 index a67fadc..0000000 --- a/ui/styles.css +++ /dev/null @@ -1,169 +0,0 @@ -:root { - color-scheme: dark; - font-family: "Segoe UI", sans-serif; - background: #111318; - color: #edf1f7; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: #111318; -} - -.layout { - max-width: 1200px; - margin: 0 auto; - padding: 24px; - display: grid; - gap: 20px; -} - -.toolbar, -.status-grid, -.parameter-grid { - display: grid; - gap: 16px; -} - -.toolbar { - grid-template-columns: minmax(220px, 2fr) minmax(220px, 3fr) auto auto; - align-items: end; -} - -.toolbar__group { - display: grid; - gap: 8px; -} - -.toolbar__group--wide { - min-width: 260px; -} - -.panel { - background: #181c24; - border: 1px solid #2a3140; - border-radius: 8px; - padding: 16px; -} - -.panel--full { - grid-column: 1 / -1; -} - -.panel__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.panel__header button { - width: auto; - min-width: 160px; -} - -.status-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.kv { - display: grid; - grid-template-columns: 160px 1fr; - gap: 8px 12px; - margin: 0; -} - -.kv dt { - color: #94a4c2; -} - -.kv dd { - margin: 0; -} - -.parameter-grid { - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); -} - -.parameter { - display: grid; - gap: 8px; - padding: 12px; - border: 1px solid #2a3140; - border-radius: 8px; - background: #131720; -} - -.parameter__value { - color: #94a4c2; - font-size: 12px; -} - -.parameter__pair { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -label, -h2 { - margin: 0; - font-size: 14px; - font-weight: 600; -} - -input, -select, -button, -pre { - font: inherit; -} - -input[type="range"] { - width: 100%; -} - -input[type="number"], -select, -button { - width: 100%; - min-height: 36px; - border-radius: 6px; - border: 1px solid #38445b; - background: #0f131a; - color: inherit; - padding: 8px 10px; -} - -button { - cursor: pointer; - background: #22314a; -} - -.toggle { - display: inline-flex; - align-items: center; - gap: 10px; - min-height: 36px; -} - -pre { - margin: 0; - white-space: pre-wrap; - color: #c9d5ea; -} - -@media (max-width: 900px) { - .toolbar { - grid-template-columns: 1fr; - } - - .status-grid { - grid-template-columns: 1fr; - } -} diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 0000000..4672aa1 --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + }, + server: { + host: "127.0.0.1", + }, +});