From 9938a6cc265a8aa330f6eb634491c58b27f2a388 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Tue, 12 May 2026 12:38:54 +1000 Subject: [PATCH] http --- CMakeLists.txt | 58 +++ apps/RenderCadenceCompositor/README.md | 45 +++ .../RenderCadenceCompositor.cpp | 38 +- .../RenderCadenceCompositor/app/AppConfig.cpp | 14 + apps/RenderCadenceCompositor/app/AppConfig.h | 13 + .../app/AppConfigProvider.cpp | 186 +++++++++ .../app/AppConfigProvider.h | 30 ++ .../app/RenderCadenceApp.h | 87 ++++ .../control/HttpControlServer.cpp | 382 ++++++++++++++++++ .../control/HttpControlServer.h | 114 ++++++ ...adenceCompositorAppConfigProviderTests.cpp | 124 ++++++ ...adenceCompositorHttpControlServerTests.cpp | 110 +++++ 12 files changed, 1182 insertions(+), 19 deletions(-) create mode 100644 apps/RenderCadenceCompositor/app/AppConfigProvider.cpp create mode 100644 apps/RenderCadenceCompositor/app/AppConfigProvider.h create mode 100644 apps/RenderCadenceCompositor/control/HttpControlServer.cpp create mode 100644 apps/RenderCadenceCompositor/control/HttpControlServer.h create mode 100644 tests/RenderCadenceCompositorAppConfigProviderTests.cpp create mode 100644 tests/RenderCadenceCompositorHttpControlServerTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d3a43de..cda4e08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,7 +304,11 @@ set(RENDER_CADENCE_APP_SOURCES "${RENDER_CADENCE_APP_DIR}/RenderCadenceCompositor.cpp" "${RENDER_CADENCE_APP_DIR}/app/AppConfig.cpp" "${RENDER_CADENCE_APP_DIR}/app/AppConfig.h" + "${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.cpp" + "${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.h" "${RENDER_CADENCE_APP_DIR}/app/RenderCadenceApp.h" + "${RENDER_CADENCE_APP_DIR}/control/HttpControlServer.cpp" + "${RENDER_CADENCE_APP_DIR}/control/HttpControlServer.h" "${RENDER_CADENCE_APP_DIR}/frames/SystemFrameExchange.cpp" "${RENDER_CADENCE_APP_DIR}/frames/SystemFrameExchange.h" "${RENDER_CADENCE_APP_DIR}/frames/SystemFrameTypes.h" @@ -353,6 +357,7 @@ target_include_directories(RenderCadenceCompositor PRIVATE "${APP_DIR}/videoio/decklink" "${RENDER_CADENCE_APP_DIR}" "${RENDER_CADENCE_APP_DIR}/app" + "${RENDER_CADENCE_APP_DIR}/control" "${RENDER_CADENCE_APP_DIR}/frames" "${RENDER_CADENCE_APP_DIR}/json" "${RENDER_CADENCE_APP_DIR}/logging" @@ -366,6 +371,7 @@ target_include_directories(RenderCadenceCompositor PRIVATE target_link_libraries(RenderCadenceCompositor PRIVATE opengl32 Ole32 + Ws2_32 ) target_compile_definitions(RenderCadenceCompositor PRIVATE @@ -842,6 +848,58 @@ endif() add_test(NAME RenderCadenceCompositorJsonWriterTests COMMAND RenderCadenceCompositorJsonWriterTests) +add_executable(RenderCadenceCompositorHttpControlServerTests + "${RENDER_CADENCE_APP_DIR}/control/HttpControlServer.cpp" + "${RENDER_CADENCE_APP_DIR}/json/JsonWriter.cpp" + "${RENDER_CADENCE_APP_DIR}/logging/Logger.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorHttpControlServerTests.cpp" +) + +target_include_directories(RenderCadenceCompositorHttpControlServerTests PRIVATE + "${RENDER_CADENCE_APP_DIR}/control" + "${RENDER_CADENCE_APP_DIR}/json" + "${RENDER_CADENCE_APP_DIR}/logging" +) + +target_link_libraries(RenderCadenceCompositorHttpControlServerTests PRIVATE + Ws2_32 +) + +if(MSVC) + target_compile_options(RenderCadenceCompositorHttpControlServerTests PRIVATE /W3) +endif() + +add_test(NAME RenderCadenceCompositorHttpControlServerTests COMMAND RenderCadenceCompositorHttpControlServerTests) + +add_executable(RenderCadenceCompositorAppConfigProviderTests + "${APP_DIR}/runtime/support/RuntimeJson.cpp" + "${RENDER_CADENCE_APP_DIR}/app/AppConfig.cpp" + "${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorAppConfigProviderTests.cpp" +) + +target_include_directories(RenderCadenceCompositorAppConfigProviderTests PRIVATE + "${APP_DIR}" + "${APP_DIR}/runtime/support" + "${RENDER_CADENCE_APP_DIR}/app" + "${RENDER_CADENCE_APP_DIR}/control" + "${RENDER_CADENCE_APP_DIR}/logging" + "${RENDER_CADENCE_APP_DIR}/telemetry" + "${RENDER_CADENCE_APP_DIR}/video" + "${APP_DIR}/videoio" + "${APP_DIR}/videoio/decklink" +) + +target_link_libraries(RenderCadenceCompositorAppConfigProviderTests PRIVATE + Ws2_32 +) + +if(MSVC) + target_compile_options(RenderCadenceCompositorAppConfigProviderTests PRIVATE /W3) +endif() + +add_test(NAME RenderCadenceCompositorAppConfigProviderTests COMMAND RenderCadenceCompositorAppConfigProviderTests) + add_executable(SystemOutputFramePoolTests "${APP_DIR}/videoio/SystemOutputFramePool.cpp" "${APP_DIR}/videoio/VideoIOFormat.cpp" diff --git a/apps/RenderCadenceCompositor/README.md b/apps/RenderCadenceCompositor/README.md index ebcc1d0..54c195b 100644 --- a/apps/RenderCadenceCompositor/README.md +++ b/apps/RenderCadenceCompositor/README.md @@ -47,6 +47,8 @@ Included now: - small JSON writer for future HTTP/WebSocket payloads - JSON serialization for cadence telemetry snapshots - background logging with `log`, `warning`, and `error` levels +- local HTTP control server matching the OpenAPI route surface +- startup config provider for `config/runtime-host.json` - compact telemetry - non-GL frame-exchange tests @@ -101,12 +103,53 @@ build\vs2022-x64-debug\Debug\RenderCadenceCompositor.exe --shader solid-color Use `--no-shader` to keep the simple motion fallback only. +## Startup Config + +On startup the app loads `config/runtime-host.json` through `AppConfigProvider`, then applies explicit CLI overrides. + +Currently consumed fields: + +- `serverPort` +- `shaderLibrary` +- `oscBindAddress` +- `oscPort` +- `oscSmoothing` +- `inputVideoFormat` +- `inputFrameRate` +- `outputVideoFormat` +- `outputFrameRate` +- `autoReload` +- `maxTemporalHistoryFrames` +- `previewFps` +- `enableExternalKeying` + +The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently. + +Supported CLI overrides: + +- `--shader ` +- `--no-shader` +- `--port ` + ## Expected Telemetry Startup, shutdown, shader-build, and render-thread event messages are written through the app logger. Telemetry is intentionally separate and remains a compact once-per-second cadence line. The logger writes to the console, `OutputDebugStringA`, and `logs/render-cadence-compositor.log` by default. Render-thread log calls use the non-blocking path so diagnostics do not become cadence blockers. +## HTTP Control Server + +The app starts a local HTTP control server on `http://127.0.0.1:8080` by default, searching nearby ports if that one is busy. + +Current endpoints: + +- `GET /api/state`: returns an OpenAPI-shaped state scaffold with cadence telemetry under `performance.cadence` +- `GET /docs/openapi.yaml` and `GET /openapi.yaml`: serves the OpenAPI document +- `GET /docs`: serves Swagger UI +- OpenAPI POST routes are present but return `{ "ok": false, "error": "Endpoint is not implemented in RenderCadenceCompositor yet." }` + +The HTTP server runs on its own thread. It samples/copies telemetry through callbacks and does not call render work or DeckLink scheduling. + The app prints one line per second: ```text @@ -191,10 +234,12 @@ This app keeps the same core behavior but splits it into modules that can grow: - `frames/`: system-memory handoff - `platform/`: COM/Win32/hidden GL context support - `render/`: cadence, simple rendering, PBO readback +- `control/`: local HTTP API edge - `json/`: compact JSON serialization helpers - `video/`: DeckLink output wrapper and scheduling thread - `telemetry/`: cadence telemetry - `app/`: startup/shutdown orchestration +- `app/AppConfigProvider`: startup config loading and CLI overrides ## Next Porting Steps diff --git a/apps/RenderCadenceCompositor/RenderCadenceCompositor.cpp b/apps/RenderCadenceCompositor/RenderCadenceCompositor.cpp index 0dda354..c7e1c02 100644 --- a/apps/RenderCadenceCompositor/RenderCadenceCompositor.cpp +++ b/apps/RenderCadenceCompositor/RenderCadenceCompositor.cpp @@ -1,4 +1,5 @@ #include "app/AppConfig.h" +#include "app/AppConfigProvider.h" #include "app/RenderCadenceApp.h" #include "frames/SystemFrameExchange.h" #include "logging/Logger.h" @@ -40,11 +41,23 @@ private: int main(int argc, char** argv) { - RenderCadenceCompositor::AppConfig appConfig = RenderCadenceCompositor::DefaultAppConfig(); + RenderCadenceCompositor::AppConfigProvider configProvider; + std::string configError; + if (!configProvider.Load("config/runtime-host.json", configError)) + { + RenderCadenceCompositor::Logger::Instance().Start(RenderCadenceCompositor::DefaultAppConfig().logging); + RenderCadenceCompositor::LogError("app", "Config load failed: " + configError); + RenderCadenceCompositor::Logger::Instance().Stop(); + return 1; + } + configProvider.ApplyCommandLine(argc, argv); + + RenderCadenceCompositor::AppConfig appConfig = configProvider.Config(); RenderCadenceCompositor::Logger::Instance().Start(appConfig.logging); RenderCadenceCompositor::Log( "app", "RenderCadenceCompositor starting. Starts render cadence, system-memory exchange, DeckLink scheduled output, and telemetry. Press Enter to stop."); + RenderCadenceCompositor::Log("app", "Loaded config from " + configProvider.SourcePath().string()); ComInitGuard com; if (!com.Initialize()) @@ -57,8 +70,10 @@ int main(int argc, char** argv) } SystemFrameExchangeConfig frameExchangeConfig; - frameExchangeConfig.width = 1920; - frameExchangeConfig.height = 1080; + RenderCadenceCompositor::VideoFormatDimensions( + appConfig.outputVideoFormat, + frameExchangeConfig.width, + frameExchangeConfig.height); frameExchangeConfig.pixelFormat = VideoIOPixelFormat::Bgra8; frameExchangeConfig.rowBytes = VideoIORowBytes(frameExchangeConfig.pixelFormat, frameExchangeConfig.width); frameExchangeConfig.capacity = 12; @@ -68,26 +83,11 @@ int main(int argc, char** argv) RenderThread::Config renderConfig; renderConfig.width = frameExchangeConfig.width; renderConfig.height = frameExchangeConfig.height; - renderConfig.frameDurationMilliseconds = 1000.0 / 59.94; + renderConfig.frameDurationMilliseconds = RenderCadenceCompositor::FrameDurationMillisecondsFromRateString(appConfig.outputFrameRate); renderConfig.pboDepth = 6; RenderThread renderThread(frameExchange, renderConfig); - for (int index = 1; index < argc; ++index) - { - const std::string argument = argv[index]; - if (argument == "--shader" && index + 1 < argc) - { - appConfig.runtimeShaderId = argv[++index]; - continue; - } - if (argument == "--no-shader") - { - appConfig.runtimeShaderId.clear(); - continue; - } - } - RenderCadenceCompositor::RenderCadenceApp app(renderThread, frameExchange, appConfig); std::string error; diff --git a/apps/RenderCadenceCompositor/app/AppConfig.cpp b/apps/RenderCadenceCompositor/app/AppConfig.cpp index 90744d3..b9adea7 100644 --- a/apps/RenderCadenceCompositor/app/AppConfig.cpp +++ b/apps/RenderCadenceCompositor/app/AppConfig.cpp @@ -15,6 +15,20 @@ AppConfig DefaultAppConfig() config.logging.writeToFile = true; config.logging.filePath = "logs/render-cadence-compositor.log"; config.logging.maxQueuedMessages = 1024; + config.http.preferredPort = 8080; + config.http.portSearchCount = 20; + config.http.idleSleep = std::chrono::milliseconds(10); + config.shaderLibrary = "shaders"; + config.oscBindAddress = "0.0.0.0"; + config.oscPort = 9000; + config.oscSmoothing = 0.18; + config.inputVideoFormat = "1080p"; + config.inputFrameRate = "59.94"; + config.outputVideoFormat = "1080p"; + config.outputFrameRate = "59.94"; + config.autoReload = true; + config.maxTemporalHistoryFrames = 12; + config.previewFps = 30.0; config.warmupCompletedFrames = 4; config.warmupTimeout = std::chrono::seconds(3); config.prerollTimeout = std::chrono::seconds(3); diff --git a/apps/RenderCadenceCompositor/app/AppConfig.h b/apps/RenderCadenceCompositor/app/AppConfig.h index 5839cb2..824b6e3 100644 --- a/apps/RenderCadenceCompositor/app/AppConfig.h +++ b/apps/RenderCadenceCompositor/app/AppConfig.h @@ -1,5 +1,6 @@ #pragma once +#include "../control/HttpControlServer.h" #include "../logging/Logger.h" #include "../telemetry/TelemetryPrinter.h" #include "../video/DeckLinkOutput.h" @@ -17,6 +18,18 @@ struct AppConfig DeckLinkOutputThreadConfig outputThread; TelemetryPrinterConfig telemetry; LoggerConfig logging; + HttpControlServerConfig http; + std::string shaderLibrary = "shaders"; + std::string oscBindAddress = "0.0.0.0"; + unsigned short oscPort = 9000; + double oscSmoothing = 0.18; + std::string inputVideoFormat = "1080p"; + std::string inputFrameRate = "59.94"; + std::string outputVideoFormat = "1080p"; + std::string outputFrameRate = "59.94"; + bool autoReload = true; + std::size_t maxTemporalHistoryFrames = 12; + double previewFps = 30.0; std::size_t warmupCompletedFrames = 4; std::chrono::milliseconds warmupTimeout = std::chrono::seconds(3); std::chrono::milliseconds prerollTimeout = std::chrono::seconds(3); diff --git a/apps/RenderCadenceCompositor/app/AppConfigProvider.cpp b/apps/RenderCadenceCompositor/app/AppConfigProvider.cpp new file mode 100644 index 0000000..415f7d0 --- /dev/null +++ b/apps/RenderCadenceCompositor/app/AppConfigProvider.cpp @@ -0,0 +1,186 @@ +#include "AppConfigProvider.h" + +#include "RuntimeJson.h" + +#include +#include +#include +#include +#include + +namespace RenderCadenceCompositor +{ +namespace +{ +std::string ReadTextFile(const std::filesystem::path& path, std::string& error) +{ + std::ifstream input(path, std::ios::binary); + if (!input) + { + error = "Could not open config file: " + path.string(); + return std::string(); + } + + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +const JsonValue* Find(const JsonValue& root, const char* key) +{ + return root.find(key); +} + +void ApplyString(const JsonValue& root, const char* key, std::string& target) +{ + const JsonValue* value = Find(root, key); + if (value && value->isString()) + target = value->asString(); +} + +void ApplyBool(const JsonValue& root, const char* key, bool& target) +{ + const JsonValue* value = Find(root, key); + if (value && value->isBoolean()) + target = value->asBoolean(); +} + +void ApplyDouble(const JsonValue& root, const char* key, double& target) +{ + const JsonValue* value = Find(root, key); + if (value && value->isNumber()) + target = value->asNumber(); +} + +void ApplySize(const JsonValue& root, const char* key, std::size_t& target) +{ + const JsonValue* value = Find(root, key); + if (value && value->isNumber() && value->asNumber() >= 0.0) + target = static_cast(value->asNumber()); +} + +void ApplyPort(const JsonValue& root, const char* key, unsigned short& target) +{ + const JsonValue* value = Find(root, key); + if (!value || !value->isNumber()) + return; + + const double port = value->asNumber(); + if (port >= 1.0 && port <= 65535.0) + target = static_cast(port); +} +} + +AppConfigProvider::AppConfigProvider() : + mConfig(DefaultAppConfig()) +{ +} + +bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& error) +{ + mConfig = DefaultAppConfig(); + mSourcePath = path; + mLoadedFromFile = false; + + std::string fileError; + const std::string text = ReadTextFile(path, fileError); + if (!fileError.empty()) + { + error = fileError; + return false; + } + + JsonValue root; + std::string parseError; + if (!ParseJson(text, root, parseError) || !root.isObject()) + { + error = parseError.empty() ? "Config root must be a JSON object." : parseError; + return false; + } + + ApplyString(root, "shaderLibrary", mConfig.shaderLibrary); + ApplyPort(root, "serverPort", mConfig.http.preferredPort); + ApplyString(root, "oscBindAddress", mConfig.oscBindAddress); + ApplyPort(root, "oscPort", mConfig.oscPort); + ApplyDouble(root, "oscSmoothing", mConfig.oscSmoothing); + ApplyString(root, "inputVideoFormat", mConfig.inputVideoFormat); + ApplyString(root, "inputFrameRate", mConfig.inputFrameRate); + ApplyString(root, "outputVideoFormat", mConfig.outputVideoFormat); + ApplyString(root, "outputFrameRate", mConfig.outputFrameRate); + ApplyBool(root, "autoReload", mConfig.autoReload); + ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames); + ApplyDouble(root, "previewFps", mConfig.previewFps); + ApplyBool(root, "enableExternalKeying", mConfig.deckLink.externalKeyingEnabled); + + mLoadedFromFile = true; + error.clear(); + return true; +} + +void AppConfigProvider::ApplyCommandLine(int argc, char** argv) +{ + for (int index = 1; index < argc; ++index) + { + const std::string argument = argv[index]; + if (argument == "--shader" && index + 1 < argc) + { + mConfig.runtimeShaderId = argv[++index]; + continue; + } + if (argument == "--no-shader") + { + mConfig.runtimeShaderId.clear(); + continue; + } + if (argument == "--port" && index + 1 < argc) + { + const int port = std::atoi(argv[++index]); + if (port >= 1 && port <= 65535) + mConfig.http.preferredPort = static_cast(port); + continue; + } + } +} + +double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate) +{ + double rate = fallbackRate; + try + { + rate = std::stod(rateText); + } + catch (...) + { + rate = fallbackRate; + } + + if (rate <= 0.0) + rate = fallbackRate; + return 1000.0 / rate; +} + +void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height) +{ + std::string normalized = formatName; + std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + + if (normalized == "720p") + { + width = 1280; + height = 720; + return; + } + + if (normalized == "2160p" || normalized == "4k" || normalized == "uhd") + { + width = 3840; + height = 2160; + return; + } + + width = 1920; + height = 1080; +} +} diff --git a/apps/RenderCadenceCompositor/app/AppConfigProvider.h b/apps/RenderCadenceCompositor/app/AppConfigProvider.h new file mode 100644 index 0000000..35c06ff --- /dev/null +++ b/apps/RenderCadenceCompositor/app/AppConfigProvider.h @@ -0,0 +1,30 @@ +#pragma once + +#include "AppConfig.h" + +#include +#include + +namespace RenderCadenceCompositor +{ +class AppConfigProvider +{ +public: + AppConfigProvider(); + + bool Load(const std::filesystem::path& path, std::string& error); + void ApplyCommandLine(int argc, char** argv); + + const AppConfig& Config() const { return mConfig; } + const std::filesystem::path& SourcePath() const { return mSourcePath; } + bool LoadedFromFile() const { return mLoadedFromFile; } + +private: + AppConfig mConfig; + std::filesystem::path mSourcePath; + bool mLoadedFromFile = false; +}; + +double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate = 59.94); +void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height); +} diff --git a/apps/RenderCadenceCompositor/app/RenderCadenceApp.h b/apps/RenderCadenceCompositor/app/RenderCadenceApp.h index e9b74fd..f2b4b09 100644 --- a/apps/RenderCadenceCompositor/app/RenderCadenceApp.h +++ b/apps/RenderCadenceCompositor/app/RenderCadenceApp.h @@ -1,13 +1,17 @@ #pragma once #include "AppConfig.h" +#include "AppConfigProvider.h" +#include "../json/JsonWriter.h" #include "../logging/Logger.h" #include "../runtime/RuntimeShaderBridge.h" +#include "../telemetry/CadenceTelemetryJson.h" #include "../telemetry/TelemetryPrinter.h" #include "../video/DeckLinkOutput.h" #include "../video/DeckLinkOutputThread.h" #include +#include #include #include #include @@ -122,6 +126,7 @@ public: } mTelemetry.Start(mFrameExchange, mOutput, mOutputThread, mRenderThread); + StartHttpServer(); Log("app", "RenderCadenceCompositor started."); mStarted = true; return true; @@ -129,6 +134,7 @@ public: void Stop() { + mHttpServer.Stop(); mTelemetry.Stop(); mOutputThread.Stop(); mOutput.Stop(); @@ -144,6 +150,85 @@ public: const DeckLinkOutput& Output() const { return mOutput; } private: + void StartHttpServer() + { + HttpControlServerCallbacks callbacks; + callbacks.getStateJson = [this]() { + return BuildStateJson(); + }; + + std::string error; + if (!mHttpServer.Start(std::filesystem::current_path() / "docs", mConfig.http, callbacks, error)) + { + LogWarning("http", "HTTP control server did not start: " + error); + return; + } + } + + std::string BuildStateJson() + { + CadenceTelemetrySnapshot telemetry = mHttpTelemetry.Sample(mFrameExchange, mOutput, mOutputThread, mRenderThread); + + JsonWriter writer; + writer.BeginObject(); + writer.Key("app"); + writer.BeginObject(); + writer.KeyUInt("serverPort", mHttpServer.Port()); + writer.KeyUInt("oscPort", mConfig.oscPort); + writer.KeyString("oscBindAddress", mConfig.oscBindAddress); + writer.KeyDouble("oscSmoothing", mConfig.oscSmoothing); + writer.KeyBool("autoReload", mConfig.autoReload); + writer.KeyUInt("maxTemporalHistoryFrames", static_cast(mConfig.maxTemporalHistoryFrames)); + writer.KeyDouble("previewFps", mConfig.previewFps); + writer.KeyBool("enableExternalKeying", mConfig.deckLink.externalKeyingEnabled); + writer.KeyString("inputVideoFormat", mConfig.inputVideoFormat); + writer.KeyString("inputFrameRate", mConfig.inputFrameRate); + writer.KeyString("outputVideoFormat", mConfig.outputVideoFormat); + writer.KeyString("outputFrameRate", mConfig.outputFrameRate); + writer.EndObject(); + + writer.Key("runtime"); + writer.BeginObject(); + writer.KeyUInt("layerCount", 0); + writer.KeyBool("compileSucceeded", true); + writer.KeyString("compileMessage", "Runtime state is not ported into RenderCadenceCompositor yet."); + writer.EndObject(); + + writer.KeyNull("video"); + writer.KeyNull("decklink"); + writer.KeyNull("videoIO"); + + writer.Key("performance"); + writer.BeginObject(); + writer.KeyDouble("frameBudgetMs", FrameDurationMillisecondsFromRateString(mConfig.outputFrameRate)); + writer.KeyNull("renderMs"); + writer.KeyNull("smoothedRenderMs"); + writer.KeyNull("budgetUsedPercent"); + writer.KeyNull("completionIntervalMs"); + writer.KeyNull("smoothedCompletionIntervalMs"); + writer.KeyNull("maxCompletionIntervalMs"); + writer.KeyUInt("lateFrameCount", telemetry.displayedLate); + writer.KeyUInt("droppedFrameCount", telemetry.dropped); + writer.KeyNull("flushedFrameCount"); + writer.Key("cadence"); + WriteCadenceTelemetryJson(writer, telemetry); + writer.EndObject(); + + writer.KeyNull("backendPlayout"); + writer.KeyNull("runtimeEvents"); + writer.Key("shaders"); + writer.BeginArray(); + writer.EndArray(); + writer.Key("stackPresets"); + writer.BeginArray(); + writer.EndArray(); + writer.Key("layers"); + writer.BeginArray(); + writer.EndArray(); + writer.EndObject(); + return writer.StringValue(); + } + bool WaitForPreroll() const { const auto deadline = std::chrono::steady_clock::now() + mConfig.prerollTimeout; @@ -186,6 +271,8 @@ private: DeckLinkOutput mOutput; DeckLinkOutputThread mOutputThread; TelemetryPrinter mTelemetry; + CadenceTelemetry mHttpTelemetry; + HttpControlServer mHttpServer; RuntimeShaderBridge mShaderBridge; bool mStarted = false; }; diff --git a/apps/RenderCadenceCompositor/control/HttpControlServer.cpp b/apps/RenderCadenceCompositor/control/HttpControlServer.cpp new file mode 100644 index 0000000..1706118 --- /dev/null +++ b/apps/RenderCadenceCompositor/control/HttpControlServer.cpp @@ -0,0 +1,382 @@ +#include "HttpControlServer.h" + +#include "../json/JsonWriter.h" +#include "../logging/Logger.h" + +#include + +#include +#include +#include +#include + +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; +} + +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"; +} +} + +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& docsRoot, + HttpControlServerConfig config, + HttpControlServerCallbacks callbacks, + std::string& error) +{ + Stop(); + + if (!InitializeWinsock(error)) + return false; + mWinsockStarted = true; + + 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(mConfig.preferredPort + offset)); + if (bind(mListenSocket.get(), reinterpret_cast(&address), sizeof(address)) == 0) + { + mPort = static_cast(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(); + + 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::ThreadMain() +{ + while (mRunning.load(std::memory_order_acquire)) + { + 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(&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")); + + 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(payload.size()), 0) == static_cast(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"); +} + +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 TextResponse("200 OK", "RenderCadenceCompositor control server"); + return TextResponse("404 Not Found", "Not Found"); +} + +HttpControlServer::HttpResponse HttpControlServer::ServePost(const HttpRequest& request) const +{ + if (!IsKnownPostEndpoint(request.path)) + return TextResponse("404 Not Found", "Not Found"); + + 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 << "\n" + << "Video Shader Toys API Docs\n" + << "\n" + << "
\n" + << "\n" + << "\n" + << "\n"; + return { "200 OK", "text/html", html.str() }; +} + +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 }; +} + +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"; + return "text/plain"; +} + +std::string HttpControlServer::ToLower(std::string text) +{ + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return text; +} + +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(); +} +} diff --git a/apps/RenderCadenceCompositor/control/HttpControlServer.h b/apps/RenderCadenceCompositor/control/HttpControlServer.h new file mode 100644 index 0000000..9275aff --- /dev/null +++ b/apps/RenderCadenceCompositor/control/HttpControlServer.h @@ -0,0 +1,114 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace RenderCadenceCompositor +{ +struct HttpControlServerConfig +{ + unsigned short preferredPort = 8080; + unsigned short portSearchCount = 20; + std::chrono::milliseconds idleSleep = std::chrono::milliseconds(10); +}; + +struct HttpControlServerCallbacks +{ + std::function getStateJson; +}; + +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 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& 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); + HttpResponse RouteRequestForTest(const HttpRequest& request) const; + + static bool ParseHttpRequest(const std::string& rawRequest, HttpRequest& request); + +private: + void ThreadMain(); + bool TryAcceptClient(); + bool HandleClient(UniqueSocket clientSocket); + 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; + 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 std::string ActionResponse(bool ok, const std::string& error = std::string()); + static std::string GuessContentType(const std::filesystem::path& path); + static std::string ToLower(std::string text); + + std::filesystem::path mDocsRoot; + HttpControlServerConfig mConfig; + HttpControlServerCallbacks mCallbacks; + UniqueSocket mListenSocket; + std::thread mThread; + std::atomic mRunning{ false }; + unsigned short mPort = 0; + bool mWinsockStarted = false; +}; +} diff --git a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp new file mode 100644 index 0000000..21fffca --- /dev/null +++ b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp @@ -0,0 +1,124 @@ +#include "AppConfigProvider.h" + +#include +#include +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const std::string& message) +{ + if (condition) + return; + + ++gFailures; + std::cerr << "FAILED: " << message << "\n"; +} + +std::filesystem::path WriteConfigFixture() +{ + const std::filesystem::path path = std::filesystem::temp_directory_path() / "render-cadence-compositor-config-test.json"; + std::ofstream output(path, std::ios::binary); + output + << "{\n" + << " \"shaderLibrary\": \"test-shaders\",\n" + << " \"serverPort\": 8181,\n" + << " \"oscBindAddress\": \"127.0.0.1\",\n" + << " \"oscPort\": 9100,\n" + << " \"oscSmoothing\": 0.25,\n" + << " \"inputVideoFormat\": \"720p\",\n" + << " \"inputFrameRate\": \"50\",\n" + << " \"outputVideoFormat\": \"2160p\",\n" + << " \"outputFrameRate\": \"60\",\n" + << " \"autoReload\": false,\n" + << " \"maxTemporalHistoryFrames\": 8,\n" + << " \"previewFps\": 24,\n" + << " \"enableExternalKeying\": true\n" + << "}\n"; + return path; +} + +void TestLoadsRuntimeHostConfig() +{ + using namespace RenderCadenceCompositor; + + const std::filesystem::path path = WriteConfigFixture(); + AppConfigProvider provider; + std::string error; + const bool loaded = provider.Load(path, error); + const AppConfig& config = provider.Config(); + + Expect(loaded, "config loads"); + Expect(error.empty(), "config load has no error"); + Expect(provider.LoadedFromFile(), "provider records file load"); + Expect(config.shaderLibrary == "test-shaders", "shader library loads"); + Expect(config.http.preferredPort == 8181, "server port loads"); + Expect(config.oscBindAddress == "127.0.0.1", "OSC bind address loads"); + Expect(config.oscPort == 9100, "OSC port loads"); + Expect(config.oscSmoothing == 0.25, "OSC smoothing loads"); + Expect(config.inputVideoFormat == "720p", "input format loads"); + Expect(config.inputFrameRate == "50", "input frame rate loads"); + Expect(config.outputVideoFormat == "2160p", "output format loads"); + Expect(config.outputFrameRate == "60", "output frame rate loads"); + Expect(!config.autoReload, "auto reload loads"); + Expect(config.maxTemporalHistoryFrames == 8, "history length loads"); + Expect(config.previewFps == 24.0, "preview fps loads"); + Expect(config.deckLink.externalKeyingEnabled, "external keying loads"); + + std::filesystem::remove(path); +} + +void TestCommandLineOverrides() +{ + using namespace RenderCadenceCompositor; + + AppConfigProvider provider; + const char* argv[] = { + "app.exe", + "--shader", + "solid-color", + "--port", + "8282" + }; + provider.ApplyCommandLine(5, const_cast(argv)); + + const AppConfig& config = provider.Config(); + Expect(config.runtimeShaderId == "solid-color", "shader CLI override applies"); + Expect(config.http.preferredPort == 8282, "port CLI override applies"); +} + +void TestHelpers() +{ + using namespace RenderCadenceCompositor; + + unsigned width = 0; + unsigned height = 0; + VideoFormatDimensions("720p", width, height); + Expect(width == 1280 && height == 720, "720p dimensions resolve"); + + VideoFormatDimensions("2160p", width, height); + Expect(width == 3840 && height == 2160, "2160p dimensions resolve"); + + const double duration = FrameDurationMillisecondsFromRateString("50"); + Expect(duration > 19.9 && duration < 20.1, "frame duration parses numeric rate"); +} +} + +int main() +{ + TestLoadsRuntimeHostConfig(); + TestCommandLineOverrides(); + TestHelpers(); + + if (gFailures != 0) + { + std::cerr << gFailures << " RenderCadenceCompositorAppConfigProvider test failure(s).\n"; + return 1; + } + + std::cout << "RenderCadenceCompositorAppConfigProvider tests passed.\n"; + return 0; +} diff --git a/tests/RenderCadenceCompositorHttpControlServerTests.cpp b/tests/RenderCadenceCompositorHttpControlServerTests.cpp new file mode 100644 index 0000000..78a4478 --- /dev/null +++ b/tests/RenderCadenceCompositorHttpControlServerTests.cpp @@ -0,0 +1,110 @@ +#include "HttpControlServer.h" + +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const std::string& message) +{ + if (condition) + return; + + ++gFailures; + std::cerr << "FAILED: " << message << "\n"; +} + +void ExpectEquals(const std::string& actual, const std::string& expected, const std::string& message) +{ + if (actual == expected) + return; + + ++gFailures; + std::cerr << "FAILED: " << message << "\n" + << "expected: " << expected << "\n" + << "actual: " << actual << "\n"; +} + +void TestParsesHttpRequest() +{ + using namespace RenderCadenceCompositor; + + HttpControlServer::HttpRequest request; + const bool parsed = HttpControlServer::ParseHttpRequest( + "GET /api/state?cacheBust=1 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + request); + + Expect(parsed, "request parses"); + ExpectEquals(request.method, "GET", "method is parsed"); + ExpectEquals(request.path, "/api/state", "query string is stripped from path"); + ExpectEquals(request.headers["host"], "127.0.0.1", "headers are lower-cased and trimmed"); +} + +void TestStateEndpointUsesCallback() +{ + using namespace RenderCadenceCompositor; + + HttpControlServer server; + HttpControlServerCallbacks callbacks; + callbacks.getStateJson = []() { return std::string("{\"ok\":true}"); }; + server.SetCallbacksForTest(callbacks); + + HttpControlServer::HttpRequest request; + request.method = "GET"; + request.path = "/api/state"; + + const HttpControlServer::HttpResponse response = server.RouteRequestForTest(request); + ExpectEquals(response.status, "200 OK", "state endpoint succeeds"); + ExpectEquals(response.contentType, "application/json", "state endpoint is JSON"); + ExpectEquals(response.body, "{\"ok\":true}", "state endpoint returns callback JSON"); +} + +void TestKnownPostEndpointReturnsActionError() +{ + using namespace RenderCadenceCompositor; + + HttpControlServer server; + HttpControlServer::HttpRequest request; + request.method = "POST"; + request.path = "/api/layers/add"; + request.body = "{\"shaderId\":\"happy-accident\"}"; + + const HttpControlServer::HttpResponse response = server.RouteRequestForTest(request); + ExpectEquals(response.status, "400 Bad Request", "unimplemented post returns OpenAPI action error status"); + ExpectEquals(response.contentType, "application/json", "unimplemented post returns JSON"); + Expect(response.body.find("\"ok\":false") != std::string::npos, "unimplemented post reports ok false"); + Expect(response.body.find("not implemented") != std::string::npos, "unimplemented post reports diagnostic"); +} + +void TestUnknownEndpointReturns404() +{ + using namespace RenderCadenceCompositor; + + HttpControlServer server; + HttpControlServer::HttpRequest request; + request.method = "GET"; + request.path = "/api/nope"; + + const HttpControlServer::HttpResponse response = server.RouteRequestForTest(request); + ExpectEquals(response.status, "404 Not Found", "unknown endpoint returns 404"); +} +} + +int main() +{ + TestParsesHttpRequest(); + TestStateEndpointUsesCallback(); + TestKnownPostEndpointReturnsActionError(); + TestUnknownEndpointReturns404(); + + if (gFailures != 0) + { + std::cerr << gFailures << " RenderCadenceCompositorHttpControlServer test failure(s).\n"; + return 1; + } + + std::cout << "RenderCadenceCompositorHttpControlServer tests passed.\n"; + return 0; +}