Added OSC
Some checks failed
CI / Native Windows Build And Tests (push) Failing after 7s
CI / React UI Build (push) Has been cancelled
CI / Windows Release Package (push) Has been cancelled

This commit is contained in:
2026-05-03 12:17:03 +10:00
parent 1b56ede462
commit 254d4cd070
14 changed files with 499 additions and 0 deletions

View File

@@ -30,6 +30,8 @@ set(APP_SOURCES
"${APP_DIR}/NativeSockets.h" "${APP_DIR}/NativeSockets.h"
"${APP_DIR}/OpenGLComposite.cpp" "${APP_DIR}/OpenGLComposite.cpp"
"${APP_DIR}/OpenGLComposite.h" "${APP_DIR}/OpenGLComposite.h"
"${APP_DIR}/OscServer.cpp"
"${APP_DIR}/OscServer.h"
"${APP_DIR}/resource.h" "${APP_DIR}/resource.h"
"${APP_DIR}/RuntimeHost.cpp" "${APP_DIR}/RuntimeHost.cpp"
"${APP_DIR}/RuntimeHost.h" "${APP_DIR}/RuntimeHost.h"

View File

@@ -158,6 +158,16 @@ http://127.0.0.1:<serverPort>/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. 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 ## Shader Packages
Each shader package lives under: Each shader package lives under:

View File

@@ -66,6 +66,8 @@ std::string GuessContentType(const std::filesystem::path& assetPath)
return "image/x-icon"; return "image/x-icon";
if (extension == ".map") if (extension == ".map")
return "application/json"; return "application/json";
if (extension == ".md")
return "text/markdown";
return "text/html"; return "text/html";
} }
} }
@@ -255,6 +257,10 @@ ControlServer::HttpResponse ControlServer::ServeGetRequest(const HttpRequest& re
if (request.path == "/docs" || request.path == "/docs/") if (request.path == "/docs" || request.path == "/docs/")
return ServeSwaggerDocs(); 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) if (request.path.size() > 1)
{ {
const HttpResponse assetResponse = ServeUiAsset(request.path.substr(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 }; : 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 ControlServer::HttpResponse ControlServer::ServeOpenApiSpec() const
{ {
const std::filesystem::path specPath = mDocsRoot / "openapi.yaml"; const std::filesystem::path specPath = mDocsRoot / "openapi.yaml";

View File

@@ -76,6 +76,7 @@ private:
HttpResponse RouteHttpRequest(const HttpRequest& request); HttpResponse RouteHttpRequest(const HttpRequest& request);
HttpResponse ServeGetRequest(const HttpRequest& request) const; HttpResponse ServeGetRequest(const HttpRequest& request) const;
HttpResponse ServeUiAsset(const std::string& relativePath) const; HttpResponse ServeUiAsset(const std::string& relativePath) const;
HttpResponse ServeDocsAsset(const std::string& relativePath) const;
HttpResponse ServeOpenApiSpec() const; HttpResponse ServeOpenApiSpec() const;
HttpResponse ServeSwaggerDocs() const; HttpResponse ServeSwaggerDocs() const;
HttpResponse HandleApiPost(const HttpRequest& request); HttpResponse HandleApiPost(const HttpRequest& request);

View File

@@ -41,6 +41,7 @@
#include "ControlServer.h" #include "ControlServer.h"
#include "OpenGLComposite.h" #include "OpenGLComposite.h"
#include "GLExtensions.h" #include "GLExtensions.h"
#include "OscServer.h"
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
@@ -325,6 +326,7 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
InitializeCriticalSection(&pMutex); InitializeCriticalSection(&pMutex);
mRuntimeHost = std::make_unique<RuntimeHost>(); mRuntimeHost = std::make_unique<RuntimeHost>();
mControlServer = std::make_unique<ControlServer>(); mControlServer = std::make_unique<ControlServer>();
mOscServer = std::make_unique<OscServer>();
} }
OpenGLComposite::~OpenGLComposite() OpenGLComposite::~OpenGLComposite()
@@ -411,6 +413,8 @@ OpenGLComposite::~OpenGLComposite()
destroyTemporalHistoryResources(); destroyTemporalHistoryResources();
destroyLayerPrograms(); destroyLayerPrograms();
destroyDecodeShaderProgram(); destroyDecodeShaderProgram();
if (mOscServer)
mOscServer->Stop();
if (mControlServer) if (mControlServer)
mControlServer->Stop(); mControlServer->Stop();
@@ -843,6 +847,16 @@ bool OpenGLComposite::InitOpenGLState()
} }
mRuntimeHost->SetServerPort(mControlServer->GetPort()); 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. // Prepare the runtime shader program generated from the active shader package.
char compilerErrorMessage[1024]; char compilerErrorMessage[1024];
if (! compileDecodeShader(sizeof(compilerErrorMessage), compilerErrorMessage)) if (! compileDecodeShader(sizeof(compilerErrorMessage), compilerErrorMessage))
@@ -1163,6 +1177,9 @@ bool OpenGLComposite::Start()
bool OpenGLComposite::Stop() bool OpenGLComposite::Stop()
{ {
if (mOscServer)
mOscServer->Stop();
if (mControlServer) if (mControlServer)
mControlServer->Stop(); mControlServer->Stop();
@@ -2120,6 +2137,19 @@ bool OpenGLComposite::UpdateLayerParameterJson(const std::string& layerId, const
return true; 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) bool OpenGLComposite::ResetLayerParameters(const std::string& layerId, std::string& error)
{ {
if (!mRuntimeHost->ResetLayerParameters(layerId, error)) if (!mRuntimeHost->ResetLayerParameters(layerId, error))

View File

@@ -66,6 +66,7 @@ class PlayoutDelegate;
class CaptureDelegate; class CaptureDelegate;
class PinnedMemoryAllocator; class PinnedMemoryAllocator;
class ControlServer; class ControlServer;
class OscServer;
class OpenGLComposite class OpenGLComposite
@@ -86,6 +87,7 @@ public:
bool SetLayerBypass(const std::string& layerId, bool bypassed, 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 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 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 ResetLayerParameters(const std::string& layerId, std::string& error);
bool SaveStackPreset(const std::string& presetName, std::string& error); bool SaveStackPreset(const std::string& presetName, std::string& error);
bool LoadStackPreset(const std::string& presetName, std::string& error); bool LoadStackPreset(const std::string& presetName, std::string& error);
@@ -149,6 +151,7 @@ private:
int mViewHeight; int mViewHeight;
std::unique_ptr<RuntimeHost> mRuntimeHost; std::unique_ptr<RuntimeHost> mRuntimeHost;
std::unique_ptr<ControlServer> mControlServer; std::unique_ptr<ControlServer> mControlServer;
std::unique_ptr<OscServer> mOscServer;
struct LayerProgram struct LayerProgram
{ {

View File

@@ -0,0 +1,249 @@
#include "stdafx.h"
#include "OscServer.h"
#include <ws2tcpip.h>
#include <array>
#include <cstring>
#include <iomanip>
#include <sstream>
#include <vector>
#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<std::string> SplitAddress(const std::string& address)
{
std::vector<std::string> 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<const char*>(&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<u_short>(port));
if (bind(mSocket.get(), reinterpret_cast<sockaddr*>(&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<char, 4096> buffer = {};
while (mRunning)
{
sockaddr_in sender = {};
int senderLength = sizeof(sender);
const int byteCount = recvfrom(mSocket.get(), buffer.data(), static_cast<int>(buffer.size()), 0,
reinterpret_cast<sockaddr*>(&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<std::string> 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<const unsigned char*>(data + offset);
value = static_cast<int>((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<unsigned int>(bits);
std::memcpy(&floatValue, &unsignedBits, sizeof(floatValue));
value = static_cast<double>(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();
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "NativeSockets.h"
#include <winsock2.h>
#include <atomic>
#include <functional>
#include <string>
#include <thread>
class OscServer
{
public:
struct Callbacks
{
std::function<bool(const std::string&, const std::string&, const std::string&, std::string&)> 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<bool> mRunning;
};

View File

@@ -37,6 +37,22 @@ std::string ToLowerCopy(std::string text)
return 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<char>(std::tolower(ch)));
}
return simplified;
}
bool MatchesControlKey(const std::string& candidate, const std::string& key)
{
return candidate == key || SimplifyControlKey(candidate) == SimplifyControlKey(key);
}
std::vector<double> JsonArrayToNumbers(const JsonValue& value) std::vector<double> JsonArrayToNumbers(const JsonValue& value)
{ {
std::vector<double> numbers; std::vector<double> numbers;
@@ -871,6 +887,52 @@ bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::st
return SavePersistentState(error); return SavePersistentState(error);
} }
bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, const std::string& parameterKey, const JsonValue& newValue, std::string& error)
{
std::lock_guard<std::mutex> 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(),
[&parameterKey](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) bool RuntimeHost::ResetLayerParameters(const std::string& layerId, std::string& error)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
@@ -1109,6 +1171,8 @@ bool RuntimeHost::LoadConfig(std::string& error)
mConfig.shaderLibrary = shaderLibraryValue->asString(); mConfig.shaderLibrary = shaderLibraryValue->asString();
if (const JsonValue* serverPortValue = configJson.find("serverPort")) if (const JsonValue* serverPortValue = configJson.find("serverPort"))
mConfig.serverPort = static_cast<unsigned short>(serverPortValue->asNumber(mConfig.serverPort)); mConfig.serverPort = static_cast<unsigned short>(serverPortValue->asNumber(mConfig.serverPort));
if (const JsonValue* oscPortValue = configJson.find("oscPort"))
mConfig.oscPort = static_cast<unsigned short>(oscPortValue->asNumber(mConfig.oscPort));
if (const JsonValue* autoReloadValue = configJson.find("autoReload")) if (const JsonValue* autoReloadValue = configJson.find("autoReload"))
mConfig.autoReload = autoReloadValue->asBoolean(mConfig.autoReload); mConfig.autoReload = autoReloadValue->asBoolean(mConfig.autoReload);
if (const JsonValue* maxTemporalHistoryFramesValue = configJson.find("maxTemporalHistoryFrames")) if (const JsonValue* maxTemporalHistoryFramesValue = configJson.find("maxTemporalHistoryFrames"))
@@ -1412,6 +1476,7 @@ JsonValue RuntimeHost::BuildStateValue() const
JsonValue app = JsonValue::MakeObject(); JsonValue app = JsonValue::MakeObject();
app.set("serverPort", JsonValue(static_cast<double>(mServerPort))); app.set("serverPort", JsonValue(static_cast<double>(mServerPort)));
app.set("oscPort", JsonValue(static_cast<double>(mConfig.oscPort)));
app.set("autoReload", JsonValue(mAutoReloadEnabled)); app.set("autoReload", JsonValue(mAutoReloadEnabled));
app.set("maxTemporalHistoryFrames", JsonValue(static_cast<double>(mConfig.maxTemporalHistoryFrames))); app.set("maxTemporalHistoryFrames", JsonValue(static_cast<double>(mConfig.maxTemporalHistoryFrames)));
app.set("enableExternalKeying", JsonValue(mConfig.enableExternalKeying)); app.set("enableExternalKeying", JsonValue(mConfig.enableExternalKeying));

View File

@@ -28,6 +28,7 @@ public:
bool SetLayerBypass(const std::string& layerId, bool bypassed, 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 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 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 ResetLayerParameters(const std::string& layerId, std::string& error);
bool SaveStackPreset(const std::string& presetName, std::string& error) const; bool SaveStackPreset(const std::string& presetName, std::string& error) const;
bool LoadStackPreset(const std::string& presetName, std::string& error); 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& GetDocsRoot() const { return mDocsRoot; }
const std::filesystem::path& GetRuntimeRoot() const { return mRuntimeRoot; } const std::filesystem::path& GetRuntimeRoot() const { return mRuntimeRoot; }
unsigned short GetServerPort() const { return mServerPort; } unsigned short GetServerPort() const { return mServerPort; }
unsigned short GetOscPort() const { return mConfig.oscPort; }
unsigned GetMaxTemporalHistoryFrames() const { return mConfig.maxTemporalHistoryFrames; } unsigned GetMaxTemporalHistoryFrames() const { return mConfig.maxTemporalHistoryFrames; }
bool ExternalKeyingEnabled() const { return mConfig.enableExternalKeying; } bool ExternalKeyingEnabled() const { return mConfig.enableExternalKeying; }
const std::string& GetVideoFormat() const { return mConfig.videoFormat; } const std::string& GetVideoFormat() const { return mConfig.videoFormat; }
@@ -60,6 +62,7 @@ private:
{ {
std::string shaderLibrary = "shaders"; std::string shaderLibrary = "shaders";
unsigned short serverPort = 8080; unsigned short serverPort = 8080;
unsigned short oscPort = 9000;
bool autoReload = true; bool autoReload = true;
unsigned maxTemporalHistoryFrames = 4; unsigned maxTemporalHistoryFrames = 4;
bool enableExternalKeying = false; bool enableExternalKeying = false;

View File

@@ -1,6 +1,7 @@
{ {
"shaderLibrary": "shaders", "shaderLibrary": "shaders",
"serverPort": 8080, "serverPort": 8080,
"oscPort": 9000,
"videoFormat": "1080p", "videoFormat": "1080p",
"frameRate": "59.94", "frameRate": "59.94",
"autoReload": true, "autoReload": true,

65
docs/OSC_CONTROL.md Normal file
View File

@@ -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:<oscPort>
```
This keeps the control surface local to the machine running Video Shader Toys.

View File

@@ -359,6 +359,8 @@ components:
properties: properties:
serverPort: serverPort:
type: number type: number
oscPort:
type: number
autoReload: autoReload:
type: boolean type: boolean
maxTemporalHistoryFrames: maxTemporalHistoryFrames:

View File

@@ -9,6 +9,7 @@ Tracked files:
Packaged documentation: Packaged documentation:
- `../docs/openapi.yaml`: OpenAPI/Swagger spec for the local control API. - `../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:<serverPort>/docs`: Swagger UI page served by the native control server. - `http://127.0.0.1:<serverPort>/docs`: Swagger UI page served by the native control server.
Generated files: Generated files: