Clean up
Some checks failed
CI / Native Windows Build And Tests (push) Failing after 16s
CI / React UI Build (push) Successful in 38s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
2026-05-18 14:19:29 +10:00
parent 3ffb562ff7
commit f461a05c65
222 changed files with 0 additions and 45423 deletions

View File

@@ -0,0 +1,12 @@
#pragma once
#include <string>
namespace RenderCadenceCompositor
{
struct ControlActionResult
{
bool ok = false;
std::string error;
};
}

View File

@@ -0,0 +1,127 @@
#include "RuntimeControlCommand.h"
namespace RenderCadenceCompositor
{
namespace
{
const JsonValue* RequireObjectField(const JsonValue& root, const char* fieldName, std::string& error)
{
const JsonValue* field = root.find(fieldName);
if (!field)
error = std::string("Request field '") + fieldName + "' is required.";
return field;
}
bool RequireStringField(const JsonValue& root, const char* fieldName, std::string& value, std::string& error)
{
const JsonValue* field = RequireObjectField(root, fieldName, error);
if (!field)
return false;
if (!field->isString() || field->asString().empty())
{
error = std::string("Request field '") + fieldName + "' must be a non-empty string.";
return false;
}
value = field->asString();
return true;
}
bool RequireBoolField(const JsonValue& root, const char* fieldName, bool& value, std::string& error)
{
const JsonValue* field = RequireObjectField(root, fieldName, error);
if (!field)
return false;
if (!field->isBoolean())
{
error = std::string("Request field '") + fieldName + "' must be a boolean.";
return false;
}
value = field->asBoolean();
return true;
}
bool RequireIntegerField(const JsonValue& root, const char* fieldName, int& value, std::string& error)
{
const JsonValue* field = RequireObjectField(root, fieldName, error);
if (!field)
return false;
if (!field->isNumber())
{
error = std::string("Request field '") + fieldName + "' must be a number.";
return false;
}
value = static_cast<int>(field->asNumber());
return true;
}
}
bool ParseRuntimeControlCommand(
const std::string& path,
const std::string& body,
RuntimeControlCommand& command,
std::string& error)
{
command = RuntimeControlCommand();
JsonValue root;
std::string parseError;
if (!ParseJson(body.empty() ? "{}" : body, root, parseError) || !root.isObject())
{
error = parseError.empty() ? "Request body must be a JSON object." : parseError;
return false;
}
if (path == "/api/layers/add")
{
command.type = RuntimeControlCommandType::AddLayer;
return RequireStringField(root, "shaderId", command.shaderId, error);
}
if (path == "/api/layers/remove")
{
command.type = RuntimeControlCommandType::RemoveLayer;
return RequireStringField(root, "layerId", command.layerId, error);
}
if (path == "/api/layers/reorder")
{
command.type = RuntimeControlCommandType::ReorderLayer;
return RequireStringField(root, "layerId", command.layerId, error)
&& RequireIntegerField(root, "targetIndex", command.targetIndex, error);
}
if (path == "/api/layers/set-bypass")
{
command.type = RuntimeControlCommandType::SetLayerBypass;
return RequireStringField(root, "layerId", command.layerId, error)
&& RequireBoolField(root, "bypass", command.bypass, error);
}
if (path == "/api/layers/set-shader")
{
command.type = RuntimeControlCommandType::SetLayerShader;
return RequireStringField(root, "layerId", command.layerId, error)
&& RequireStringField(root, "shaderId", command.shaderId, error);
}
if (path == "/api/layers/update-parameter")
{
command.type = RuntimeControlCommandType::UpdateLayerParameter;
const JsonValue* value = nullptr;
if (!RequireStringField(root, "layerId", command.layerId, error)
|| !RequireStringField(root, "parameterId", command.parameterId, error))
{
return false;
}
value = RequireObjectField(root, "value", error);
if (!value)
return false;
command.value = *value;
return true;
}
if (path == "/api/layers/reset-parameters")
{
command.type = RuntimeControlCommandType::ResetLayerParameters;
return RequireStringField(root, "layerId", command.layerId, error);
}
command.type = RuntimeControlCommandType::Unsupported;
error = "Endpoint is not implemented in RenderCadenceCompositor yet.";
return false;
}
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include "RuntimeJson.h"
#include <string>
namespace RenderCadenceCompositor
{
enum class RuntimeControlCommandType
{
AddLayer,
RemoveLayer,
ReorderLayer,
SetLayerBypass,
SetLayerShader,
UpdateLayerParameter,
ResetLayerParameters,
Unsupported
};
struct RuntimeControlCommand
{
RuntimeControlCommandType type = RuntimeControlCommandType::Unsupported;
std::string layerId;
std::string shaderId;
std::string parameterId;
int targetIndex = 0;
bool bypass = false;
JsonValue value;
};
bool ParseRuntimeControlCommand(
const std::string& path,
const std::string& body,
RuntimeControlCommand& command,
std::string& error);
}

View File

@@ -0,0 +1,324 @@
#pragma once
#include "../app/AppConfig.h"
#include "../app/AppConfigProvider.h"
#include "../json/JsonWriter.h"
#include "../runtime/RuntimeLayerModel.h"
#include "../runtime/SupportedShaderCatalog.h"
#include "../telemetry/CadenceTelemetryJson.h"
#include <cstdint>
#include <string>
#include <vector>
namespace RenderCadenceCompositor
{
struct RuntimeStateJsonInput
{
const AppConfig& config;
const CadenceTelemetrySnapshot& telemetry;
unsigned short serverPort = 0;
bool videoOutputEnabled = false;
std::string videoOutputStatus;
const SupportedShaderCatalog& shaderCatalog;
const RuntimeLayerModelSnapshot& runtimeLayers;
};
inline void WriteVideoIoStatusJson(JsonWriter& writer, const RuntimeStateJsonInput& input)
{
writer.BeginObject();
writer.KeyString("backend", "decklink");
writer.KeyNull("modelName");
writer.KeyBool("supportsInternalKeying", false);
writer.KeyBool("supportsExternalKeying", false);
writer.KeyBool("keyerInterfaceAvailable", false);
writer.KeyBool("externalKeyingRequested", input.config.deckLink.externalKeyingEnabled);
writer.KeyBool("externalKeyingActive", input.videoOutputEnabled && input.config.deckLink.externalKeyingEnabled);
writer.KeyString("statusMessage", input.videoOutputStatus);
writer.EndObject();
}
inline void OutputDimensions(const RuntimeStateJsonInput& input, unsigned& width, unsigned& height)
{
VideoFormatDimensions(input.config.outputVideoFormat, width, height);
}
inline const char* ShaderParameterTypeName(ShaderParameterType type)
{
switch (type)
{
case ShaderParameterType::Float: return "float";
case ShaderParameterType::Vec2: return "vec2";
case ShaderParameterType::Color: return "color";
case ShaderParameterType::Boolean: return "bool";
case ShaderParameterType::Enum: return "enum";
case ShaderParameterType::Text: return "text";
case ShaderParameterType::Trigger: return "trigger";
}
return "unknown";
}
inline void WriteNumberArray(JsonWriter& writer, const std::vector<double>& values)
{
writer.BeginArray();
for (double value : values)
writer.Double(value);
writer.EndArray();
}
inline void WriteDefaultParameterValue(JsonWriter& writer, const ShaderParameterDefinition& parameter)
{
switch (parameter.type)
{
case ShaderParameterType::Boolean:
writer.Bool(parameter.defaultBoolean);
return;
case ShaderParameterType::Enum:
writer.String(parameter.defaultEnumValue);
return;
case ShaderParameterType::Text:
writer.String(parameter.defaultTextValue);
return;
case ShaderParameterType::Trigger:
writer.Double(0.0);
return;
case ShaderParameterType::Float:
writer.Double(parameter.defaultNumbers.empty() ? 0.0 : parameter.defaultNumbers.front());
return;
case ShaderParameterType::Vec2:
case ShaderParameterType::Color:
WriteNumberArray(writer, parameter.defaultNumbers);
return;
}
writer.Null();
}
inline void WriteParameterValue(JsonWriter& writer, const ShaderParameterDefinition& parameter, const ShaderParameterValue& value)
{
switch (parameter.type)
{
case ShaderParameterType::Boolean:
writer.Bool(value.booleanValue);
return;
case ShaderParameterType::Enum:
writer.String(value.enumValue);
return;
case ShaderParameterType::Text:
writer.String(value.textValue);
return;
case ShaderParameterType::Trigger:
case ShaderParameterType::Float:
writer.Double(value.numberValues.empty() ? 0.0 : value.numberValues.front());
return;
case ShaderParameterType::Vec2:
case ShaderParameterType::Color:
WriteNumberArray(writer, value.numberValues);
return;
}
writer.Null();
}
inline void WriteTemporalJson(JsonWriter& writer, const TemporalSettings& temporal)
{
writer.BeginObject();
writer.KeyBool("enabled", temporal.enabled);
writer.KeyString("historySource", "none");
writer.KeyUInt("requestedHistoryLength", temporal.requestedHistoryLength);
writer.KeyUInt("effectiveHistoryLength", temporal.effectiveHistoryLength);
writer.EndObject();
}
inline void WriteFeedbackJson(JsonWriter& writer, const FeedbackSettings& feedback)
{
writer.BeginObject();
writer.KeyBool("enabled", feedback.enabled);
writer.KeyString("writePass", feedback.writePassId);
writer.EndObject();
}
inline const char* RuntimeLayerBuildStateName(RuntimeLayerBuildState state)
{
switch (state)
{
case RuntimeLayerBuildState::Pending: return "pending";
case RuntimeLayerBuildState::Ready: return "ready";
case RuntimeLayerBuildState::Failed: return "failed";
}
return "unknown";
}
inline void WriteParameterDefinitionJson(JsonWriter& writer, const ShaderParameterDefinition& parameter, const ShaderParameterValue* value)
{
writer.BeginObject();
writer.KeyString("id", parameter.id);
writer.KeyString("label", parameter.label.empty() ? parameter.id : parameter.label);
writer.KeyString("description", parameter.description);
writer.KeyString("type", ShaderParameterTypeName(parameter.type));
writer.Key("defaultValue");
WriteDefaultParameterValue(writer, parameter);
writer.Key("value");
if (value)
WriteParameterValue(writer, parameter, *value);
else
WriteDefaultParameterValue(writer, parameter);
if (!parameter.minNumbers.empty())
{
writer.Key("min");
WriteNumberArray(writer, parameter.minNumbers);
}
if (!parameter.maxNumbers.empty())
{
writer.Key("max");
WriteNumberArray(writer, parameter.maxNumbers);
}
if (!parameter.stepNumbers.empty())
{
writer.Key("step");
WriteNumberArray(writer, parameter.stepNumbers);
}
if (parameter.type == ShaderParameterType::Enum)
{
writer.Key("options");
writer.BeginArray();
for (const ShaderParameterOption& option : parameter.enumOptions)
{
writer.BeginObject();
writer.KeyString("value", option.value);
writer.KeyString("label", option.label.empty() ? option.value : option.label);
writer.EndObject();
}
writer.EndArray();
}
if (parameter.type == ShaderParameterType::Text)
{
writer.KeyUInt("maxLength", parameter.maxLength);
if (!parameter.fontId.empty())
writer.KeyString("font", parameter.fontId);
}
writer.EndObject();
}
inline void WriteLayersJson(JsonWriter& writer, const RuntimeStateJsonInput& input)
{
writer.BeginArray();
for (const RuntimeLayerReadModel& layer : input.runtimeLayers.displayLayers)
{
const ShaderPackage* shaderPackage = input.shaderCatalog.FindPackage(layer.shaderId);
writer.BeginObject();
writer.KeyString("id", layer.id);
writer.KeyString("shaderId", layer.shaderId);
writer.KeyString("shaderName", layer.shaderName);
writer.KeyBool("bypass", layer.bypass);
writer.KeyString("buildState", RuntimeLayerBuildStateName(layer.buildState));
writer.KeyBool("renderReady", layer.renderReady);
writer.KeyString("message", layer.message);
writer.Key("temporal");
if (shaderPackage)
WriteTemporalJson(writer, shaderPackage->temporal);
else
WriteTemporalJson(writer, TemporalSettings());
writer.Key("feedback");
if (shaderPackage)
WriteFeedbackJson(writer, shaderPackage->feedback);
else
WriteFeedbackJson(writer, FeedbackSettings());
writer.Key("parameters");
writer.BeginArray();
for (const ShaderParameterDefinition& parameter : layer.parameterDefinitions)
{
const auto valueIt = layer.parameterValues.find(parameter.id);
WriteParameterDefinitionJson(writer, parameter, valueIt == layer.parameterValues.end() ? nullptr : &valueIt->second);
}
writer.EndArray();
writer.EndObject();
}
writer.EndArray();
}
inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
{
JsonWriter writer;
writer.BeginObject();
writer.Key("app");
writer.BeginObject();
writer.KeyUInt("serverPort", input.serverPort);
writer.KeyUInt("oscPort", input.config.oscPort);
writer.KeyString("oscBindAddress", input.config.oscBindAddress);
writer.KeyDouble("oscSmoothing", input.config.oscSmoothing);
writer.KeyBool("autoReload", input.config.autoReload);
writer.KeyUInt("maxTemporalHistoryFrames", static_cast<uint64_t>(input.config.maxTemporalHistoryFrames));
writer.KeyDouble("previewFps", input.config.previewFps);
writer.KeyBool("enableExternalKeying", input.config.deckLink.externalKeyingEnabled);
writer.KeyString("inputVideoFormat", input.config.inputVideoFormat);
writer.KeyString("inputFrameRate", input.config.inputFrameRate);
writer.KeyString("outputVideoFormat", input.config.outputVideoFormat);
writer.KeyString("outputFrameRate", input.config.outputFrameRate);
writer.EndObject();
writer.Key("runtime");
writer.BeginObject();
writer.KeyUInt("layerCount", static_cast<uint64_t>(input.runtimeLayers.displayLayers.size()));
writer.KeyBool("compileSucceeded", input.runtimeLayers.compileSucceeded);
writer.KeyString("compileMessage", input.runtimeLayers.compileMessage);
writer.EndObject();
writer.Key("video");
writer.BeginObject();
unsigned outputWidth = 0;
unsigned outputHeight = 0;
OutputDimensions(input, outputWidth, outputHeight);
writer.KeyBool("hasSignal", input.videoOutputEnabled);
writer.KeyUInt("width", outputWidth);
writer.KeyUInt("height", outputHeight);
writer.KeyString("modeName", input.config.outputVideoFormat + " output-only");
writer.EndObject();
writer.Key("decklink");
WriteVideoIoStatusJson(writer, input);
writer.Key("videoIO");
WriteVideoIoStatusJson(writer, input);
writer.Key("performance");
writer.BeginObject();
writer.KeyDouble("frameBudgetMs", FrameDurationMillisecondsFromRateString(input.config.outputFrameRate));
writer.KeyDouble("renderMs", input.telemetry.renderFrameMilliseconds);
writer.KeyNull("smoothedRenderMs");
writer.KeyDouble("budgetUsedPercent", input.telemetry.renderFrameBudgetUsedPercent);
writer.KeyNull("completionIntervalMs");
writer.KeyNull("smoothedCompletionIntervalMs");
writer.KeyNull("maxCompletionIntervalMs");
writer.KeyUInt("lateFrameCount", input.telemetry.displayedLate);
writer.KeyUInt("droppedFrameCount", input.telemetry.dropped);
writer.KeyNull("flushedFrameCount");
writer.Key("cadence");
WriteCadenceTelemetryJson(writer, input.telemetry);
writer.EndObject();
writer.KeyNull("backendPlayout");
writer.KeyNull("runtimeEvents");
writer.Key("shaders");
writer.BeginArray();
for (const SupportedShaderSummary& shader : input.shaderCatalog.Shaders())
{
writer.BeginObject();
writer.KeyString("id", shader.id);
writer.KeyString("name", shader.name);
writer.KeyString("description", shader.description);
writer.KeyString("category", shader.category);
writer.KeyBool("available", true);
writer.KeyNull("error");
writer.EndObject();
}
writer.EndArray();
writer.Key("stackPresets");
writer.BeginArray();
writer.EndArray();
writer.Key("layers");
WriteLayersJson(writer, input);
writer.EndObject();
return writer.StringValue();
}
}

View File

@@ -0,0 +1,293 @@
#include "HttpControlServer.h"
#include "../logging/Logger.h"
#include <ws2tcpip.h>
#include <algorithm>
#include <sstream>
namespace RenderCadenceCompositor
{
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;
}
}
UniqueSocket::UniqueSocket(SOCKET socket) :
mSocket(socket)
{
}
UniqueSocket::~UniqueSocket()
{
reset();
}
UniqueSocket::UniqueSocket(UniqueSocket&& other) noexcept :
mSocket(other.release())
{
}
UniqueSocket& UniqueSocket::operator=(UniqueSocket&& other) noexcept
{
if (this != &other)
reset(other.release());
return *this;
}
SOCKET UniqueSocket::release()
{
const SOCKET socket = mSocket;
mSocket = INVALID_SOCKET;
return socket;
}
void UniqueSocket::reset(SOCKET socket)
{
if (valid())
closesocket(mSocket);
mSocket = socket;
}
HttpControlServer::~HttpControlServer()
{
Stop();
}
bool HttpControlServer::Start(
const std::filesystem::path& uiRoot,
const std::filesystem::path& docsRoot,
HttpControlServerConfig config,
HttpControlServerCallbacks callbacks,
std::string& error)
{
Stop();
if (!InitializeWinsock(error))
return false;
mWinsockStarted = true;
mUiRoot = uiRoot;
mDocsRoot = docsRoot;
mConfig = config;
mCallbacks = std::move(callbacks);
mListenSocket.reset(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP));
if (!mListenSocket.valid())
{
error = "Could not create HTTP control server socket.";
Stop();
return false;
}
u_long nonBlocking = 1;
ioctlsocket(mListenSocket.get(), FIONBIO, &nonBlocking);
sockaddr_in address = {};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bool bound = false;
for (unsigned short offset = 0; offset < mConfig.portSearchCount; ++offset)
{
address.sin_port = htons(static_cast<u_short>(mConfig.preferredPort + offset));
if (bind(mListenSocket.get(), reinterpret_cast<sockaddr*>(&address), sizeof(address)) == 0)
{
mPort = static_cast<unsigned short>(mConfig.preferredPort + offset);
bound = true;
break;
}
}
if (!bound)
{
error = "Could not bind HTTP control server to loopback.";
Stop();
return false;
}
if (listen(mListenSocket.get(), SOMAXCONN) != 0)
{
error = "Could not listen on HTTP control server socket.";
Stop();
return false;
}
mRunning.store(true, std::memory_order_release);
mThread = std::thread([this]() { ThreadMain(); });
Log("http", "HTTP control server listening on http://127.0.0.1:" + std::to_string(mPort));
return true;
}
void HttpControlServer::Stop()
{
mRunning.store(false, std::memory_order_release);
mListenSocket.reset();
if (mThread.joinable())
mThread.join();
std::vector<std::thread> clientThreads;
{
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
clientThreads.swap(mClientThreads);
for (std::thread& thread : mFinishedClientThreads)
clientThreads.push_back(std::move(thread));
mFinishedClientThreads.clear();
}
for (std::thread& thread : clientThreads)
{
if (thread.joinable())
thread.join();
}
if (mWinsockStarted)
{
WSACleanup();
mWinsockStarted = false;
}
mPort = 0;
}
HttpControlServer::HttpResponse HttpControlServer::RouteRequestForTest(const HttpRequest& request) const
{
return RouteRequest(request);
}
void HttpControlServer::SetCallbacksForTest(HttpControlServerCallbacks callbacks)
{
mCallbacks = std::move(callbacks);
}
void HttpControlServer::SetRootsForTest(const std::filesystem::path& uiRoot, const std::filesystem::path& docsRoot)
{
mUiRoot = uiRoot;
mDocsRoot = docsRoot;
}
void HttpControlServer::ThreadMain()
{
while (mRunning.load(std::memory_order_acquire))
{
JoinFinishedClientThreads();
TryAcceptClient();
std::this_thread::sleep_for(mConfig.idleSleep);
}
}
bool HttpControlServer::TryAcceptClient()
{
sockaddr_in clientAddress = {};
int addressSize = sizeof(clientAddress);
UniqueSocket clientSocket(accept(mListenSocket.get(), reinterpret_cast<sockaddr*>(&clientAddress), &addressSize));
if (!clientSocket.valid())
return false;
return HandleClient(std::move(clientSocket));
}
bool HttpControlServer::HandleClient(UniqueSocket clientSocket)
{
char buffer[16384];
const int received = recv(clientSocket.get(), buffer, sizeof(buffer), 0);
if (received <= 0)
return false;
HttpRequest request;
if (!ParseHttpRequest(std::string(buffer, buffer + received), request))
return SendResponse(clientSocket.get(), TextResponse("400 Bad Request", "Bad Request"));
if (request.path == "/ws")
return HandleWebSocketClient(std::move(clientSocket), request);
return SendResponse(clientSocket.get(), RouteRequest(request));
}
bool HttpControlServer::SendResponse(SOCKET clientSocket, const HttpResponse& response) const
{
std::ostringstream stream;
stream << "HTTP/1.1 " << response.status << "\r\n"
<< "Content-Type: " << response.contentType << "\r\n"
<< "Content-Length: " << response.body.size() << "\r\n"
<< "Access-Control-Allow-Origin: *\r\n"
<< "Connection: close\r\n\r\n"
<< response.body;
const std::string payload = stream.str();
return send(clientSocket, payload.c_str(), static_cast<int>(payload.size()), 0) == static_cast<int>(payload.size());
}
HttpControlServer::HttpResponse HttpControlServer::RouteRequest(const HttpRequest& request) const
{
if (request.method == "GET")
return ServeGet(request);
if (request.method == "POST")
return ServePost(request);
if (request.method == "OPTIONS")
return TextResponse("204 No Content", std::string());
return TextResponse("404 Not Found", "Not Found");
}
bool HttpControlServer::ParseHttpRequest(const std::string& rawRequest, HttpRequest& request)
{
const std::size_t requestLineEnd = rawRequest.find("\r\n");
if (requestLineEnd == std::string::npos)
return false;
const std::string requestLine = rawRequest.substr(0, requestLineEnd);
const std::size_t methodEnd = requestLine.find(' ');
if (methodEnd == std::string::npos)
return false;
const std::size_t pathEnd = requestLine.find(' ', methodEnd + 1);
if (pathEnd == std::string::npos)
return false;
request.method = requestLine.substr(0, methodEnd);
request.path = requestLine.substr(methodEnd + 1, pathEnd - methodEnd - 1);
request.headers.clear();
const std::size_t queryStart = request.path.find('?');
if (queryStart != std::string::npos)
request.path = request.path.substr(0, queryStart);
const std::size_t headersStart = requestLineEnd + 2;
const std::size_t bodySeparator = rawRequest.find("\r\n\r\n", headersStart);
const std::size_t headersEnd = bodySeparator == std::string::npos ? rawRequest.size() : bodySeparator;
for (std::size_t lineStart = headersStart; lineStart < headersEnd;)
{
const std::size_t lineEnd = rawRequest.find("\r\n", lineStart);
const std::size_t currentLineEnd = lineEnd == std::string::npos ? headersEnd : (std::min)(lineEnd, headersEnd);
const std::string line = rawRequest.substr(lineStart, currentLineEnd - lineStart);
const std::size_t separator = line.find(':');
if (separator != std::string::npos)
{
const std::string key = ToLower(line.substr(0, separator));
std::string value = line.substr(separator + 1);
const std::size_t first = value.find_first_not_of(" \t");
const std::size_t last = value.find_last_not_of(" \t");
request.headers[key] = first == std::string::npos ? std::string() : value.substr(first, last - first + 1);
}
if (lineEnd == std::string::npos || lineEnd >= headersEnd)
break;
lineStart = lineEnd + 2;
}
request.body = bodySeparator == std::string::npos ? std::string() : rawRequest.substr(bodySeparator + 4);
return !request.method.empty() && !request.path.empty();
}
}

