http
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 <shader-id>`
|
||||
- `--no-shader`
|
||||
- `--port <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
|
||||
|
||||
|
||||
@@ -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<RenderThread, SystemFrameExchange> app(renderThread, frameExchange, appConfig);
|
||||
|
||||
std::string error;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
186
apps/RenderCadenceCompositor/app/AppConfigProvider.cpp
Normal file
186
apps/RenderCadenceCompositor/app/AppConfigProvider.cpp
Normal file
@@ -0,0 +1,186 @@
|
||||
#include "AppConfigProvider.h"
|
||||
|
||||
#include "RuntimeJson.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
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<std::size_t>(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<unsigned short>(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<unsigned short>(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<char>(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;
|
||||
}
|
||||
}
|
||||
30
apps/RenderCadenceCompositor/app/AppConfigProvider.h
Normal file
30
apps/RenderCadenceCompositor/app/AppConfigProvider.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "AppConfig.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <type_traits>
|
||||
@@ -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<uint64_t>(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<SystemFrameExchange> mOutputThread;
|
||||
TelemetryPrinter mTelemetry;
|
||||
CadenceTelemetry mHttpTelemetry;
|
||||
HttpControlServer mHttpServer;
|
||||
RuntimeShaderBridge mShaderBridge;
|
||||
bool mStarted = false;
|
||||
};
|
||||
|
||||
382
apps/RenderCadenceCompositor/control/HttpControlServer.cpp
Normal file
382
apps/RenderCadenceCompositor/control/HttpControlServer.cpp
Normal file
@@ -0,0 +1,382 @@
|
||||
#include "HttpControlServer.h"
|
||||
|
||||
#include "../json/JsonWriter.h"
|
||||
#include "../logging/Logger.h"
|
||||
|
||||
#include <ws2tcpip.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#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;
|
||||
}
|
||||
|
||||
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<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();
|
||||
|
||||
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<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"));
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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 << "<!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() };
|
||||
}
|
||||
|
||||
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<char>(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();
|
||||
}
|
||||
}
|
||||
114
apps/RenderCadenceCompositor/control/HttpControlServer.h
Normal file
114
apps/RenderCadenceCompositor/control/HttpControlServer.h
Normal file
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
|
||||
#include <winsock2.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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& 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<bool> mRunning{ false };
|
||||
unsigned short mPort = 0;
|
||||
bool mWinsockStarted = false;
|
||||
};
|
||||
}
|
||||
124
tests/RenderCadenceCompositorAppConfigProviderTests.cpp
Normal file
124
tests/RenderCadenceCompositorAppConfigProviderTests.cpp
Normal file
@@ -0,0 +1,124 @@
|
||||
#include "AppConfigProvider.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
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<char**>(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;
|
||||
}
|
||||
110
tests/RenderCadenceCompositorHttpControlServerTests.cpp
Normal file
110
tests/RenderCadenceCompositorHttpControlServerTests.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
#include "HttpControlServer.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user