Added OSC
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
249
apps/LoopThroughWithOpenGLCompositing/OscServer.cpp
Normal file
249
apps/LoopThroughWithOpenGLCompositing/OscServer.cpp
Normal 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();
|
||||||
|
}
|
||||||
48
apps/LoopThroughWithOpenGLCompositing/OscServer.h
Normal file
48
apps/LoopThroughWithOpenGLCompositing/OscServer.h
Normal 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;
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
[¶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)
|
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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
65
docs/OSC_CONTROL.md
Normal 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.
|
||||||
@@ -359,6 +359,8 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
serverPort:
|
serverPort:
|
||||||
type: number
|
type: number
|
||||||
|
oscPort:
|
||||||
|
type: number
|
||||||
autoReload:
|
autoReload:
|
||||||
type: boolean
|
type: boolean
|
||||||
maxTemporalHistoryFrames:
|
maxTemporalHistoryFrames:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user