View File

@@ -0,0 +1,135 @@
#pragma once
#include "ControlActionResult.h"
#include <winsock2.h>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <functional>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace RenderCadenceCompositor
{
struct HttpControlServerConfig
{
unsigned short preferredPort = 8080;
unsigned short portSearchCount = 20;
std::chrono::milliseconds idleSleep = std::chrono::milliseconds(10);
};
struct HttpControlServerCallbacks
{
std::function<std::string()> getStateJson;
std::function<ControlActionResult(const std::string&)> addLayer;
std::function<ControlActionResult(const std::string&)> removeLayer;
std::function<ControlActionResult(const std::string&, const std::string&)> executePost;
};
class UniqueSocket
{
public:
explicit UniqueSocket(SOCKET socket = INVALID_SOCKET);
~UniqueSocket();
UniqueSocket(const UniqueSocket&) = delete;
UniqueSocket& operator=(const UniqueSocket&) = delete;
UniqueSocket(UniqueSocket&& other) noexcept;
UniqueSocket& operator=(UniqueSocket&& other) noexcept;
SOCKET get() const { return mSocket; }
bool valid() const { return mSocket != INVALID_SOCKET; }
SOCKET release();
void reset(SOCKET socket = INVALID_SOCKET);
private:
SOCKET mSocket = INVALID_SOCKET;
};
class HttpControlServer
{
public:
struct HttpRequest
{
std::string method;
std::string path;
std::map<std::string, std::string> headers;
std::string body;
};
struct HttpResponse
{
std::string status;
std::string contentType;
std::string body;
};
HttpControlServer() = default;
~HttpControlServer();
HttpControlServer(const HttpControlServer&) = delete;
HttpControlServer& operator=(const HttpControlServer&) = delete;
bool Start(
const std::filesystem::path& uiRoot,
const std::filesystem::path& docsRoot,
HttpControlServerConfig config,
HttpControlServerCallbacks callbacks,
std::string& error);
void Stop();
bool IsRunning() const { return mRunning.load(std::memory_order_acquire); }
unsigned short Port() const { return mPort; }
void SetCallbacksForTest(HttpControlServerCallbacks callbacks);
void SetRootsForTest(const std::filesystem::path& uiRoot, const std::filesystem::path& docsRoot);
HttpResponse RouteRequestForTest(const HttpRequest& request) const;
static bool ParseHttpRequest(const std::string& rawRequest, HttpRequest& request);
static std::string WebSocketAcceptKey(const std::string& clientKey);
private:
void ThreadMain();
bool TryAcceptClient();
bool HandleClient(UniqueSocket clientSocket);
bool HandleWebSocketClient(UniqueSocket clientSocket, const HttpRequest& request);
void WebSocketClientMain(UniqueSocket clientSocket);
void JoinFinishedClientThreads();
bool SendResponse(SOCKET clientSocket, const HttpResponse& response) const;
HttpResponse RouteRequest(const HttpRequest& request) const;
HttpResponse ServeGet(const HttpRequest& request) const;
HttpResponse ServePost(const HttpRequest& request) const;
HttpResponse ServeOpenApiSpec() const;
HttpResponse ServeSwaggerDocs() const;
HttpResponse ServeUiAsset(const std::string& relativePath) const;
std::string LoadTextFile(const std::filesystem::path& path) const;
static HttpResponse JsonResponse(const std::string& status, const std::string& body);
static HttpResponse TextResponse(const std::string& status, const std::string& body);
static HttpResponse HtmlResponse(const std::string& status, const std::string& body);
static std::string ActionResponse(bool ok, const std::string& error = std::string());
static bool SendWebSocketText(SOCKET clientSocket, const std::string& text);
static std::string GuessContentType(const std::filesystem::path& path);
static bool IsSafeRelativePath(const std::filesystem::path& path);
static std::string ToLower(std::string text);
std::filesystem::path mUiRoot;
std::filesystem::path mDocsRoot;
HttpControlServerConfig mConfig;
HttpControlServerCallbacks mCallbacks;
UniqueSocket mListenSocket;
std::thread mThread;
std::mutex mClientThreadsMutex;
std::vector<std::thread> mClientThreads;
std::vector<std::thread> mFinishedClientThreads;
std::atomic<bool> mRunning{ false };
unsigned short mPort = 0;
bool mWinsockStarted = false;
};
}

