diff --git a/README.md b/README.md index c41a09a..e0c605d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Current native test coverage includes: "serverPort": 8080, "oscBindAddress": "127.0.0.1", "oscPort": 9000, + "oscSmoothing": 0.18, "inputVideoFormat": "1080p", "inputFrameRate": "59.94", "outputVideoFormat": "1080p", @@ -210,7 +211,7 @@ The native host also listens for OSC parameter control on the configured `oscBin /VideoShaderToys/{LayerNameOrID}/{ParameterNameOrID} ``` -For example, `/VideoShaderToys/VHS/intensity` updates the `intensity` parameter on the first matching `VHS` layer. The listener accepts float, integer, string, and boolean OSC values, and validates them through the same shader parameter path as the REST API. The default bind address is `127.0.0.1`; set `oscBindAddress` to `0.0.0.0` to accept OSC on all IPv4 interfaces. See `docs/OSC_CONTROL.md` for details. +For example, `/VideoShaderToys/VHS/intensity` updates the `intensity` parameter on the first matching `VHS` layer. The listener accepts float, integer, string, and boolean OSC values, and validates them through the same shader parameter path as the REST API. OSC updates are coalesced and applied once per render tick, UI state broadcasts are throttled, and OSC-driven parameter changes are not autosaved to `runtime/runtime_state.json`. `oscSmoothing` adds a small per-frame easing amount for numeric OSC controls such as floats, `vec2`, and `color`, while booleans, enums, text, and triggers stay immediate. The default bind address is `127.0.0.1`; set `oscBindAddress` to `0.0.0.0` to accept OSC on all IPv4 interfaces. See `docs/OSC_CONTROL.md` for details. ## Shader Packages diff --git a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp index 1056825..d6dd653 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp @@ -17,6 +17,7 @@ namespace { constexpr DWORD kStateBroadcastIntervalMs = 250; +constexpr DWORD kStateBroadcastThrottleMs = 50; bool InitializeWinsock(std::string& error) { @@ -75,7 +76,7 @@ std::string GuessContentType(const std::filesystem::path& assetPath) } ControlServer::ControlServer() - : mPort(0), mRunning(false) + : mPort(0), mRunning(false), mBroadcastPending(false) { } @@ -161,10 +162,16 @@ void ControlServer::Stop() void ControlServer::BroadcastState() { + mBroadcastPending = false; std::lock_guard lock(mMutex); BroadcastStateLocked(); } +void ControlServer::RequestBroadcastState() +{ + mBroadcastPending = true; +} + void ControlServer::ServerLoop() { DWORD lastStateBroadcastMs = GetTickCount(); @@ -173,7 +180,12 @@ void ControlServer::ServerLoop() TryAcceptClient(); const DWORD nowMs = GetTickCount(); - if (nowMs - lastStateBroadcastMs >= kStateBroadcastIntervalMs) + if (mBroadcastPending && nowMs - lastStateBroadcastMs >= kStateBroadcastThrottleMs) + { + BroadcastState(); + lastStateBroadcastMs = nowMs; + } + else if (nowMs - lastStateBroadcastMs >= kStateBroadcastIntervalMs) { BroadcastState(); lastStateBroadcastMs = nowMs; @@ -469,6 +481,7 @@ bool ControlServer::HandleWebSocketUpgrade(UniqueSocket clientSocket, const Http client.socket.reset(clientSocket.release()); client.websocket = true; mClients.push_back(std::move(client)); + mBroadcastPending = false; BroadcastStateLocked(); } return true; diff --git a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h index 98bba25..51bca6b 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h +++ b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h @@ -41,6 +41,7 @@ public: bool Start(const std::filesystem::path& uiRoot, const std::filesystem::path& docsRoot, unsigned short preferredPort, const Callbacks& callbacks, std::string& error); void Stop(); void BroadcastState(); + void RequestBroadcastState(); unsigned short GetPort() const { return mPort; } @@ -100,6 +101,7 @@ private: unsigned short mPort; std::thread mThread; std::atomic mRunning; + std::atomic mBroadcastPending; mutable std::mutex mMutex; std::vector mClients; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp index 72bc15b..322475b 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp @@ -4,10 +4,12 @@ #include "OpenGLComposite.h" #include "OscServer.h" #include "RuntimeHost.h" +#include "RuntimeServices.h" bool StartRuntimeControlServices( OpenGLComposite& composite, RuntimeHost& runtimeHost, + RuntimeServices& runtimeServices, ControlServer& controlServer, OscServer& oscServer, std::string& error) @@ -41,8 +43,8 @@ bool StartRuntimeControlServices( runtimeHost.SetServerPort(controlServer.GetPort()); OscServer::Callbacks oscCallbacks; - oscCallbacks.updateParameter = [&composite](const std::string& layerKey, const std::string& parameterKey, const std::string& valueJson, std::string& actionError) { - return composite.UpdateLayerParameterByControlKeyJson(layerKey, parameterKey, valueJson, actionError); + oscCallbacks.updateParameter = [&runtimeServices](const std::string& layerKey, const std::string& parameterKey, const std::string& valueJson, std::string& actionError) { + return runtimeServices.QueueOscUpdate(layerKey, parameterKey, valueJson, actionError); }; if (runtimeHost.GetOscPort() > 0 && !oscServer.Start(runtimeHost.GetOscBindAddress(), runtimeHost.GetOscPort(), oscCallbacks, error)) return false; diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.h b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.h index 5646a6e..e2f4244 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.h +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.h @@ -6,10 +6,12 @@ class ControlServer; class OpenGLComposite; class OscServer; class RuntimeHost; +class RuntimeServices; bool StartRuntimeControlServices( OpenGLComposite& composite, RuntimeHost& runtimeHost, + RuntimeServices& runtimeServices, ControlServer& controlServer, OscServer& oscServer, std::string& error); diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp index ff398a3..9a360fa 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp @@ -4,7 +4,6 @@ #include "OscServer.h" #include "RuntimeControlBridge.h" #include "RuntimeHost.h" - #include RuntimeServices::RuntimeServices() : @@ -26,7 +25,7 @@ bool RuntimeServices::Start(OpenGLComposite& composite, RuntimeHost& runtimeHost { Stop(); - if (!StartRuntimeControlServices(composite, runtimeHost, *mControlServer, *mOscServer, error)) + if (!StartRuntimeControlServices(composite, runtimeHost, *this, *mControlServer, *mOscServer, error)) { Stop(); return false; @@ -57,6 +56,62 @@ void RuntimeServices::BroadcastState() mControlServer->BroadcastState(); } +void RuntimeServices::RequestBroadcastState() +{ + if (mControlServer) + mControlServer->RequestBroadcastState(); +} + +bool RuntimeServices::QueueOscUpdate(const std::string& layerKey, const std::string& parameterKey, const std::string& valueJson, std::string& error) +{ + (void)error; + + PendingOscUpdate update; + update.layerKey = layerKey; + update.parameterKey = parameterKey; + update.valueJson = valueJson; + + const std::string routeKey = layerKey + "\n" + parameterKey; + { + std::lock_guard lock(mPendingOscMutex); + mPendingOscUpdates[routeKey] = std::move(update); + } + return true; +} + +bool RuntimeServices::ApplyPendingOscUpdates(std::vector& appliedUpdates, std::string& error) +{ + appliedUpdates.clear(); + + std::map pending; + { + std::lock_guard lock(mPendingOscMutex); + if (mPendingOscUpdates.empty()) + return true; + pending.swap(mPendingOscUpdates); + } + + for (const auto& entry : pending) + { + JsonValue targetValue; + std::string parseError; + if (!ParseJson(entry.second.valueJson, targetValue, parseError)) + { + OutputDebugStringA(("OSC queued value parse failed: " + parseError + "\n").c_str()); + continue; + } + + AppliedOscUpdate appliedUpdate; + appliedUpdate.layerKey = entry.second.layerKey; + appliedUpdate.parameterKey = entry.second.parameterKey; + appliedUpdate.targetValue = targetValue; + appliedUpdates.push_back(std::move(appliedUpdate)); + } + + (void)error; + return true; +} + RuntimePollEvents RuntimeServices::ConsumePollEvents() { RuntimePollEvents events; diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h index 29ba710..2dc6153 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h @@ -1,6 +1,10 @@ #pragma once +#include "RuntimeJson.h" +#include "ShaderTypes.h" + #include +#include #include #include #include @@ -22,6 +26,13 @@ struct RuntimePollEvents class RuntimeServices { public: + struct AppliedOscUpdate + { + std::string layerKey; + std::string parameterKey; + JsonValue targetValue; + }; + RuntimeServices(); ~RuntimeServices(); @@ -29,9 +40,19 @@ public: void BeginPolling(RuntimeHost& runtimeHost); void Stop(); void BroadcastState(); + void RequestBroadcastState(); + bool QueueOscUpdate(const std::string& layerKey, const std::string& parameterKey, const std::string& valueJson, std::string& error); + bool ApplyPendingOscUpdates(std::vector& appliedUpdates, std::string& error); RuntimePollEvents ConsumePollEvents(); private: + struct PendingOscUpdate + { + std::string layerKey; + std::string parameterKey; + std::string valueJson; + }; + void StartPolling(RuntimeHost& runtimeHost); void StopPolling(); void PollLoop(RuntimeHost& runtimeHost); @@ -45,4 +66,6 @@ private: std::atomic mPollFailed; std::mutex mPollErrorMutex; std::string mPollError; + std::mutex mPendingOscMutex; + std::map mPendingOscUpdates; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp index 8f7aff3..e272ed9 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp @@ -8,19 +8,74 @@ #include "OpenGLShaderPrograms.h" #include "OpenGLVideoIOBridge.h" #include "PngScreenshotWriter.h" +#include "RuntimeParameterUtils.h" #include "RuntimeServices.h" #include "ShaderBuildQueue.h" #include +#include #include #include #include #include #include +#include #include #include #include +namespace +{ +constexpr auto kOscOverlayCommitDelay = std::chrono::milliseconds(150); + +std::string SimplifyOscControlKey(const std::string& text) +{ + std::string simplified; + for (unsigned char ch : text) + { + if (std::isalnum(ch)) + simplified.push_back(static_cast(std::tolower(ch))); + } + return simplified; +} + +bool MatchesOscControlKey(const std::string& candidate, const std::string& key) +{ + return candidate == key || SimplifyOscControlKey(candidate) == SimplifyOscControlKey(key); +} + +double ClampOscAlpha(double value) +{ + return (std::max)(0.0, (std::min)(1.0, value)); +} + +JsonValue BuildOscCommitValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) +{ + switch (definition.type) + { + case ShaderParameterType::Boolean: + return JsonValue(value.booleanValue); + case ShaderParameterType::Enum: + return JsonValue(value.enumValue); + case ShaderParameterType::Text: + return JsonValue(value.textValue); + case ShaderParameterType::Trigger: + case ShaderParameterType::Float: + return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); + case ShaderParameterType::Vec2: + case ShaderParameterType::Color: + { + JsonValue array = JsonValue::MakeArray(); + for (double number : value.numberValues) + array.pushBack(JsonValue(number)); + return array; + } + } + + return JsonValue(); +} +} + OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) : hGLWnd(hWnd), hGLDC(hDC), hGLRC(hRC), mVideoIO(std::make_unique()), @@ -322,12 +377,158 @@ bool OpenGLComposite::RequestScreenshot(std::string& error) void OpenGLComposite::renderEffect() { ProcessRuntimePollResults(); + std::vector appliedOscUpdates; + if (mRuntimeHost && mRuntimeServices) + { + std::string oscError; + if (!mRuntimeServices->ApplyPendingOscUpdates(appliedOscUpdates, oscError) && !oscError.empty()) + OutputDebugStringA(("OSC apply failed: " + oscError + "\n").c_str()); + } + + std::set pendingOscRouteKeys; + const auto oscNow = std::chrono::steady_clock::now(); + for (const RuntimeServices::AppliedOscUpdate& update : appliedOscUpdates) + { + const std::string routeKey = update.layerKey + "\n" + update.parameterKey; + auto overlayIt = mOscOverlayStates.find(routeKey); + if (overlayIt == mOscOverlayStates.end()) + { + OscOverlayState overlay; + overlay.layerKey = update.layerKey; + overlay.parameterKey = update.parameterKey; + overlay.targetValue = update.targetValue; + overlay.lastUpdatedTime = oscNow; + mOscOverlayStates[routeKey] = std::move(overlay); + } + else + { + overlayIt->second.targetValue = update.targetValue; + overlayIt->second.lastUpdatedTime = oscNow; + } + pendingOscRouteKeys.insert(routeKey); + } + + const auto applyOscOverlays = [&](std::vector& states, bool allowCommit) + { + if (states.empty() || mOscOverlayStates.empty() || !mRuntimeHost) + return; + + const double smoothing = ClampOscAlpha(mRuntimeHost->GetOscSmoothing()); + std::vector overlayKeysToRemove; + for (auto& item : mOscOverlayStates) + { + OscOverlayState& overlay = item.second; + auto stateIt = std::find_if(states.begin(), states.end(), + [&overlay](const RuntimeRenderState& state) + { + return MatchesOscControlKey(state.layerId, overlay.layerKey) || + MatchesOscControlKey(state.shaderId, overlay.layerKey) || + MatchesOscControlKey(state.shaderName, overlay.layerKey); + }); + if (stateIt == states.end()) + continue; + + auto definitionIt = std::find_if(stateIt->parameterDefinitions.begin(), stateIt->parameterDefinitions.end(), + [&overlay](const ShaderParameterDefinition& definition) + { + return MatchesOscControlKey(definition.id, overlay.parameterKey) || + MatchesOscControlKey(definition.label, overlay.parameterKey); + }); + if (definitionIt == stateIt->parameterDefinitions.end()) + continue; + + if (definitionIt->type == ShaderParameterType::Trigger) + { + if (pendingOscRouteKeys.find(item.first) == pendingOscRouteKeys.end()) + continue; + + ShaderParameterValue& value = stateIt->parameterValues[definitionIt->id]; + const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; + const double triggerTime = stateIt->timeSeconds; + value.numberValues = { previousCount + 1.0, triggerTime }; + overlayKeysToRemove.push_back(item.first); + continue; + } + + ShaderParameterValue targetValue; + std::string normalizeError; + if (!NormalizeAndValidateParameterValue(*definitionIt, overlay.targetValue, targetValue, normalizeError)) + continue; + + const bool smoothable = + smoothing > 0.0 && + (definitionIt->type == ShaderParameterType::Float || + definitionIt->type == ShaderParameterType::Vec2 || + definitionIt->type == ShaderParameterType::Color); + if (!smoothable) + { + overlay.currentValue = targetValue; + overlay.hasCurrentValue = true; + stateIt->parameterValues[definitionIt->id] = overlay.currentValue; + if (allowCommit && oscNow - overlay.lastUpdatedTime >= kOscOverlayCommitDelay) + { + std::string commitError; + if (mRuntimeHost->UpdateLayerParameterByControlKey(overlay.layerKey, overlay.parameterKey, overlay.targetValue, false, commitError)) + overlayKeysToRemove.push_back(item.first); + } + continue; + } + + if (!overlay.hasCurrentValue) + { + overlay.currentValue = DefaultValueForDefinition(*definitionIt); + auto currentIt = stateIt->parameterValues.find(definitionIt->id); + if (currentIt != stateIt->parameterValues.end()) + overlay.currentValue = currentIt->second; + overlay.hasCurrentValue = true; + } + + if (overlay.currentValue.numberValues.size() != targetValue.numberValues.size()) + overlay.currentValue.numberValues = targetValue.numberValues; + + ShaderParameterValue nextValue = targetValue; + bool converged = true; + for (std::size_t index = 0; index < targetValue.numberValues.size(); ++index) + { + const double currentNumber = overlay.currentValue.numberValues[index]; + const double targetNumber = targetValue.numberValues[index]; + const double delta = targetNumber - currentNumber; + double nextNumber = currentNumber + delta * smoothing; + if (std::fabs(delta) <= 0.0005) + nextNumber = targetNumber; + else + converged = false; + nextValue.numberValues[index] = nextNumber; + } + + if (converged) + nextValue.numberValues = targetValue.numberValues; + + overlay.currentValue = nextValue; + overlay.hasCurrentValue = true; + stateIt->parameterValues[definitionIt->id] = overlay.currentValue; + if (allowCommit && converged && oscNow - overlay.lastUpdatedTime >= kOscOverlayCommitDelay) + { + std::string commitError; + JsonValue committedValue = BuildOscCommitValue(*definitionIt, overlay.currentValue); + if (mRuntimeHost->UpdateLayerParameterByControlKey(overlay.layerKey, overlay.parameterKey, committedValue, false, commitError)) + overlayKeysToRemove.push_back(item.first); + } + } + + if (allowCommit) + { + for (const std::string& overlayKey : overlayKeysToRemove) + mOscOverlayStates.erase(overlayKey); + } + }; const bool hasInputSource = mVideoIO->HasInputSource(); std::vector layerStates; if (mUseCommittedLayerStates) { layerStates = mShaderPrograms->CommittedLayerStates(); + applyOscOverlays(layerStates, false); if (mRuntimeHost) mRuntimeHost->RefreshDynamicRenderStateFields(layerStates); } @@ -336,6 +537,7 @@ void OpenGLComposite::renderEffect() const unsigned renderWidth = mVideoIO->InputFrameWidth(); const unsigned renderHeight = mVideoIO->InputFrameHeight(); const uint64_t renderStateVersion = mRuntimeHost->GetRenderStateVersion(); + const uint64_t parameterStateVersion = mRuntimeHost->GetParameterStateVersion(); const bool renderStateCacheValid = !mCachedLayerRenderStates.empty() && mCachedRenderStateVersion == renderStateVersion && @@ -344,6 +546,13 @@ void OpenGLComposite::renderEffect() if (renderStateCacheValid) { + applyOscOverlays(mCachedLayerRenderStates, true); + if (mCachedParameterStateVersion != parameterStateVersion && + mRuntimeHost->TryRefreshCachedLayerStates(mCachedLayerRenderStates)) + { + mCachedParameterStateVersion = parameterStateVersion; + applyOscOverlays(mCachedLayerRenderStates, true); + } layerStates = mCachedLayerRenderStates; mRuntimeHost->RefreshDynamicRenderStateFields(layerStates); } @@ -353,11 +562,15 @@ void OpenGLComposite::renderEffect() { mCachedLayerRenderStates = layerStates; mCachedRenderStateVersion = renderStateVersion; + mCachedParameterStateVersion = parameterStateVersion; mCachedRenderStateWidth = renderWidth; mCachedRenderStateHeight = renderHeight; + applyOscOverlays(mCachedLayerRenderStates, true); + layerStates = mCachedLayerRenderStates; } else { + applyOscOverlays(mCachedLayerRenderStates, true); layerStates = mCachedLayerRenderStates; mRuntimeHost->RefreshDynamicRenderStateFields(layerStates); } diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h index f1586c6..8c75f13 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h @@ -73,6 +73,15 @@ private: bool CheckOpenGLExtensions(); void PublishVideoIOStatus(const std::string& statusMessage); using LayerProgram = OpenGLRenderer::LayerProgram; + struct OscOverlayState + { + std::string layerKey; + std::string parameterKey; + JsonValue targetValue; + ShaderParameterValue currentValue; + bool hasCurrentValue = false; + std::chrono::steady_clock::time_point lastUpdatedTime; + }; HWND hGLWnd; HDC hGLDC; @@ -90,8 +99,10 @@ private: std::unique_ptr mRuntimeServices; std::vector mCachedLayerRenderStates; uint64_t mCachedRenderStateVersion = 0; + uint64_t mCachedParameterStateVersion = 0; unsigned mCachedRenderStateWidth = 0; unsigned mCachedRenderStateHeight = 0; + std::map mOscOverlayStates; std::atomic mUseCommittedLayerStates; std::atomic mScreenshotRequested; std::chrono::steady_clock::time_point mLastPreviewPresentTime; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp index bbea694..fd4b62c 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp @@ -33,6 +33,11 @@ bool IsFiniteNumber(double value) return std::isfinite(value) != 0; } +double Clamp01(double value) +{ + return std::max(0.0, std::min(1.0, value)); +} + std::string ToLowerCopy(std::string text) { std::transform(text.begin(), text.end(), text.begin(), @@ -56,6 +61,20 @@ bool MatchesControlKey(const std::string& candidate, const std::string& key) return candidate == key || SimplifyControlKey(candidate) == SimplifyControlKey(key); } +bool JsonValueContainsOnlyNumbers(const JsonValue& value) +{ + if (!value.isArray()) + return false; + + for (const JsonValue& item : value.asArray()) + { + if (!item.isNumber() || !IsFiniteNumber(item.asNumber())) + return false; + } + + return true; +} + double GenerateStartupRandom() { std::random_device randomDevice; @@ -970,7 +989,7 @@ bool RuntimeHost::SetLayerBypass(const std::string& layerId, bool bypassed, std: layer->bypass = bypassed; mReloadRequested = true; - MarkRenderStateDirtyLocked(); + MarkParameterStateDirtyLocked(); return SavePersistentState(error); } @@ -1032,7 +1051,7 @@ bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::st const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; const double triggerTime = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); value.numberValues = { previousCount + 1.0, triggerTime }; - MarkRenderStateDirtyLocked(); + MarkParameterStateDirtyLocked(); return true; } @@ -1041,11 +1060,16 @@ bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::st return false; layer->parameterValues[parameterId] = normalized; - MarkRenderStateDirtyLocked(); + MarkParameterStateDirtyLocked(); return SavePersistentState(error); } bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& newValue, std::string& error) +{ + return UpdateLayerParameterByControlKey(layerKey, parameterKey, newValue, true, error); +} + +bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& newValue, bool persistState, std::string& error) { std::lock_guard lock(mMutex); @@ -1089,7 +1113,7 @@ bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; const double triggerTime = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); value.numberValues = { previousCount + 1.0, triggerTime }; - MarkRenderStateDirtyLocked(); + MarkParameterStateDirtyLocked(); return true; } @@ -1098,8 +1122,141 @@ bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, return false; matchedLayer->parameterValues[parameterIt->id] = normalized; - MarkRenderStateDirtyLocked(); - return SavePersistentState(error); + MarkParameterStateDirtyLocked(); + return !persistState || SavePersistentState(error); +} + +bool RuntimeHost::ApplyOscTargetByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& targetValue, double smoothingAmount, bool& keepApplying, std::string& resolvedLayerId, std::string& resolvedParameterId, ShaderParameterValue& appliedValue, std::string& error) +{ + keepApplying = false; + resolvedLayerId.clear(); + resolvedParameterId.clear(); + appliedValue = ShaderParameterValue(); + + std::lock_guard lock(mMutex); + + LayerPersistentState* matchedLayer = nullptr; + const ShaderPackage* matchedPackage = nullptr; + for (LayerPersistentState& layer : mPersistentState.layers) + { + auto shaderIt = mPackagesById.find(layer.shaderId); + if (shaderIt == mPackagesById.end()) + continue; + + if (MatchesControlKey(layer.id, layerKey) || MatchesControlKey(shaderIt->second.id, layerKey) || + MatchesControlKey(shaderIt->second.displayName, layerKey)) + { + matchedLayer = &layer; + matchedPackage = &shaderIt->second; + break; + } + } + + if (!matchedLayer || !matchedPackage) + { + error = "Unknown OSC layer key: " + layerKey; + return false; + } + + resolvedLayerId = matchedLayer->id; + + const auto parameterIt = std::find_if(matchedPackage->parameters.begin(), matchedPackage->parameters.end(), + [¶meterKey](const ShaderParameterDefinition& definition) + { + return MatchesControlKey(definition.id, parameterKey) || MatchesControlKey(definition.label, parameterKey); + }); + if (parameterIt == matchedPackage->parameters.end()) + { + error = "Unknown OSC parameter key: " + parameterKey; + return false; + } + + resolvedParameterId = parameterIt->id; + + if (parameterIt->type == ShaderParameterType::Trigger) + { + ShaderParameterValue& value = matchedLayer->parameterValues[parameterIt->id]; + const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; + const double triggerTime = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); + value.numberValues = { previousCount + 1.0, triggerTime }; + MarkParameterStateDirtyLocked(); + appliedValue = value; + return true; + } + + ShaderParameterValue normalizedTarget; + if (!NormalizeAndValidateValue(*parameterIt, targetValue, normalizedTarget, error)) + return false; + + const bool smoothableType = + parameterIt->type == ShaderParameterType::Float || + parameterIt->type == ShaderParameterType::Vec2 || + parameterIt->type == ShaderParameterType::Color; + const bool smoothableInput = targetValue.isNumber() || JsonValueContainsOnlyNumbers(targetValue); + const double alpha = Clamp01(smoothingAmount); + + if (!smoothableType || !smoothableInput || alpha <= 0.0) + { + matchedLayer->parameterValues[parameterIt->id] = normalizedTarget; + MarkParameterStateDirtyLocked(); + appliedValue = normalizedTarget; + return true; + } + + ShaderParameterValue currentValue = DefaultValueForDefinition(*parameterIt); + auto currentIt = matchedLayer->parameterValues.find(parameterIt->id); + if (currentIt != matchedLayer->parameterValues.end()) + currentValue = currentIt->second; + + ShaderParameterValue nextValue = normalizedTarget; + nextValue.numberValues = normalizedTarget.numberValues; + + if (currentValue.numberValues.size() != normalizedTarget.numberValues.size()) + currentValue.numberValues = normalizedTarget.numberValues; + + bool changed = false; + bool converged = true; + for (std::size_t index = 0; index < normalizedTarget.numberValues.size(); ++index) + { + const double currentNumber = currentValue.numberValues[index]; + const double targetNumber = normalizedTarget.numberValues[index]; + const double delta = targetNumber - currentNumber; + double nextNumber = currentNumber + delta * alpha; + + if (std::fabs(delta) <= 0.0005) + { + nextNumber = targetNumber; + } + else + { + converged = false; + } + + if (std::fabs(nextNumber - currentNumber) > 0.0000001) + changed = true; + + nextValue.numberValues[index] = nextNumber; + } + + if (!converged) + { + keepApplying = true; + } + else + { + nextValue.numberValues = normalizedTarget.numberValues; + } + + if (!changed && !keepApplying) + { + appliedValue = matchedLayer->parameterValues[parameterIt->id]; + return true; + } + + matchedLayer->parameterValues[parameterIt->id] = nextValue; + MarkParameterStateDirtyLocked(); + appliedValue = nextValue; + return true; } bool RuntimeHost::ResetLayerParameters(const std::string& layerId, std::string& error) @@ -1122,7 +1279,7 @@ bool RuntimeHost::ResetLayerParameters(const std::string& layerId, std::string& layer->parameterValues.clear(); EnsureLayerDefaultsLocked(*layer, shaderIt->second); - MarkRenderStateDirtyLocked(); + MarkParameterStateDirtyLocked(); return SavePersistentState(error); } @@ -1226,6 +1383,12 @@ void RuntimeHost::SetSignalStatusLocked(bool hasSignal, unsigned width, unsigned void RuntimeHost::MarkRenderStateDirtyLocked() { mRenderStateVersion.fetch_add(1, std::memory_order_relaxed); + mParameterStateVersion.fetch_add(1, std::memory_order_relaxed); +} + +void RuntimeHost::MarkParameterStateDirtyLocked() +{ + mParameterStateVersion.fetch_add(1, std::memory_order_relaxed); } void RuntimeHost::SetDeckLinkOutputStatus(const std::string& modelName, bool supportsInternalKeying, bool supportsExternalKeying, @@ -1388,6 +1551,34 @@ bool RuntimeHost::TryGetLayerRenderStates(unsigned outputWidth, unsigned outputH return true; } +bool RuntimeHost::TryRefreshCachedLayerStates(std::vector& states) const +{ + std::unique_lock lock(mMutex, std::try_to_lock); + if (!lock.owns_lock()) + return false; + + for (RuntimeRenderState& state : states) + { + const auto layerIt = std::find_if(mPersistentState.layers.begin(), mPersistentState.layers.end(), + [&state](const LayerPersistentState& layer) { return layer.id == state.layerId; }); + if (layerIt == mPersistentState.layers.end()) + continue; + + state.bypass = layerIt->bypass ? 1.0 : 0.0; + state.parameterValues.clear(); + for (const ShaderParameterDefinition& definition : state.parameterDefinitions) + { + ShaderParameterValue value = DefaultValueForDefinition(definition); + auto valueIt = layerIt->parameterValues.find(definition.id); + if (valueIt != layerIt->parameterValues.end()) + value = valueIt->second; + state.parameterValues[definition.id] = value; + } + } + + return true; +} + void RuntimeHost::RefreshDynamicRenderStateFields(std::vector& states) const { const RuntimeClockSnapshot clock = GetRuntimeClockSnapshot(); @@ -1415,6 +1606,7 @@ void RuntimeHost::BuildLayerRenderStatesLocked(unsigned outputWidth, unsigned ou RuntimeRenderState state; state.layerId = layer.id; state.shaderId = layer.shaderId; + state.shaderName = shaderIt->second.displayName; state.mixAmount = 1.0; state.bypass = layer.bypass ? 1.0 : 0.0; state.inputWidth = mSignalWidth; @@ -1476,6 +1668,8 @@ bool RuntimeHost::LoadConfig(std::string& error) mConfig.oscPort = static_cast(oscPortValue->asNumber(mConfig.oscPort)); if (const JsonValue* oscBindAddressValue = configJson.find("oscBindAddress")) mConfig.oscBindAddress = oscBindAddressValue->asString(); + if (const JsonValue* oscSmoothingValue = configJson.find("oscSmoothing")) + mConfig.oscSmoothing = Clamp01(oscSmoothingValue->asNumber(mConfig.oscSmoothing)); if (const JsonValue* autoReloadValue = configJson.find("autoReload")) mConfig.autoReload = autoReloadValue->asBoolean(mConfig.autoReload); if (const JsonValue* maxTemporalHistoryFramesValue = configJson.find("maxTemporalHistoryFrames")) @@ -1873,6 +2067,7 @@ JsonValue RuntimeHost::BuildStateValue() const app.set("serverPort", JsonValue(static_cast(mServerPort))); app.set("oscPort", JsonValue(static_cast(mConfig.oscPort))); app.set("oscBindAddress", JsonValue(mConfig.oscBindAddress)); + app.set("oscSmoothing", JsonValue(mConfig.oscSmoothing)); app.set("autoReload", JsonValue(mAutoReloadEnabled)); app.set("maxTemporalHistoryFrames", JsonValue(static_cast(mConfig.maxTemporalHistoryFrames))); app.set("previewFps", JsonValue(static_cast(mConfig.previewFps))); diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.h b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.h index cba1d47..0a33330 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.h +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.h @@ -31,6 +31,8 @@ public: 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 UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& newValue, std::string& error); + bool UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& newValue, bool persistState, std::string& error); + bool ApplyOscTargetByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& targetValue, double smoothingAmount, bool& keepApplying, std::string& resolvedLayerId, std::string& resolvedParameterId, ShaderParameterValue& appliedValue, 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); @@ -54,9 +56,11 @@ public: bool BuildLayerPassFragmentShaderSources(const std::string& layerId, std::vector& passSources, std::string& error); std::vector GetLayerRenderStates(unsigned outputWidth, unsigned outputHeight) const; bool TryGetLayerRenderStates(unsigned outputWidth, unsigned outputHeight, std::vector& states) const; + bool TryRefreshCachedLayerStates(std::vector& states) const; void RefreshDynamicRenderStateFields(std::vector& states) const; std::string BuildStateJson() const; uint64_t GetRenderStateVersion() const { return mRenderStateVersion.load(std::memory_order_relaxed); } + uint64_t GetParameterStateVersion() const { return mParameterStateVersion.load(std::memory_order_relaxed); } const std::filesystem::path& GetRepoRoot() const { return mRepoRoot; } const std::filesystem::path& GetUiRoot() const { return mUiRoot; } @@ -65,6 +69,7 @@ public: unsigned short GetServerPort() const { return mServerPort; } unsigned short GetOscPort() const { return mConfig.oscPort; } const std::string& GetOscBindAddress() const { return mConfig.oscBindAddress; } + double GetOscSmoothing() const { return mConfig.oscSmoothing; } unsigned GetMaxTemporalHistoryFrames() const { return mConfig.maxTemporalHistoryFrames; } unsigned GetPreviewFps() const { return mConfig.previewFps; } bool ExternalKeyingEnabled() const { return mConfig.enableExternalKeying; } @@ -82,6 +87,7 @@ private: unsigned short serverPort = 8080; unsigned short oscPort = 9000; std::string oscBindAddress = "127.0.0.1"; + double oscSmoothing = 0.18; bool autoReload = true; unsigned maxTemporalHistoryFrames = 4; unsigned previewFps = 30; @@ -141,6 +147,7 @@ private: std::string GenerateLayerId(); void SetSignalStatusLocked(bool hasSignal, unsigned width, unsigned height, const std::string& modeName); void MarkRenderStateDirtyLocked(); + void MarkParameterStateDirtyLocked(); void SetPerformanceStatsLocked(double frameBudgetMilliseconds, double renderMilliseconds); void SetFramePacingStatsLocked(double completionIntervalMilliseconds, double smoothedCompletionIntervalMilliseconds, double maxCompletionIntervalMilliseconds, uint64_t lateFrameCount, uint64_t droppedFrameCount, uint64_t flushedFrameCount); @@ -187,5 +194,6 @@ private: std::chrono::steady_clock::time_point mLastScanTime; std::atomic mFrameCounter{ 0 }; std::atomic mRenderStateVersion{ 0 }; + std::atomic mParameterStateVersion{ 0 }; uint64_t mNextLayerId; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h index a378ee4..54cd2da 100644 --- a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h +++ b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h @@ -128,6 +128,7 @@ struct RuntimeRenderState { std::string layerId; std::string shaderId; + std::string shaderName; std::vector parameterDefinitions; std::map parameterValues; std::vector textureAssets; diff --git a/config/runtime-host.json b/config/runtime-host.json index e20ef5b..b6944e7 100644 --- a/config/runtime-host.json +++ b/config/runtime-host.json @@ -1,8 +1,9 @@ { "shaderLibrary": "shaders", "serverPort": 8080, - "oscBindAddress": "127.0.0.1", + "oscBindAddress": "0.0.0.0", "oscPort": 9000, + "oscSmoothing": 0.18, "inputVideoFormat": "1080p", "inputFrameRate": "59.94", "outputVideoFormat": "1080p", diff --git a/docs/OSC_CONTROL.md b/docs/OSC_CONTROL.md index 69ac619..521fd83 100644 --- a/docs/OSC_CONTROL.md +++ b/docs/OSC_CONTROL.md @@ -9,12 +9,14 @@ Set the UDP port in `config/runtime-host.json`: ```json { "oscBindAddress": "127.0.0.1", - "oscPort": 9000 + "oscPort": 9000, + "oscSmoothing": 0.18 } ``` Set `oscPort` to `0` to disable the OSC listener. Set `oscBindAddress` to `127.0.0.1` to keep OSC local to the host, or `0.0.0.0` to listen on all IPv4 interfaces. +Set `oscSmoothing` to a value from `0.0` to `1.0` to add a subtle per-frame easing amount for numeric OSC controls. `0.0` disables smoothing, and larger values respond more quickly. ## Address Pattern @@ -63,6 +65,8 @@ The listener accepts these OSC argument types: Single-argument messages become scalar JSON values. Multi-argument messages become JSON arrays, which lets OSC drive `vec2` and `color` parameters. +OSC updates are coalesced by target route and applied once per render tick, so rapid controller motion does not force one runtime mutation, disk write, and UI push per incoming UDP packet. Numeric OSC controls can also be slightly smoothed with `oscSmoothing`. + Examples: ```text @@ -74,6 +78,8 @@ Examples: Values are validated with the same shader parameter rules used by the REST API. Invalid values or unknown addresses are ignored and reported to the native debug output. +OSC-driven parameter changes are not autosaved to `runtime/runtime_state.json`. Stack edits made through the UI and preset operations still persist as before. Smoothing only applies to numeric controls such as floats, `vec2`, and `color`; booleans, enums, text, and triggers stay immediate. + For `trigger` parameters, the OSC value is treated as a pulse. A simple integer or boolean message is enough: ```text