2 Commits

Author SHA1 Message Date
Aiden
da7e1a93f6 Websockets
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m58s
CI / Windows Release Package (push) Has been skipped
2026-05-12 15:32:01 +10:00
Aiden
334693f28c Render udpates 2026-05-12 15:26:02 +10:00
17 changed files with 733 additions and 25 deletions

View File

@@ -336,6 +336,9 @@ set(RENDER_CADENCE_APP_SOURCES
"${RENDER_CADENCE_APP_DIR}/render/RuntimeShaderParams.h" "${RENDER_CADENCE_APP_DIR}/render/RuntimeShaderParams.h"
"${RENDER_CADENCE_APP_DIR}/render/RuntimeRenderScene.cpp" "${RENDER_CADENCE_APP_DIR}/render/RuntimeRenderScene.cpp"
"${RENDER_CADENCE_APP_DIR}/render/RuntimeRenderScene.h" "${RENDER_CADENCE_APP_DIR}/render/RuntimeRenderScene.h"
"${RENDER_CADENCE_APP_DIR}/render/RuntimeShaderPrepareWorker.cpp"
"${RENDER_CADENCE_APP_DIR}/render/RuntimeShaderPrepareWorker.h"
"${RENDER_CADENCE_APP_DIR}/render/RuntimeShaderProgram.h"
"${RENDER_CADENCE_APP_DIR}/render/SimpleMotionRenderer.cpp" "${RENDER_CADENCE_APP_DIR}/render/SimpleMotionRenderer.cpp"
"${RENDER_CADENCE_APP_DIR}/render/SimpleMotionRenderer.h" "${RENDER_CADENCE_APP_DIR}/render/SimpleMotionRenderer.h"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeLayerModel.cpp" "${RENDER_CADENCE_APP_DIR}/runtime/RuntimeLayerModel.cpp"

View File

@@ -44,7 +44,8 @@ Included now:
- app-owned display/render layer model for shader build readiness - app-owned display/render layer model for shader build readiness
- app-owned submission of a completed shader artifact - app-owned submission of a completed shader artifact
- render-thread-owned runtime render scene for ready shader layers - render-thread-owned runtime render scene for ready shader layers
- render-thread-only GL commit once the artifact is ready - shared-context GL prepare worker for runtime shader program compile/link
- render-thread-only GL program swap once a prepared program is ready
- manifest-driven stateless single-pass shader packages - manifest-driven stateless single-pass shader packages
- HTTP shader list populated from supported stateless single-pass shader packages - HTTP shader list populated from supported stateless single-pass shader packages
- default float, vec2, color, boolean, enum, and trigger parameters - default float, vec2, color, boolean, enum, and trigger parameters
@@ -149,6 +150,7 @@ Current endpoints:
- `GET /` and UI asset paths: serve the bundled control UI from `ui/dist` - `GET /` and UI asset paths: serve the bundled control UI from `ui/dist`
- `GET /api/state`: returns OpenAPI-shaped display data with cadence telemetry, supported shaders, output status, and a read-only current runtime layer - `GET /api/state`: returns OpenAPI-shaped display data with cadence telemetry, supported shaders, output status, and a read-only current runtime layer
- `GET /ws`: upgrades to a WebSocket and streams state snapshots when they change
- `GET /docs/openapi.yaml` and `GET /openapi.yaml`: serves the OpenAPI document - `GET /docs/openapi.yaml` and `GET /openapi.yaml`: serves the OpenAPI document
- `GET /docs`: serves Swagger UI - `GET /docs`: serves Swagger UI
- `POST /api/layers/add` and `POST /api/layers/remove` mutate the app-owned display layer model only - `POST /api/layers/add` and `POST /api/layers/remove` mutate the app-owned display layer model only
@@ -197,7 +199,7 @@ Healthy first-run signs:
On startup the app begins compiling the selected shader package on a background thread owned by the app orchestration layer. The default is `shaders/happy-accident`. On startup the app begins compiling the selected shader package on a background thread owned by the app orchestration layer. The default is `shaders/happy-accident`.
The render thread keeps drawing the simple motion renderer while Slang compiles. It does not choose packages, launch Slang, or track build lifecycle. It only receives a completed shader artifact and attempts the OpenGL shader compile/link at a frame boundary. If either the Slang build or GL commit fails, the app keeps rendering the simple motion fallback. The render thread keeps drawing the simple motion renderer while Slang compiles. It does not choose packages, launch Slang, or track build lifecycle. Once a completed shader artifact is published, the render-thread-owned runtime scene queues changed layers to a shared-context GL prepare worker. That worker compiles/links runtime shader programs off the cadence thread. The render thread only swaps in an already-prepared GL program at a frame boundary. If either the Slang build or GL preparation fails, the app keeps rendering the current renderer or simple motion fallback.
Current runtime shader support is deliberately limited to stateless single-pass packages: Current runtime shader support is deliberately limited to stateless single-pass packages:
@@ -213,7 +215,7 @@ The `/api/state` shader list uses the same support rules as runtime shader compi
Runtime shaders are exposed through `RuntimeLayerModel` as display layers with manifest parameter defaults. The model also records whether each layer has a render-ready artifact. Add/remove POST controls mutate this app-owned model and may start background shader builds. Runtime shaders are exposed through `RuntimeLayerModel` as display layers with manifest parameter defaults. The model also records whether each layer has a render-ready artifact. Add/remove POST controls mutate this app-owned model and may start background shader builds.
When a layer becomes render-ready, the app publishes the ready render-layer snapshot to the render thread. The render thread owns the GL-side `RuntimeRenderScene`, diffs that snapshot at a frame boundary, commits/removes GL programs, and renders the ready layers in order. Current layer rendering is still deliberately simple: each stateless full-frame shader draws to the output target using fallback source textures until proper layer-input texture handoff is designed. When a layer becomes render-ready, the app publishes the ready render-layer snapshot to the render thread. The render thread owns the GL-side `RuntimeRenderScene`, diffs that snapshot at a frame boundary, queues new or changed programs to the shared-context prepare worker, swaps in prepared programs when available, removes obsolete GL programs, and renders ready layers in order. Current layer rendering is still deliberately simple: each stateless full-frame shader draws to the output target using fallback source textures until proper layer-input texture handoff is designed.
Successful handoff signs: Successful handoff signs:
@@ -264,6 +266,7 @@ This app keeps the same core behavior but splits it into modules that can grow:
- `platform/`: COM/Win32/hidden GL context support - `platform/`: COM/Win32/hidden GL context support
- `render/`: cadence, simple rendering, PBO readback - `render/`: cadence, simple rendering, PBO readback
- `render/RuntimeRenderScene`: render-thread-owned GL scene for ready runtime shader layers - `render/RuntimeRenderScene`: render-thread-owned GL scene for ready runtime shader layers
- `render/RuntimeShaderPrepareWorker`: shared-context runtime shader program compile/link worker
- `runtime/`: app-owned shader layer readiness model, runtime Slang build bridge, and completed artifact handoff - `runtime/`: app-owned shader layer readiness model, runtime Slang build bridge, and completed artifact handoff
- `control/`: local HTTP API edge and runtime-state JSON presentation - `control/`: local HTTP API edge and runtime-state JSON presentation
- `json/`: compact JSON serialization helpers - `json/`: compact JSON serialization helpers

View File

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

View File

@@ -9,8 +9,10 @@
#include <filesystem> #include <filesystem>
#include <functional> #include <functional>
#include <map> #include <map>
#include <mutex>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector>
namespace RenderCadenceCompositor namespace RenderCadenceCompositor
{ {
@@ -89,11 +91,15 @@ public:
HttpResponse RouteRequestForTest(const HttpRequest& request) const; HttpResponse RouteRequestForTest(const HttpRequest& request) const;
static bool ParseHttpRequest(const std::string& rawRequest, HttpRequest& request); static bool ParseHttpRequest(const std::string& rawRequest, HttpRequest& request);
static std::string WebSocketAcceptKey(const std::string& clientKey);
private: private:
void ThreadMain(); void ThreadMain();
bool TryAcceptClient(); bool TryAcceptClient();
bool HandleClient(UniqueSocket clientSocket); bool HandleClient(UniqueSocket clientSocket);
bool HandleWebSocketClient(UniqueSocket clientSocket, const HttpRequest& request);
void WebSocketClientMain(UniqueSocket clientSocket);
void JoinFinishedClientThreads();
bool SendResponse(SOCKET clientSocket, const HttpResponse& response) const; bool SendResponse(SOCKET clientSocket, const HttpResponse& response) const;
HttpResponse RouteRequest(const HttpRequest& request) const; HttpResponse RouteRequest(const HttpRequest& request) const;
HttpResponse ServeGet(const HttpRequest& request) const; HttpResponse ServeGet(const HttpRequest& request) const;
@@ -107,6 +113,7 @@ private:
static HttpResponse TextResponse(const std::string& status, const std::string& body); static HttpResponse TextResponse(const std::string& status, const std::string& body);
static HttpResponse HtmlResponse(const std::string& status, const std::string& body); static HttpResponse HtmlResponse(const std::string& status, const std::string& body);
static std::string ActionResponse(bool ok, const std::string& error = std::string()); static std::string ActionResponse(bool ok, const std::string& error = std::string());
static bool SendWebSocketText(SOCKET clientSocket, const std::string& text);
static std::string GuessContentType(const std::filesystem::path& path); static std::string GuessContentType(const std::filesystem::path& path);
static bool IsSafeRelativePath(const std::filesystem::path& path); static bool IsSafeRelativePath(const std::filesystem::path& path);
static std::string ToLower(std::string text); static std::string ToLower(std::string text);
@@ -117,6 +124,9 @@ private:
HttpControlServerCallbacks mCallbacks; HttpControlServerCallbacks mCallbacks;
UniqueSocket mListenSocket; UniqueSocket mListenSocket;
std::thread mThread; std::thread mThread;
std::mutex mClientThreadsMutex;
std::vector<std::thread> mClientThreads;
std::vector<std::thread> mFinishedClientThreads;
std::atomic<bool> mRunning{ false }; std::atomic<bool> mRunning{ false };
unsigned short mPort = 0; unsigned short mPort = 0;
bool mWinsockStarted = false; bool mWinsockStarted = false;

View File

@@ -11,6 +11,11 @@ HiddenGlWindow::~HiddenGlWindow()
} }
bool HiddenGlWindow::Create(unsigned width, unsigned height, std::string& error) bool HiddenGlWindow::Create(unsigned width, unsigned height, std::string& error)
{
return CreateShared(width, height, nullptr, nullptr, error);
}
bool HiddenGlWindow::CreateShared(unsigned width, unsigned height, HDC sharedDeviceContext, HGLRC sharedContext, std::string& error)
{ {
Destroy(); Destroy();
@@ -63,7 +68,11 @@ bool HiddenGlWindow::Create(unsigned width, unsigned height, std::string& error)
pfd.cDepthBits = 0; pfd.cDepthBits = 0;
pfd.iLayerType = PFD_MAIN_PLANE; pfd.iLayerType = PFD_MAIN_PLANE;
const int pixelFormat = ChoosePixelFormat(mDc, &pfd); int pixelFormat = 0;
if (sharedDeviceContext != nullptr)
pixelFormat = GetPixelFormat(sharedDeviceContext);
if (pixelFormat == 0)
pixelFormat = ChoosePixelFormat(mDc, &pfd);
if (pixelFormat == 0 || !SetPixelFormat(mDc, pixelFormat, &pfd)) if (pixelFormat == 0 || !SetPixelFormat(mDc, pixelFormat, &pfd))
{ {
error = "Could not choose/set pixel format for hidden OpenGL window."; error = "Could not choose/set pixel format for hidden OpenGL window.";
@@ -76,6 +85,11 @@ bool HiddenGlWindow::Create(unsigned width, unsigned height, std::string& error)
error = "wglCreateContext failed for hidden OpenGL window."; error = "wglCreateContext failed for hidden OpenGL window.";
return false; return false;
} }
if (sharedContext != nullptr && wglShareLists(sharedContext, mGlrc) != TRUE)
{
error = "wglShareLists failed for hidden OpenGL shared context.";
return false;
}
return true; return true;
} }

View File

@@ -13,6 +13,7 @@ public:
~HiddenGlWindow(); ~HiddenGlWindow();
bool Create(unsigned width, unsigned height, std::string& error); bool Create(unsigned width, unsigned height, std::string& error);
bool CreateShared(unsigned width, unsigned height, HDC sharedDeviceContext, HGLRC sharedContext, std::string& error);
bool MakeCurrent() const; bool MakeCurrent() const;
void ClearCurrent() const; void ClearCurrent() const;
void Destroy(); void Destroy();

View File

@@ -11,6 +11,7 @@
#include "SimpleMotionRenderer.h" #include "SimpleMotionRenderer.h"
#include <algorithm> #include <algorithm>
#include <memory>
#include <thread> #include <thread>
RenderThread::RenderThread(SystemFrameExchange& frameExchange, Config config) : RenderThread::RenderThread(SystemFrameExchange& frameExchange, Config config) :
@@ -83,11 +84,22 @@ void RenderThread::ThreadMain()
RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread starting."); RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread starting.");
HiddenGlWindow window; HiddenGlWindow window;
std::string error; std::string error;
if (!window.Create(mConfig.width, mConfig.height, error) || !window.MakeCurrent()) if (!window.Create(mConfig.width, mConfig.height, error))
{ {
SignalStartupFailure(error.empty() ? "OpenGL context creation failed." : error); SignalStartupFailure(error.empty() ? "OpenGL context creation failed." : error);
return; return;
} }
std::unique_ptr<HiddenGlWindow> prepareWindow = std::make_unique<HiddenGlWindow>();
if (!prepareWindow->CreateShared(mConfig.width, mConfig.height, window.DeviceContext(), window.Context(), error))
{
SignalStartupFailure(error.empty() ? "Runtime shader prepare shared context creation failed." : error);
return;
}
if (!window.MakeCurrent())
{
SignalStartupFailure("OpenGL context creation failed.");
return;
}
if (!ResolveGLExtensions()) if (!ResolveGLExtensions())
{ {
SignalStartupFailure("OpenGL extension resolution failed."); SignalStartupFailure("OpenGL extension resolution failed.");
@@ -97,6 +109,11 @@ void RenderThread::ThreadMain()
SimpleMotionRenderer renderer; SimpleMotionRenderer renderer;
RuntimeRenderScene runtimeRenderScene; RuntimeRenderScene runtimeRenderScene;
Bgra8ReadbackPipeline readback; Bgra8ReadbackPipeline readback;
if (!runtimeRenderScene.StartPrepareWorker(std::move(prepareWindow), error))
{
SignalStartupFailure(error.empty() ? "Runtime shader prepare worker initialization failed." : error);
return;
}
if (!renderer.InitializeGl(mConfig.width, mConfig.height) || !readback.Initialize(mConfig.width, mConfig.height, mConfig.pboDepth)) if (!renderer.InitializeGl(mConfig.width, mConfig.height) || !readback.Initialize(mConfig.width, mConfig.height, mConfig.pboDepth))
{ {
SignalStartupFailure("Render pipeline initialization failed."); SignalStartupFailure("Render pipeline initialization failed.");

View File

@@ -1,5 +1,7 @@
#include "RuntimeRenderScene.h" #include "RuntimeRenderScene.h"
#include "../platform/HiddenGlWindow.h"
#include <algorithm> #include <algorithm>
#include <functional> #include <functional>
#include <utility> #include <utility>
@@ -9,9 +11,17 @@ RuntimeRenderScene::~RuntimeRenderScene()
ShutdownGl(); ShutdownGl();
} }
bool RuntimeRenderScene::StartPrepareWorker(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error)
{
return mPrepareWorker.Start(std::move(sharedWindow), error);
}
bool RuntimeRenderScene::CommitRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers, std::string& error) bool RuntimeRenderScene::CommitRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers, std::string& error)
{ {
ConsumePreparedPrograms();
std::vector<std::string> nextOrder; std::vector<std::string> nextOrder;
std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> layersToPrepare;
nextOrder.reserve(layers.size()); nextOrder.reserve(layers.size());
for (const RenderCadenceCompositor::RuntimeRenderLayerModel& layer : layers) for (const RenderCadenceCompositor::RuntimeRenderLayerModel& layer : layers)
nextOrder.push_back(layer.id); nextOrder.push_back(layer.id);
@@ -48,25 +58,38 @@ bool RuntimeRenderScene::CommitRenderLayers(const std::vector<RenderCadenceCompo
if (program->shaderId == layer.shaderId && program->sourceFingerprint == fingerprint && program->renderer && program->renderer->HasProgram()) if (program->shaderId == layer.shaderId && program->sourceFingerprint == fingerprint && program->renderer && program->renderer->HasProgram())
continue; continue;
if (program->pendingFingerprint == fingerprint)
continue;
std::unique_ptr<RuntimeShaderRenderer> nextRenderer = std::make_unique<RuntimeShaderRenderer>();
if (!nextRenderer->CommitShaderArtifact(layer.artifact, error))
return false;
if (program->renderer)
program->renderer->ShutdownGl();
program->shaderId = layer.shaderId; program->shaderId = layer.shaderId;
program->sourceFingerprint = fingerprint; program->pendingFingerprint = fingerprint;
program->renderer = std::move(nextRenderer); layersToPrepare.push_back(layer);
} }
mLayerOrder = std::move(nextOrder); mLayerOrder = std::move(nextOrder);
if (!layersToPrepare.empty())
mPrepareWorker.Submit(layersToPrepare);
error.clear(); error.clear();
return true; return true;
} }
bool RuntimeRenderScene::HasLayers()
{
ConsumePreparedPrograms();
for (const std::string& layerId : mLayerOrder)
{
const LayerProgram* layer = FindLayer(layerId);
if (layer && layer->renderer && layer->renderer->HasProgram())
return true;
}
return false;
}
void RuntimeRenderScene::RenderFrame(uint64_t frameIndex, unsigned width, unsigned height) void RuntimeRenderScene::RenderFrame(uint64_t frameIndex, unsigned width, unsigned height)
{ {
ConsumePreparedPrograms();
for (const std::string& layerId : mLayerOrder) for (const std::string& layerId : mLayerOrder)
{ {
LayerProgram* layer = FindLayer(layerId); LayerProgram* layer = FindLayer(layerId);
@@ -78,6 +101,7 @@ void RuntimeRenderScene::RenderFrame(uint64_t frameIndex, unsigned width, unsign
void RuntimeRenderScene::ShutdownGl() void RuntimeRenderScene::ShutdownGl()
{ {
mPrepareWorker.Stop();
for (LayerProgram& layer : mLayers) for (LayerProgram& layer : mLayers)
{ {
if (layer.renderer) if (layer.renderer)
@@ -87,6 +111,41 @@ void RuntimeRenderScene::ShutdownGl()
mLayerOrder.clear(); mLayerOrder.clear();
} }
void RuntimeRenderScene::ConsumePreparedPrograms()
{
RuntimePreparedShaderProgram preparedProgram;
while (mPrepareWorker.TryConsume(preparedProgram))
{
if (!preparedProgram.succeeded)
{
preparedProgram.ReleaseGl();
continue;
}
LayerProgram* layer = FindLayer(preparedProgram.layerId);
if (!layer || layer->pendingFingerprint != preparedProgram.sourceFingerprint)
{
preparedProgram.ReleaseGl();
continue;
}
std::unique_ptr<RuntimeShaderRenderer> nextRenderer = std::make_unique<RuntimeShaderRenderer>();
std::string error;
if (!nextRenderer->CommitPreparedProgram(preparedProgram, error))
{
preparedProgram.ReleaseGl();
continue;
}
if (layer->renderer)
layer->renderer->ShutdownGl();
layer->renderer = std::move(nextRenderer);
layer->shaderId = preparedProgram.shaderId;
layer->sourceFingerprint = preparedProgram.sourceFingerprint;
layer->pendingFingerprint.clear();
}
}
RuntimeRenderScene::LayerProgram* RuntimeRenderScene::FindLayer(const std::string& layerId) RuntimeRenderScene::LayerProgram* RuntimeRenderScene::FindLayer(const std::string& layerId)
{ {
for (LayerProgram& layer : mLayers) for (LayerProgram& layer : mLayers)

View File

@@ -1,8 +1,11 @@
#pragma once #pragma once
#include "../runtime/RuntimeLayerModel.h" #include "../runtime/RuntimeLayerModel.h"
#include "RuntimeShaderPrepareWorker.h"
#include "RuntimeShaderRenderer.h" #include "RuntimeShaderRenderer.h"
#include <windows.h>
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -16,8 +19,9 @@ public:
RuntimeRenderScene& operator=(const RuntimeRenderScene&) = delete; RuntimeRenderScene& operator=(const RuntimeRenderScene&) = delete;
~RuntimeRenderScene(); ~RuntimeRenderScene();
bool StartPrepareWorker(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error);
bool CommitRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers, std::string& error); bool CommitRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers, std::string& error);
bool HasLayers() const { return !mLayerOrder.empty(); } bool HasLayers();
void RenderFrame(uint64_t frameIndex, unsigned width, unsigned height); void RenderFrame(uint64_t frameIndex, unsigned width, unsigned height);
void ShutdownGl(); void ShutdownGl();
@@ -27,13 +31,16 @@ private:
std::string layerId; std::string layerId;
std::string shaderId; std::string shaderId;
std::string sourceFingerprint; std::string sourceFingerprint;
std::string pendingFingerprint;
std::unique_ptr<RuntimeShaderRenderer> renderer; std::unique_ptr<RuntimeShaderRenderer> renderer;
}; };
void ConsumePreparedPrograms();
LayerProgram* FindLayer(const std::string& layerId); LayerProgram* FindLayer(const std::string& layerId);
const LayerProgram* FindLayer(const std::string& layerId) const; const LayerProgram* FindLayer(const std::string& layerId) const;
static std::string Fingerprint(const RuntimeShaderArtifact& artifact); static std::string Fingerprint(const RuntimeShaderArtifact& artifact);
RuntimeShaderPrepareWorker mPrepareWorker;
std::vector<LayerProgram> mLayers; std::vector<LayerProgram> mLayers;
std::vector<std::string> mLayerOrder; std::vector<std::string> mLayerOrder;
}; };

View File

@@ -0,0 +1,158 @@
#include "RuntimeShaderPrepareWorker.h"
#include "../platform/HiddenGlWindow.h"
#include "RuntimeShaderRenderer.h"
#include <algorithm>
#include <chrono>
#include <functional>
#include <utility>
RuntimeShaderPrepareWorker::~RuntimeShaderPrepareWorker()
{
Stop();
}
bool RuntimeShaderPrepareWorker::Start(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error)
{
if (mThread.joinable())
return true;
if (!sharedWindow || sharedWindow->DeviceContext() == nullptr || sharedWindow->Context() == nullptr)
{
error = "Runtime shader prepare worker needs an existing shared GL context.";
return false;
}
mWindow = std::move(sharedWindow);
mStopping.store(false, std::memory_order_release);
mStarted.store(false, std::memory_order_release);
{
std::lock_guard<std::mutex> lock(mMutex);
mStartupReady = false;
mStartupError.clear();
}
mThread = std::thread([this]() { ThreadMain(); });
std::unique_lock<std::mutex> lock(mMutex);
if (!mStartupCondition.wait_for(lock, std::chrono::seconds(3), [this]() {
return mStartupReady || !mStartupError.empty();
}))
{
error = "Timed out starting runtime shader prepare worker.";
lock.unlock();
Stop();
return false;
}
if (!mStartupError.empty())
{
error = mStartupError;
lock.unlock();
Stop();
return false;
}
return true;
}
void RuntimeShaderPrepareWorker::Stop()
{
mStopping.store(true, std::memory_order_release);
mCondition.notify_all();
if (mThread.joinable())
mThread.join();
std::deque<RuntimePreparedShaderProgram> completed;
{
std::lock_guard<std::mutex> lock(mMutex);
mRequests.clear();
completed.swap(mCompleted);
}
for (RuntimePreparedShaderProgram& program : completed)
program.ReleaseGl();
mWindow.reset();
mStarted.store(false, std::memory_order_release);
}
void RuntimeShaderPrepareWorker::Submit(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers)
{
std::lock_guard<std::mutex> lock(mMutex);
for (const RenderCadenceCompositor::RuntimeRenderLayerModel& layer : layers)
{
if (layer.artifact.fragmentShaderSource.empty())
continue;
PrepareRequest request;
request.layerId = layer.id;
request.shaderId = layer.shaderId;
request.sourceFingerprint = Fingerprint(layer.artifact);
request.artifact = layer.artifact;
auto sameLayer = [&request](const PrepareRequest& existing) {
return existing.layerId == request.layerId;
};
mRequests.erase(std::remove_if(mRequests.begin(), mRequests.end(), sameLayer), mRequests.end());
mRequests.push_back(std::move(request));
}
mCondition.notify_one();
}
bool RuntimeShaderPrepareWorker::TryConsume(RuntimePreparedShaderProgram& preparedProgram)
{
std::lock_guard<std::mutex> lock(mMutex);
if (mCompleted.empty())
return false;
preparedProgram = std::move(mCompleted.front());
mCompleted.pop_front();
return true;
}
void RuntimeShaderPrepareWorker::ThreadMain()
{
if (!mWindow || !mWindow->MakeCurrent())
{
std::lock_guard<std::mutex> lock(mMutex);
mStartupError = "Runtime shader prepare worker could not make shared GL context current.";
mStartupCondition.notify_all();
return;
}
{
std::lock_guard<std::mutex> lock(mMutex);
mStartupReady = true;
}
mStarted.store(true, std::memory_order_release);
mStartupCondition.notify_all();
while (!mStopping.load(std::memory_order_acquire))
{
PrepareRequest request;
{
std::unique_lock<std::mutex> lock(mMutex);
mCondition.wait(lock, [this]() {
return mStopping.load(std::memory_order_acquire) || !mRequests.empty();
});
if (mStopping.load(std::memory_order_acquire))
break;
request = std::move(mRequests.front());
mRequests.pop_front();
}
RuntimePreparedShaderProgram preparedProgram;
RuntimeShaderRenderer::BuildPreparedProgram(
request.layerId,
request.sourceFingerprint,
request.artifact,
preparedProgram);
glFlush();
std::lock_guard<std::mutex> lock(mMutex);
mCompleted.push_back(std::move(preparedProgram));
}
mWindow->ClearCurrent();
}
std::string RuntimeShaderPrepareWorker::Fingerprint(const RuntimeShaderArtifact& artifact)
{
const std::hash<std::string> hasher;
return artifact.shaderId + ":" + std::to_string(artifact.fragmentShaderSource.size()) + ":" + std::to_string(hasher(artifact.fragmentShaderSource));
}

View File

@@ -0,0 +1,56 @@
#pragma once
#include "RuntimeShaderProgram.h"
#include "../runtime/RuntimeLayerModel.h"
#include <windows.h>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
class HiddenGlWindow;
class RuntimeShaderPrepareWorker
{
public:
RuntimeShaderPrepareWorker() = default;
RuntimeShaderPrepareWorker(const RuntimeShaderPrepareWorker&) = delete;
RuntimeShaderPrepareWorker& operator=(const RuntimeShaderPrepareWorker&) = delete;
~RuntimeShaderPrepareWorker();
bool Start(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error);
void Stop();
void Submit(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers);
bool TryConsume(RuntimePreparedShaderProgram& preparedProgram);
private:
struct PrepareRequest
{
std::string layerId;
std::string shaderId;
std::string sourceFingerprint;
RuntimeShaderArtifact artifact;
};
void ThreadMain();
static std::string Fingerprint(const RuntimeShaderArtifact& artifact);
std::unique_ptr<HiddenGlWindow> mWindow;
std::mutex mMutex;
std::condition_variable mCondition;
std::deque<PrepareRequest> mRequests;
std::deque<RuntimePreparedShaderProgram> mCompleted;
std::condition_variable mStartupCondition;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mStarted{ false };
bool mStartupReady = false;
std::string mStartupError;
};

View File

@@ -0,0 +1,32 @@
#pragma once
#include "GLExtensions.h"
#include "../runtime/RuntimeShaderArtifact.h"
#include <string>
struct RuntimePreparedShaderProgram
{
std::string layerId;
std::string shaderId;
std::string sourceFingerprint;
RuntimeShaderArtifact artifact;
GLuint program = 0;
GLuint vertexShader = 0;
GLuint fragmentShader = 0;
bool succeeded = false;
std::string error;
void ReleaseGl()
{
if (program != 0)
glDeleteProgram(program);
if (vertexShader != 0)
glDeleteShader(vertexShader);
if (fragmentShader != 0)
glDeleteShader(fragmentShader);
program = 0;
vertexShader = 0;
fragmentShader = 0;
}
};

View File

@@ -71,6 +71,62 @@ bool RuntimeShaderRenderer::CommitShaderArtifact(const RuntimeShaderArtifact& ar
return true; return true;
} }
bool RuntimeShaderRenderer::CommitPreparedProgram(RuntimePreparedShaderProgram& preparedProgram, std::string& error)
{
if (!preparedProgram.succeeded || preparedProgram.program == 0)
{
error = preparedProgram.error.empty() ? "Prepared runtime shader program is not valid." : preparedProgram.error;
return false;
}
if (!EnsureStaticGlResources(error))
return false;
DestroyProgram();
mProgram = preparedProgram.program;
mVertexShader = preparedProgram.vertexShader;
mFragmentShader = preparedProgram.fragmentShader;
mArtifact = preparedProgram.artifact;
preparedProgram.program = 0;
preparedProgram.vertexShader = 0;
preparedProgram.fragmentShader = 0;
return true;
}
bool RuntimeShaderRenderer::BuildPreparedProgram(
const std::string& layerId,
const std::string& sourceFingerprint,
const RuntimeShaderArtifact& artifact,
RuntimePreparedShaderProgram& preparedProgram)
{
preparedProgram = RuntimePreparedShaderProgram();
preparedProgram.layerId = layerId;
preparedProgram.shaderId = artifact.shaderId;
preparedProgram.sourceFingerprint = sourceFingerprint;
preparedProgram.artifact = artifact;
if (artifact.fragmentShaderSource.empty())
{
preparedProgram.error = "Cannot prepare an empty fragment shader.";
return false;
}
if (!BuildProgram(
artifact.fragmentShaderSource,
preparedProgram.program,
preparedProgram.vertexShader,
preparedProgram.fragmentShader,
preparedProgram.error))
{
preparedProgram.ReleaseGl();
return false;
}
preparedProgram.succeeded = true;
AssignSamplerUniforms(preparedProgram.program);
return true;
}
void RuntimeShaderRenderer::RenderFrame(uint64_t frameIndex, unsigned width, unsigned height) void RuntimeShaderRenderer::RenderFrame(uint64_t frameIndex, unsigned width, unsigned height)
{ {
if (mProgram == 0) if (mProgram == 0)
@@ -175,7 +231,7 @@ bool RuntimeShaderRenderer::BuildProgram(const std::string& fragmentShaderSource
return true; return true;
} }
void RuntimeShaderRenderer::AssignSamplerUniforms(GLuint program) const void RuntimeShaderRenderer::AssignSamplerUniforms(GLuint program)
{ {
glUseProgram(program); glUseProgram(program);
const GLint videoInputLocation = glGetUniformLocation(program, "gVideoInput"); const GLint videoInputLocation = glGetUniformLocation(program, "gVideoInput");
@@ -219,7 +275,7 @@ void RuntimeShaderRenderer::BindRuntimeTextures()
glActiveTexture(GL_TEXTURE0); glActiveTexture(GL_TEXTURE0);
} }
bool RuntimeShaderRenderer::CompileShader(GLenum shaderType, const char* source, GLuint& shader, std::string& error) const bool RuntimeShaderRenderer::CompileShader(GLenum shaderType, const char* source, GLuint& shader, std::string& error)
{ {
shader = glCreateShader(shaderType); shader = glCreateShader(shaderType);
glShaderSource(shader, 1, &source, nullptr); glShaderSource(shader, 1, &source, nullptr);

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "GLExtensions.h" #include "GLExtensions.h"
#include "RuntimeShaderProgram.h"
#include "../runtime/RuntimeShaderArtifact.h" #include "../runtime/RuntimeShaderArtifact.h"
#include <cstdint> #include <cstdint>
@@ -17,15 +18,22 @@ public:
bool CommitFragmentShader(const std::string& fragmentShaderSource, std::string& error); bool CommitFragmentShader(const std::string& fragmentShaderSource, std::string& error);
bool CommitShaderArtifact(const RuntimeShaderArtifact& artifact, std::string& error); bool CommitShaderArtifact(const RuntimeShaderArtifact& artifact, std::string& error);
bool CommitPreparedProgram(RuntimePreparedShaderProgram& preparedProgram, std::string& error);
bool HasProgram() const { return mProgram != 0; } bool HasProgram() const { return mProgram != 0; }
void RenderFrame(uint64_t frameIndex, unsigned width, unsigned height); void RenderFrame(uint64_t frameIndex, unsigned width, unsigned height);
void ShutdownGl(); void ShutdownGl();
static bool BuildPreparedProgram(
const std::string& layerId,
const std::string& sourceFingerprint,
const RuntimeShaderArtifact& artifact,
RuntimePreparedShaderProgram& preparedProgram);
private: private:
bool EnsureStaticGlResources(std::string& error); bool EnsureStaticGlResources(std::string& error);
bool CompileShader(GLenum shaderType, const char* source, GLuint& shader, std::string& error) const; static bool CompileShader(GLenum shaderType, const char* source, GLuint& shader, std::string& error);
bool BuildProgram(const std::string& fragmentShaderSource, GLuint& program, GLuint& vertexShader, GLuint& fragmentShader, std::string& error); static bool BuildProgram(const std::string& fragmentShaderSource, GLuint& program, GLuint& vertexShader, GLuint& fragmentShader, std::string& error);
void AssignSamplerUniforms(GLuint program) const; static void AssignSamplerUniforms(GLuint program);
void UpdateGlobalParams(uint64_t frameIndex, unsigned width, unsigned height); void UpdateGlobalParams(uint64_t frameIndex, unsigned width, unsigned height);
void BindRuntimeTextures(); void BindRuntimeTextures();
void DestroyProgram(); void DestroyProgram();

View File

@@ -11,7 +11,7 @@ Only the render thread may bind and use its primary OpenGL context.
Allowed on the render thread: Allowed on the render thread:
- GL resource creation and destruction for resources it owns - GL resource creation and destruction for resources it owns
- GL shader/program commit from an already-prepared artifact - GL shader/program swap from an already-prepared GL program
- drawing the next frame - drawing the next frame
- async readback queueing and completion polling - async readback queueing and completion polling
- publishing completed system-memory frames - publishing completed system-memory frames
@@ -28,7 +28,7 @@ Not allowed on the render thread:
- blocking console logging - blocking console logging
- config file discovery or parsing - config file discovery or parsing
If future GL preparation needs to happen off-thread, use an explicit shared-context GL prepare thread. Do not smuggle non-render work back into the cadence loop. If GL preparation happens off-thread, use an explicit shared-context GL prepare thread. Do not smuggle non-render work back into the cadence loop.
## 2. Render Cadence Does Not Chase Buffers ## 2. Render Cadence Does Not Chase Buffers
@@ -63,11 +63,12 @@ If no completed frame is available, record the miss and keep the ownership bound
Runtime shader work is split into two phases: Runtime shader work is split into two phases:
1. CPU/build phase outside the render thread 1. CPU/build phase outside the render thread
2. GL commit phase on the render thread 2. shared-context GL preparation outside the render thread where practical
3. GL program swap on the render thread
The CPU/build phase may parse manifests, invoke Slang, validate package shape, and prepare CPU-side data. The CPU/build phase may parse manifests, invoke Slang, validate package shape, and prepare CPU-side data.
The render thread receives a completed artifact and either commits it at a frame boundary or rejects it. A failed artifact must not disturb the current renderer. The render thread receives completed render-layer artifacts, asks the shared-context prepare worker to compile/link changed GL programs, and only swaps in prepared programs at a frame boundary. A failed artifact or failed GL preparation must not disturb the current renderer.
The display/render layer model is app-owned. It may track requested shaders, build state, display metadata, and render-ready artifacts, but it must not perform GL work or drive render cadence directly. The display/render layer model is app-owned. It may track requested shaders, build state, display metadata, and render-ready artifacts, but it must not perform GL work or drive render cadence directly.

View File

@@ -8,8 +8,8 @@ info:
The API is intended for local control tools and the bundled React UI. All mutating The API is intended for local control tools and the bundled React UI. All mutating
endpoints return a small action result object. endpoints return a small action result object.
WebSocket state streaming is planned for the control UI but is not currently served RenderCadenceCompositor serves `/api/state` for snapshots and `/ws` for local
by RenderCadenceCompositor. Clients should poll `/api/state` until `/ws` is implemented. WebSocket state updates consumed by the bundled control UI.
servers: servers:
- url: http://127.0.0.1:8080 - url: http://127.0.0.1:8080
description: Default local control server description: Default local control server
@@ -179,6 +179,24 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/RuntimeState" $ref: "#/components/schemas/RuntimeState"
/ws:
get:
tags: [State]
summary: Stream runtime state over WebSocket
description: |
Upgrades to a WebSocket connection. The server sends JSON runtime-state
snapshots using the same shape as `GET /api/state` whenever the serialized
state changes.
operationId: streamRuntimeState
responses:
"101":
description: WebSocket protocol upgrade accepted.
"400":
description: The request was not a valid WebSocket upgrade.
content:
text/plain:
schema:
type: string
/api/layers/add: /api/layers/add:
post: post:
tags: [Layers] tags: [Layers]

View File

@@ -63,6 +63,14 @@ void TestStateEndpointUsesCallback()
ExpectEquals(response.body, "{\"ok\":true}", "state endpoint returns callback JSON"); ExpectEquals(response.body, "{\"ok\":true}", "state endpoint returns callback JSON");
} }
void TestWebSocketAcceptKey()
{
using namespace RenderCadenceCompositor;
const std::string acceptKey = HttpControlServer::WebSocketAcceptKey("dGhlIHNhbXBsZSBub25jZQ==");
ExpectEquals(acceptKey, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", "WebSocket accept key matches RFC example");
}
void TestRootServesUiIndex() void TestRootServesUiIndex()
{ {
using namespace RenderCadenceCompositor; using namespace RenderCadenceCompositor;
@@ -157,6 +165,7 @@ int main()
{ {
TestParsesHttpRequest(); TestParsesHttpRequest();
TestStateEndpointUsesCallback(); TestStateEndpointUsesCallback();
TestWebSocketAcceptKey();
TestRootServesUiIndex(); TestRootServesUiIndex();
TestKnownPostEndpointReturnsActionError(); TestKnownPostEndpointReturnsActionError();
TestLayerPostEndpointsUseCallbacks(); TestLayerPostEndpointsUseCallbacks();