View File

@@ -0,0 +1,203 @@
#include "HttpControlServer.h"
#include "../json/JsonWriter.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <sstream>
namespace RenderCadenceCompositor
{
namespace
{
bool IsKnownPostEndpoint(const std::string& path)
{
return path == "/api/layers/add"
|| path == "/api/layers/remove"
|| path == "/api/layers/move"
|| path == "/api/layers/reorder"
|| path == "/api/layers/set-bypass"
|| path == "/api/layers/set-shader"
|| path == "/api/layers/update-parameter"
|| path == "/api/layers/reset-parameters"
|| path == "/api/stack-presets/save"
|| path == "/api/stack-presets/load"
|| path == "/api/reload"
|| path == "/api/screenshot";
}
}
HttpControlServer::HttpResponse HttpControlServer::ServeGet(const HttpRequest& request) const
{
if (request.path == "/api/state")
return JsonResponse("200 OK", mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}");
if (request.path == "/openapi.yaml" || request.path == "/docs/openapi.yaml")
return ServeOpenApiSpec();
if (request.path == "/docs" || request.path == "/docs/")
return ServeSwaggerDocs();
if (request.path == "/" || request.path == "/index.html")
return ServeUiAsset("index.html");
if (request.path.rfind("/assets/", 0) == 0)
return ServeUiAsset(request.path.substr(1));
if (request.path.size() > 1)
{
const HttpResponse asset = ServeUiAsset(request.path.substr(1));
if (asset.status != "404 Not Found")
return asset;
}
return ServeUiAsset("index.html");
}
HttpControlServer::HttpResponse HttpControlServer::ServePost(const HttpRequest& request) const
{
if (!IsKnownPostEndpoint(request.path))
return TextResponse("404 Not Found", "Not Found");
if (mCallbacks.executePost)
{
const ControlActionResult result = mCallbacks.executePost(request.path, request.body);
return JsonResponse(result.ok ? "200 OK" : "400 Bad Request", ActionResponse(result.ok, result.error));
}
if (request.path == "/api/layers/add" && mCallbacks.addLayer)
{
const ControlActionResult result = mCallbacks.addLayer(request.body);
return JsonResponse(result.ok ? "200 OK" : "400 Bad Request", ActionResponse(result.ok, result.error));
}
if (request.path == "/api/layers/remove" && mCallbacks.removeLayer)
{
const ControlActionResult result = mCallbacks.removeLayer(request.body);
return JsonResponse(result.ok ? "200 OK" : "400 Bad Request", ActionResponse(result.ok, result.error));
}
return {
"400 Bad Request",
"application/json",
ActionResponse(false, "Endpoint is not implemented in RenderCadenceCompositor yet.")
};
}
HttpControlServer::HttpResponse HttpControlServer::ServeOpenApiSpec() const
{
const std::filesystem::path path = mDocsRoot / "openapi.yaml";
const std::string body = LoadTextFile(path);
return body.empty()
? TextResponse("404 Not Found", "OpenAPI spec not found")
: HttpResponse{ "200 OK", GuessContentType(path), body };
}
HttpControlServer::HttpResponse HttpControlServer::ServeSwaggerDocs() const
{
std::ostringstream html;
html << "<!doctype html>\n"
<< "<html><head><meta charset=\"utf-8\"><title>Video Shader Toys API Docs</title>\n"
<< "<link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\"></head>\n"
<< "<body><div id=\"swagger-ui\"></div>\n"
<< "<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>\n"
<< "<script>SwaggerUIBundle({url:'/docs/openapi.yaml',dom_id:'#swagger-ui'});</script>\n"
<< "</body></html>\n";
return { "200 OK", "text/html", html.str() };
}
HttpControlServer::HttpResponse HttpControlServer::ServeUiAsset(const std::string& relativePath) const
{
if (mUiRoot.empty())
return TextResponse("404 Not Found", "UI root is not configured");
const std::filesystem::path sanitizedPath = std::filesystem::path(relativePath).lexically_normal();
if (!IsSafeRelativePath(sanitizedPath))
return TextResponse("404 Not Found", "Not Found");
const std::filesystem::path path = mUiRoot / sanitizedPath;
const std::string body = LoadTextFile(path);
if (body.empty())
return TextResponse("404 Not Found", "Not Found");
return { "200 OK", GuessContentType(path), body };
}
std::string HttpControlServer::LoadTextFile(const std::filesystem::path& path) const
{
std::ifstream input(path, std::ios::binary);
if (!input)
return std::string();
std::ostringstream buffer;
buffer << input.rdbuf();
return buffer.str();
}
HttpControlServer::HttpResponse HttpControlServer::JsonResponse(const std::string& status, const std::string& body)
{
return { status, "application/json", body };
}
HttpControlServer::HttpResponse HttpControlServer::TextResponse(const std::string& status, const std::string& body)
{
return { status, "text/plain", body };
}
HttpControlServer::HttpResponse HttpControlServer::HtmlResponse(const std::string& status, const std::string& body)
{
return { status, "text/html", body };
}
std::string HttpControlServer::ActionResponse(bool ok, const std::string& error)
{
JsonWriter writer;
writer.BeginObject();
writer.KeyBool("ok", ok);
if (!error.empty())
writer.KeyString("error", error);
writer.EndObject();
return writer.StringValue();
}
std::string HttpControlServer::GuessContentType(const std::filesystem::path& path)
{
const std::string extension = ToLower(path.extension().string());
if (extension == ".yaml" || extension == ".yml")
return "application/yaml";
if (extension == ".json")
return "application/json";
if (extension == ".js" || extension == ".mjs")
return "text/javascript";
if (extension == ".css")
return "text/css";
if (extension == ".html" || extension == ".htm")
return "text/html";
if (extension == ".svg")
return "image/svg+xml";
if (extension == ".png")
return "image/png";
if (extension == ".jpg" || extension == ".jpeg")
return "image/jpeg";
if (extension == ".ico")
return "image/x-icon";
if (extension == ".map")
return "application/json";
return "text/plain";
}
bool HttpControlServer::IsSafeRelativePath(const std::filesystem::path& path)
{
if (path.empty() || path.is_absolute())
return false;
for (const std::filesystem::path& part : path)
{
if (part == "..")
return false;
}
return true;
}
std::string HttpControlServer::ToLower(std::string text)
{
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
return text;
}
}

View File

@@ -0,0 +1,248 @@
#include "HttpControlServer.h"
#include <array>
#include <cstdint>
#include <sstream>
#include <utility>
#include <vector>
namespace RenderCadenceCompositor
{
namespace
{
std::array<uint8_t, 20> Sha1(const std::string& input)
{
auto leftRotate = [](uint32_t value, uint32_t bits) {
return (value << bits) | (value >> (32U - bits));
};
std::vector<uint8_t> data(input.begin(), input.end());
const uint64_t bitLength = static_cast<uint64_t>(data.size()) * 8ULL;
data.push_back(0x80);
while ((data.size() % 64) != 56)
data.push_back(0);
for (int shift = 56; shift >= 0; shift -= 8)
data.push_back(static_cast<uint8_t>((bitLength >> shift) & 0xff));
uint32_t h0 = 0x67452301;
uint32_t h1 = 0xefcdab89;
uint32_t h2 = 0x98badcfe;
uint32_t h3 = 0x10325476;
uint32_t h4 = 0xc3d2e1f0;
for (std::size_t offset = 0; offset < data.size(); offset += 64)
{
uint32_t words[80] = {};
for (std::size_t i = 0; i < 16; ++i)
{
const std::size_t index = offset + i * 4;
words[i] = (static_cast<uint32_t>(data[index]) << 24)
| (static_cast<uint32_t>(data[index + 1]) << 16)
| (static_cast<uint32_t>(data[index + 2]) << 8)
| static_cast<uint32_t>(data[index + 3]);
}
for (std::size_t i = 16; i < 80; ++i)
words[i] = leftRotate(words[i - 3] ^ words[i - 8] ^ words[i - 14] ^ words[i - 16], 1);
uint32_t a = h0;
uint32_t b = h1;
uint32_t c = h2;
uint32_t d = h3;
uint32_t e = h4;
for (std::size_t i = 0; i < 80; ++i)
{
uint32_t f = 0;
uint32_t k = 0;
if (i < 20)
{
f = (b & c) | ((~b) & d);
k = 0x5a827999;
}
else if (i < 40)
{
f = b ^ c ^ d;
k = 0x6ed9eba1;
}
else if (i < 60)
{
f = (b & c) | (b & d) | (c & d);
k = 0x8f1bbcdc;
}
else
{
f = b ^ c ^ d;
k = 0xca62c1d6;
}
const uint32_t temp = leftRotate(a, 5) + f + e + k + words[i];
e = d;
d = c;
c = leftRotate(b, 30);
b = a;
a = temp;
}
h0 += a;
h1 += b;
h2 += c;
h3 += d;
h4 += e;
}
std::array<uint8_t, 20> digest = {};
const uint32_t parts[] = { h0, h1, h2, h3, h4 };
for (std::size_t i = 0; i < 5; ++i)
{
digest[i * 4] = static_cast<uint8_t>((parts[i] >> 24) & 0xff);
digest[i * 4 + 1] = static_cast<uint8_t>((parts[i] >> 16) & 0xff);
digest[i * 4 + 2] = static_cast<uint8_t>((parts[i] >> 8) & 0xff);
digest[i * 4 + 3] = static_cast<uint8_t>(parts[i] & 0xff);
}
return digest;
}
std::string Base64Encode(const uint8_t* data, std::size_t size)
{
static constexpr char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string output;
output.reserve(((size + 2) / 3) * 4);
for (std::size_t i = 0; i < size; i += 3)
{
const uint32_t a = data[i];
const uint32_t b = i + 1 < size ? data[i + 1] : 0;
const uint32_t c = i + 2 < size ? data[i + 2] : 0;
const uint32_t triple = (a << 16) | (b << 8) | c;
output.push_back(kAlphabet[(triple >> 18) & 0x3f]);
output.push_back(kAlphabet[(triple >> 12) & 0x3f]);
output.push_back(i + 1 < size ? kAlphabet[(triple >> 6) & 0x3f] : '=');
output.push_back(i + 2 < size ? kAlphabet[triple & 0x3f] : '=');
}
return output;
}
}
bool HttpControlServer::HandleWebSocketClient(UniqueSocket clientSocket, const HttpRequest& request)
{
const auto keyIt = request.headers.find("sec-websocket-key");
if (keyIt == request.headers.end() || keyIt->second.empty())
return SendResponse(clientSocket.get(), TextResponse("400 Bad Request", "Missing WebSocket key"));
std::ostringstream stream;
stream << "HTTP/1.1 101 Switching Protocols\r\n"
<< "Upgrade: websocket\r\n"
<< "Connection: Upgrade\r\n"
<< "Sec-WebSocket-Accept: " << WebSocketAcceptKey(keyIt->second) << "\r\n\r\n";
const std::string response = stream.str();
if (send(clientSocket.get(), response.c_str(), static_cast<int>(response.size()), 0) != static_cast<int>(response.size()))
return false;
u_long nonBlocking = 1;
ioctlsocket(clientSocket.get(), FIONBIO, &nonBlocking);
std::thread thread([this, socket = std::move(clientSocket)]() mutable {
WebSocketClientMain(std::move(socket));
});
{
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
mClientThreads.push_back(std::move(thread));
}
return true;
}
void HttpControlServer::WebSocketClientMain(UniqueSocket clientSocket)
{
std::string previousState;
while (mRunning.load(std::memory_order_acquire))
{
const std::string state = mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}";
if (state != previousState)
{
if (!SendWebSocketText(clientSocket.get(), state))
break;
previousState = state;
}
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
const std::thread::id currentId = std::this_thread::get_id();
for (auto it = mClientThreads.begin(); it != mClientThreads.end(); ++it)
{
if (it->get_id() != currentId)
continue;
mFinishedClientThreads.push_back(std::move(*it));
mClientThreads.erase(it);
break;
}
}
void HttpControlServer::JoinFinishedClientThreads()
{
std::vector<std::thread> finished;
{
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
finished.swap(mFinishedClientThreads);
}
for (std::thread& thread : finished)
{
if (thread.joinable())
thread.join();
}
}
bool HttpControlServer::SendWebSocketText(SOCKET clientSocket, const std::string& text)
{
if (clientSocket == INVALID_SOCKET)
return false;
std::vector<unsigned char> frame;
frame.reserve(text.size() + 16);
frame.push_back(0x81);
if (text.size() <= 125)
{
frame.push_back(static_cast<unsigned char>(text.size()));
}
else if (text.size() <= 0xffff)
{
frame.push_back(126);
frame.push_back(static_cast<unsigned char>((text.size() >> 8) & 0xff));
frame.push_back(static_cast<unsigned char>(text.size() & 0xff));
}
else
{
frame.push_back(127);
const uint64_t length = static_cast<uint64_t>(text.size());
for (int shift = 56; shift >= 0; shift -= 8)
frame.push_back(static_cast<unsigned char>((length >> shift) & 0xff));
}
frame.insert(frame.end(), text.begin(), text.end());
const char* data = reinterpret_cast<const char*>(frame.data());
int remaining = static_cast<int>(frame.size());
while (remaining > 0)
{
const int sent = send(clientSocket, data, remaining, 0);
if (sent <= 0)
{
const int error = WSAGetLastError();
if (error == WSAEWOULDBLOCK)
{
std::this_thread::sleep_for(std::chrono::milliseconds(2));
continue;
}
return false;
}
data += sent;
remaining -= sent;
}
return true;
}
std::string HttpControlServer::WebSocketAcceptKey(const std::string& clientKey)
{
static constexpr const char* kWebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const std::array<uint8_t, 20> digest = Sha1(clientKey + kWebSocketGuid);
return Base64Encode(digest.data(), digest.size());
}
}