From 254d4cd070cfc71a7b612113aeae348a0717f87c Mon Sep 17 00:00:00 2001 From: Aiden Date: Sun, 3 May 2026 12:17:03 +1000 Subject: [PATCH] Added OSC --- CMakeLists.txt | 2 + README.md | 10 + .../ControlServer.cpp | 19 ++ .../ControlServer.h | 1 + .../OpenGLComposite.cpp | 30 +++ .../OpenGLComposite.h | 3 + .../OscServer.cpp | 249 ++++++++++++++++++ .../OscServer.h | 48 ++++ .../RuntimeHost.cpp | 65 +++++ .../RuntimeHost.h | 3 + config/runtime-host.json | 1 + docs/OSC_CONTROL.md | 65 +++++ docs/openapi.yaml | 2 + runtime/README.md | 1 + 14 files changed, 499 insertions(+) create mode 100644 apps/LoopThroughWithOpenGLCompositing/OscServer.cpp create mode 100644 apps/LoopThroughWithOpenGLCompositing/OscServer.h create mode 100644 docs/OSC_CONTROL.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 330b9d7..8e0d23a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,8 @@ set(APP_SOURCES "${APP_DIR}/NativeSockets.h" "${APP_DIR}/OpenGLComposite.cpp" "${APP_DIR}/OpenGLComposite.h" + "${APP_DIR}/OscServer.cpp" + "${APP_DIR}/OscServer.h" "${APP_DIR}/resource.h" "${APP_DIR}/RuntimeHost.cpp" "${APP_DIR}/RuntimeHost.h" diff --git a/README.md b/README.md index 8c80e1e..0219892 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ http://127.0.0.1:/docs Use those docs to inspect the `/api/state`, layer control, stack preset, and reload endpoints. Live state updates are also sent over the `/ws` WebSocket. +## OSC Control + +The native host also listens for local OSC parameter control on the configured `oscPort`: + +```text +/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. See `docs/OSC_CONTROL.md` for details. + ## Shader Packages Each shader package lives under: diff --git a/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp b/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp index 9f48515..a067caa 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp @@ -66,6 +66,8 @@ std::string GuessContentType(const std::filesystem::path& assetPath) return "image/x-icon"; if (extension == ".map") return "application/json"; + if (extension == ".md") + return "text/markdown"; return "text/html"; } } @@ -255,6 +257,10 @@ ControlServer::HttpResponse ControlServer::ServeGetRequest(const HttpRequest& re if (request.path == "/docs" || request.path == "/docs/") return ServeSwaggerDocs(); + const std::string docsPrefix = "/docs/"; + if (request.path.rfind(docsPrefix, 0) == 0) + return ServeDocsAsset(request.path.substr(docsPrefix.size())); + if (request.path.size() > 1) { const HttpResponse assetResponse = ServeUiAsset(request.path.substr(1)); @@ -274,6 +280,19 @@ ControlServer::HttpResponse ControlServer::ServeUiAsset(const std::string& relat : HttpResponse{ "200 OK", contentType, body }; } +ControlServer::HttpResponse ControlServer::ServeDocsAsset(const std::string& relativePath) const +{ + const std::filesystem::path sanitizedPath = std::filesystem::path(relativePath).lexically_normal(); + if (!IsSafeUiPath(sanitizedPath)) + return { "404 Not Found", "text/plain", "Not Found" }; + + const std::filesystem::path docsPath = mDocsRoot / sanitizedPath; + const std::string body = LoadTextFile(docsPath); + return body.empty() + ? HttpResponse{ "404 Not Found", "text/plain", "Not Found" } + : HttpResponse{ "200 OK", GuessContentType(docsPath), body }; +} + ControlServer::HttpResponse ControlServer::ServeOpenApiSpec() const { const std::filesystem::path specPath = mDocsRoot / "openapi.yaml"; diff --git a/apps/LoopThroughWithOpenGLCompositing/ControlServer.h b/apps/LoopThroughWithOpenGLCompositing/ControlServer.h index 1ff092e..df7a19f 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ControlServer.h +++ b/apps/LoopThroughWithOpenGLCompositing/ControlServer.h @@ -76,6 +76,7 @@ private: HttpResponse RouteHttpRequest(const HttpRequest& request); HttpResponse ServeGetRequest(const HttpRequest& request) const; HttpResponse ServeUiAsset(const std::string& relativePath) const; + HttpResponse ServeDocsAsset(const std::string& relativePath) const; HttpResponse ServeOpenApiSpec() const; HttpResponse ServeSwaggerDocs() const; HttpResponse HandleApiPost(const HttpRequest& request); diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp index 9965590..373f9d9 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp @@ -41,6 +41,7 @@ #include "ControlServer.h" #include "OpenGLComposite.h" #include "GLExtensions.h" +#include "OscServer.h" #include #include @@ -325,6 +326,7 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) : InitializeCriticalSection(&pMutex); mRuntimeHost = std::make_unique(); mControlServer = std::make_unique(); + mOscServer = std::make_unique(); } OpenGLComposite::~OpenGLComposite() @@ -411,6 +413,8 @@ OpenGLComposite::~OpenGLComposite() destroyTemporalHistoryResources(); destroyLayerPrograms(); destroyDecodeShaderProgram(); + if (mOscServer) + mOscServer->Stop(); if (mControlServer) mControlServer->Stop(); @@ -843,6 +847,16 @@ bool OpenGLComposite::InitOpenGLState() } mRuntimeHost->SetServerPort(mControlServer->GetPort()); + OscServer::Callbacks oscCallbacks; + oscCallbacks.updateParameter = [this](const std::string& layerKey, const std::string& parameterKey, const std::string& valueJson, std::string& error) { + return UpdateLayerParameterByControlKeyJson(layerKey, parameterKey, valueJson, error); + }; + if (mRuntimeHost->GetOscPort() > 0 && !mOscServer->Start(mRuntimeHost->GetOscPort(), oscCallbacks, runtimeError)) + { + MessageBoxA(NULL, runtimeError.c_str(), "OSC control server failed to start", MB_OK); + return false; + } + // Prepare the runtime shader program generated from the active shader package. char compilerErrorMessage[1024]; if (! compileDecodeShader(sizeof(compilerErrorMessage), compilerErrorMessage)) @@ -1163,6 +1177,9 @@ bool OpenGLComposite::Start() bool OpenGLComposite::Stop() { + if (mOscServer) + mOscServer->Stop(); + if (mControlServer) mControlServer->Stop(); @@ -2120,6 +2137,19 @@ bool OpenGLComposite::UpdateLayerParameterJson(const std::string& layerId, const return true; } +bool OpenGLComposite::UpdateLayerParameterByControlKeyJson(const std::string& layerKey, const std::string& parameterKey, const std::string& valueJson, std::string& error) +{ + JsonValue parsedValue; + if (!ParseJson(valueJson, parsedValue, error)) + return false; + + if (!mRuntimeHost->UpdateLayerParameterByControlKey(layerKey, parameterKey, parsedValue, error)) + return false; + + broadcastRuntimeState(); + return true; +} + bool OpenGLComposite::ResetLayerParameters(const std::string& layerId, std::string& error) { if (!mRuntimeHost->ResetLayerParameters(layerId, error)) diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h index 664119c..6b24c37 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h @@ -66,6 +66,7 @@ class PlayoutDelegate; class CaptureDelegate; class PinnedMemoryAllocator; class ControlServer; +class OscServer; class OpenGLComposite @@ -86,6 +87,7 @@ public: 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 UpdateLayerParameterByControlKeyJson(const std::string& layerKey, const std::string& parameterKey, 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); @@ -149,6 +151,7 @@ private: int mViewHeight; std::unique_ptr mRuntimeHost; std::unique_ptr mControlServer; + std::unique_ptr mOscServer; struct LayerProgram { diff --git a/apps/LoopThroughWithOpenGLCompositing/OscServer.cpp b/apps/LoopThroughWithOpenGLCompositing/OscServer.cpp new file mode 100644 index 0000000..ba78415 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/OscServer.cpp @@ -0,0 +1,249 @@ +#include "stdafx.h" +#include "OscServer.h" + +#include + +#include +#include +#include +#include +#include + +#pragma comment(lib, "Ws2_32.lib") + +namespace +{ +bool InitializeWinsock(std::string& error) +{ + WSADATA wsaData = {}; + const int result = WSAStartup(MAKEWORD(2, 2), &wsaData); + if (result != 0) + { + error = "WSAStartup failed."; + return false; + } + return true; +} + +std::vector SplitAddress(const std::string& address) +{ + std::vector parts; + std::size_t start = !address.empty() && address[0] == '/' ? 1 : 0; + + while (start <= address.size()) + { + const std::size_t slash = address.find('/', start); + const std::size_t end = slash == std::string::npos ? address.size() : slash; + if (end > start) + parts.push_back(address.substr(start, end - start)); + if (slash == std::string::npos) + break; + start = slash + 1; + } + return parts; +} +} + +OscServer::OscServer() + : mPort(0), mRunning(false) +{ +} + +OscServer::~OscServer() +{ + Stop(); +} + +bool OscServer::Start(unsigned short port, const Callbacks& callbacks, std::string& error) +{ + if (port == 0) + return true; + + mCallbacks = callbacks; + mPort = port; + + if (!InitializeWinsock(error)) + return false; + + mSocket.reset(socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)); + if (!mSocket.valid()) + { + error = "Could not create OSC UDP socket."; + return false; + } + + DWORD timeoutMilliseconds = 100; + setsockopt(mSocket.get(), SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); + + sockaddr_in address = {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(static_cast(port)); + if (bind(mSocket.get(), reinterpret_cast(&address), sizeof(address)) != 0) + { + error = "Could not bind OSC listener to UDP port " + std::to_string(port) + "."; + mSocket.reset(); + return false; + } + + mRunning = true; + mThread = std::thread(&OscServer::ServerLoop, this); + return true; +} + +void OscServer::Stop() +{ + mRunning = false; + mSocket.reset(); + if (mThread.joinable()) + mThread.join(); +} + +void OscServer::ServerLoop() +{ + std::array buffer = {}; + while (mRunning) + { + sockaddr_in sender = {}; + int senderLength = sizeof(sender); + const int byteCount = recvfrom(mSocket.get(), buffer.data(), static_cast(buffer.size()), 0, + reinterpret_cast(&sender), &senderLength); + if (byteCount <= 0) + continue; + + OscMessage message; + std::string error; + if (DecodeMessage(buffer.data(), byteCount, message, error)) + DispatchMessage(message, error); + } +} + +bool OscServer::DecodeMessage(const char* data, int byteCount, OscMessage& message, std::string& error) const +{ + int offset = 0; + if (!ReadPaddedString(data, byteCount, offset, message.address) || message.address.empty() || message.address[0] != '/') + { + error = "Invalid OSC address."; + return false; + } + + std::string typeTags; + if (!ReadPaddedString(data, byteCount, offset, typeTags) || typeTags.empty() || typeTags[0] != ',') + { + error = "Invalid OSC type tag string."; + return false; + } + + if (typeTags.size() < 2) + { + error = "OSC message has no parameter value."; + return false; + } + + const char valueType = typeTags[1]; + if (valueType == 'f') + { + double value = 0.0; + if (!ReadFloat32(data, byteCount, offset, value)) + return false; + std::ostringstream stream; + stream << std::setprecision(9) << value; + message.valueJson = stream.str(); + return true; + } + + if (valueType == 'i') + { + int value = 0; + if (!ReadInt32(data, byteCount, offset, value)) + return false; + message.valueJson = std::to_string(value); + return true; + } + + if (valueType == 's') + { + std::string value; + if (!ReadPaddedString(data, byteCount, offset, value)) + return false; + message.valueJson = BuildJsonString(value); + return true; + } + + if (valueType == 'T' || valueType == 'F') + { + message.valueJson = valueType == 'T' ? "true" : "false"; + return true; + } + + error = "Unsupported OSC value type."; + return false; +} + +bool OscServer::DispatchMessage(const OscMessage& message, std::string& error) const +{ + const std::vector parts = SplitAddress(message.address); + if (parts.size() != 3 || parts[0] != "VideoShaderToys") + { + error = "Unsupported OSC address: " + message.address; + return false; + } + + return mCallbacks.updateParameter && + mCallbacks.updateParameter(parts[1], parts[2], message.valueJson, error); +} + +bool OscServer::ReadPaddedString(const char* data, int byteCount, int& offset, std::string& value) +{ + if (offset < 0 || offset >= byteCount) + return false; + + const int start = offset; + while (offset < byteCount && data[offset] != '\0') + ++offset; + if (offset >= byteCount) + return false; + + value.assign(data + start, data + offset); + ++offset; + while (offset % 4 != 0) + ++offset; + return offset <= byteCount; +} + +bool OscServer::ReadInt32(const char* data, int byteCount, int& offset, int& value) +{ + if (offset + 4 > byteCount) + return false; + const unsigned char* bytes = reinterpret_cast(data + offset); + value = static_cast((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]); + offset += 4; + return true; +} + +bool OscServer::ReadFloat32(const char* data, int byteCount, int& offset, double& value) +{ + int bits = 0; + if (!ReadInt32(data, byteCount, offset, bits)) + return false; + + float floatValue = 0.0f; + const unsigned int unsignedBits = static_cast(bits); + std::memcpy(&floatValue, &unsignedBits, sizeof(floatValue)); + value = static_cast(floatValue); + return true; +} + +std::string OscServer::BuildJsonString(const std::string& value) +{ + std::ostringstream stream; + stream << '"'; + for (char ch : value) + { + if (ch == '"' || ch == '\\') + stream << '\\'; + stream << ch; + } + stream << '"'; + return stream.str(); +} diff --git a/apps/LoopThroughWithOpenGLCompositing/OscServer.h b/apps/LoopThroughWithOpenGLCompositing/OscServer.h new file mode 100644 index 0000000..945ffc9 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/OscServer.h @@ -0,0 +1,48 @@ +#pragma once + +#include "NativeSockets.h" + +#include + +#include +#include +#include +#include + +class OscServer +{ +public: + struct Callbacks + { + std::function updateParameter; + }; + + OscServer(); + ~OscServer(); + + bool Start(unsigned short port, const Callbacks& callbacks, std::string& error); + void Stop(); + + unsigned short GetPort() const { return mPort; } + +private: + struct OscMessage + { + std::string address; + std::string valueJson; + }; + + void ServerLoop(); + bool DecodeMessage(const char* data, int byteCount, OscMessage& message, std::string& error) const; + bool DispatchMessage(const OscMessage& message, std::string& error) const; + static bool ReadPaddedString(const char* data, int byteCount, int& offset, std::string& value); + static bool ReadInt32(const char* data, int byteCount, int& offset, int& value); + static bool ReadFloat32(const char* data, int byteCount, int& offset, double& value); + static std::string BuildJsonString(const std::string& value); + + Callbacks mCallbacks; + UniqueSocket mSocket; + unsigned short mPort; + std::thread mThread; + std::atomic mRunning; +}; diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp index accebf5..97595a6 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp @@ -37,6 +37,22 @@ std::string ToLowerCopy(std::string text) return text; } +std::string SimplifyControlKey(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 MatchesControlKey(const std::string& candidate, const std::string& key) +{ + return candidate == key || SimplifyControlKey(candidate) == SimplifyControlKey(key); +} + std::vector JsonArrayToNumbers(const JsonValue& value) { std::vector numbers; @@ -871,6 +887,52 @@ bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::st return SavePersistentState(error); } +bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& newValue, std::string& error) +{ + 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; + } + + 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; + } + + ShaderParameterValue normalized; + if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error)) + return false; + + matchedLayer->parameterValues[parameterIt->id] = normalized; + return SavePersistentState(error); +} + bool RuntimeHost::ResetLayerParameters(const std::string& layerId, std::string& error) { std::lock_guard lock(mMutex); @@ -1109,6 +1171,8 @@ bool RuntimeHost::LoadConfig(std::string& error) mConfig.shaderLibrary = shaderLibraryValue->asString(); if (const JsonValue* serverPortValue = configJson.find("serverPort")) mConfig.serverPort = static_cast(serverPortValue->asNumber(mConfig.serverPort)); + if (const JsonValue* oscPortValue = configJson.find("oscPort")) + mConfig.oscPort = static_cast(oscPortValue->asNumber(mConfig.oscPort)); if (const JsonValue* autoReloadValue = configJson.find("autoReload")) mConfig.autoReload = autoReloadValue->asBoolean(mConfig.autoReload); if (const JsonValue* maxTemporalHistoryFramesValue = configJson.find("maxTemporalHistoryFrames")) @@ -1412,6 +1476,7 @@ JsonValue RuntimeHost::BuildStateValue() const JsonValue app = JsonValue::MakeObject(); app.set("serverPort", JsonValue(static_cast(mServerPort))); + app.set("oscPort", JsonValue(static_cast(mConfig.oscPort))); app.set("autoReload", JsonValue(mAutoReloadEnabled)); app.set("maxTemporalHistoryFrames", JsonValue(static_cast(mConfig.maxTemporalHistoryFrames))); app.set("enableExternalKeying", JsonValue(mConfig.enableExternalKeying)); diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h index f220809..66f405b 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h @@ -28,6 +28,7 @@ public: 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 UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, 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); @@ -48,6 +49,7 @@ public: const std::filesystem::path& GetDocsRoot() const { return mDocsRoot; } const std::filesystem::path& GetRuntimeRoot() const { return mRuntimeRoot; } unsigned short GetServerPort() const { return mServerPort; } + unsigned short GetOscPort() const { return mConfig.oscPort; } unsigned GetMaxTemporalHistoryFrames() const { return mConfig.maxTemporalHistoryFrames; } bool ExternalKeyingEnabled() const { return mConfig.enableExternalKeying; } const std::string& GetVideoFormat() const { return mConfig.videoFormat; } @@ -60,6 +62,7 @@ private: { std::string shaderLibrary = "shaders"; unsigned short serverPort = 8080; + unsigned short oscPort = 9000; bool autoReload = true; unsigned maxTemporalHistoryFrames = 4; bool enableExternalKeying = false; diff --git a/config/runtime-host.json b/config/runtime-host.json index 7a30fd5..ee38e78 100644 --- a/config/runtime-host.json +++ b/config/runtime-host.json @@ -1,6 +1,7 @@ { "shaderLibrary": "shaders", "serverPort": 8080, + "oscPort": 9000, "videoFormat": "1080p", "frameRate": "59.94", "autoReload": true, diff --git a/docs/OSC_CONTROL.md b/docs/OSC_CONTROL.md new file mode 100644 index 0000000..2f542ca --- /dev/null +++ b/docs/OSC_CONTROL.md @@ -0,0 +1,65 @@ +# OSC Control + +Video Shader Toys can listen for local OSC messages and map them onto shader layer parameters. + +## Configuration + +Set the UDP port in `config/runtime-host.json`: + +```json +{ + "oscPort": 9000 +} +``` + +Set `oscPort` to `0` to disable the OSC listener. + +## Address Pattern + +Send OSC messages to: + +```text +/VideoShaderToys/{LayerNameOrID}/{ParameterNameOrID} +``` + +Examples: + +```text +/VideoShaderToys/layer-1/brightness +/VideoShaderToys/VHS/intensity +/VideoShaderToys/TemporalLowFPS/frameRate +``` + +Layer keys are resolved against: + +- Layer ID, such as `layer-1` +- Shader package ID, such as `vhs` +- Shader display name, such as `VHS` + +Parameter keys are resolved against: + +- Parameter ID from `shader.json` +- Parameter label from `shader.json` + +Matching is exact first. If that fails, names are compared in a simplified form that ignores spaces, underscores, hyphens, and casing. For live control, prefer stable IDs where possible. + +## Values + +The listener accepts one OSC argument per message: + +- `f`: float +- `i`: integer +- `s`: string +- `T` / `F`: boolean true/false + +Values are validated with the same shader parameter rules used by the REST API. Invalid values or unknown addresses are ignored. + +## Network + +The listener binds to localhost only: + +```text +127.0.0.1: +``` + +This keeps the control surface local to the machine running Video Shader Toys. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index dc8db5f..94197e6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -359,6 +359,8 @@ components: properties: serverPort: type: number + oscPort: + type: number autoReload: type: boolean maxTemporalHistoryFrames: diff --git a/runtime/README.md b/runtime/README.md index 8f1de93..9e20c40 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -9,6 +9,7 @@ Tracked files: Packaged documentation: - `../docs/openapi.yaml`: OpenAPI/Swagger spec for the local control API. +- `../docs/OSC_CONTROL.md`: OSC address and value reference. - `http://127.0.0.1:/docs`: Swagger UI page served by the native control server. Generated files: