OSC stubs
All checks were successful
CI / React UI Build (push) Successful in 12s
CI / Native Windows Build And Tests (push) Successful in 2m11s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
Aiden
2026-05-30 20:42:38 +10:00
parent 04e0802ef2
commit 0f3db3ba1b
18 changed files with 298 additions and 16 deletions

View File

@@ -270,7 +270,7 @@ Use those docs to inspect the `/api/state`, layer control, and reload endpoints.
The control UI has a **Reload shaders** button. It rescans `shaders/`, re-reads manifests, refreshes shader availability/errors, updates active layer parameter definitions from changed manifests, and queues recompilation for every catalog-valid layer in the active stack. Missing shader packages are marked failed, and the previous working render stack remains active where possible until replacement builds commit successfully.
Each parameter row still exposes the intended OSC route in the UI, but OSC ingress is not wired in the current native host.
Each parameter row still exposes the intended OSC route in the UI. The native host has an OSC service stub that reports the configured bind/port in state, but it does not open a UDP listener or dispatch OSC messages yet.
The control UI currently still shows preset and screenshot controls from the intended route surface. Those endpoints return an unimplemented action result in the native host until their backend paths are wired.

View File

@@ -25,20 +25,20 @@
"oscBindAddress": {
"type": "string",
"default": "0.0.0.0",
"description": "OSC bind address reserved for the control surface. The current native host exposes this in state but does not start the OSC listener yet."
"description": "OSC bind address reserved for the control surface. The current native host reports this through an OSC status stub but does not start the UDP listener yet."
},
"oscPort": {
"type": "integer",
"minimum": 1,
"minimum": 0,
"maximum": 65535,
"default": 9000,
"description": "OSC UDP port reserved for the control surface."
"description": "OSC UDP port reserved for the control surface. Use 0 to mark the OSC stub disabled."
},
"oscSmoothing": {
"type": "number",
"minimum": 0,
"default": 0.18,
"description": "Reserved OSC smoothing amount exposed in runtime state."
"description": "Reserved OSC smoothing amount reported through the OSC status stub."
},
"input": {
"$ref": "#/$defs/input"

View File

@@ -34,7 +34,7 @@ Primary source areas:
- `src/runtime/shader`: background Slang build bridge and prepared shader artifact types
- `src/runtime/state`: runtime JSON helpers, parameter normalization, and debounced runtime-state persistence
- `src/runtime/text`: MSDF/MTSDF font atlas build and CPU-side prepared text texture composition
- `src/control`: command parsing, HTTP/WebSocket transport helpers, OpenAPI state JSON
- `src/control`: command parsing, HTTP/WebSocket transport helpers, OSC status stub, OpenAPI state JSON
- `src/app/RenderCadenceHttpRoutes.*`: this app's current HTTP endpoint map
- `src/preview`: optional non-consuming preview window
- `src/telemetry` and `src/logging`: runtime observation and logging
@@ -170,6 +170,8 @@ Unsupported routes return an action response with `ok: false`.
Forks can reuse the HTTP/WebSocket shell without keeping these endpoints by installing a different route callback.
`OscControlServer` is currently a lifecycle/status stub. It consumes startup OSC config, exposes configured/disabled/not-listening state through `/api/state`, and leaves UDP socket receive, OSC decode, and runtime dispatch unimplemented until that ingress boundary is built deliberately.
## Tests
Native tests cover the main non-GL contracts:

View File

@@ -21,7 +21,7 @@ These parts are the useful base for the fork:
- `src/render/readback`: BGRA8/UYVY8 PBO readback and completed-frame publication.
- `src/platform`: hidden GL window/context support.
- `src/app`: startup, config, video backend factory, runtime layer orchestration, preview, telemetry, and HTTP server hookup.
- `src/control/http`, `src/telemetry`, `src/logging`, and `ui`: useful if the new repo still wants a local control surface.
- `src/control/http`, `src/control/osc`, `src/telemetry`, `src/logging`, and `ui`: useful if the new repo still wants a local control surface. `control/osc` is currently a status/lifecycle stub, not a UDP listener.
- `src/app/RenderCadenceHttpRoutes.*`: useful only if the new repo keeps this app's current `/api/...` control surface.
## Replace Or Rework

View File

@@ -1,6 +1,6 @@
# OSC Control
This is the intended OSC control contract, but OSC ingress is not wired in the current `RenderCadenceCompositor` native host yet. The config fields and UI copy buttons are present for compatibility; use the REST layer parameter endpoints or the control UI for live parameter changes today.
This is the intended OSC control contract, but OSC ingress is not wired in the current `RenderCadenceCompositor` native host yet. The native host has an OSC service stub that consumes config and reports status in `/api/state`; it does not open a UDP listener or dispatch messages yet. Use the REST layer parameter endpoints or the control UI for live parameter changes today.
## Configuration
@@ -14,7 +14,7 @@ Set the UDP port in `config/runtime-host.json`:
}
```
When OSC ingress is implemented, `oscPort: 0` should disable the OSC listener, `oscBindAddress: "127.0.0.1"` should keep OSC local to the host, and `oscBindAddress: "0.0.0.0"` should listen on all IPv4 interfaces. `oscSmoothing` is reserved for a per-frame easing amount on numeric OSC controls.
Today, `oscPort: 0` marks the OSC stub disabled and any nonzero port marks it configured but not listening. When OSC ingress is implemented, `oscBindAddress: "127.0.0.1"` should keep OSC local to the host, and `oscBindAddress: "0.0.0.0"` should listen on all IPv4 interfaces. `oscSmoothing` is reserved for a per-frame easing amount on numeric OSC controls.
## Address Pattern

View File

@@ -730,6 +730,8 @@ components:
type: number
previewFps:
type: number
osc:
$ref: "#/components/schemas/AppOscStatus"
input:
type: object
properties:
@@ -771,6 +773,24 @@ components:
alphaRequired:
type: boolean
description: General output alpha request. When true, automatic output pixel-format selection uses an alpha-carrying system-frame format.
AppOscStatus:
type: object
properties:
configured:
type: boolean
description: True when OSC has a nonzero configured port.
listening:
type: boolean
description: False in the current native host because UDP OSC ingress is only stubbed.
bindAddress:
type: string
port:
type: number
smoothing:
type: number
statusMessage:
type: string
additionalProperties: false
RuntimeStatus:
type: object
properties:

View File

@@ -199,9 +199,9 @@ Currently consumed fields:
- `serverPort`
- `shaderLibrary`
- `oscBindAddress` (reported for compatibility; OSC ingress is not wired yet)
- `oscPort` (reported for compatibility; OSC ingress is not wired yet)
- `oscSmoothing` (reported for compatibility; OSC ingress is not wired yet)
- `oscBindAddress` (reported through the OSC service stub; UDP ingress is not wired yet)
- `oscPort` (use `0` to mark the OSC stub disabled; UDP ingress is not wired yet)
- `oscSmoothing` (reported through the OSC service stub; smoothing is reserved for future OSC ingress)
- `input.backend`
- `input.device`
- `input.resolution`
@@ -263,6 +263,8 @@ Current endpoints:
The HTTP server runs on its own thread. `control/http/HttpControlServer` owns socket lifetime, HTTP parsing, static UI/docs helpers, and WebSocket transport. The Render Cadence endpoint map lives in `app/RenderCadenceHttpRoutes`, which samples/copies telemetry through callbacks and translates POST bodies into runtime control commands. A fork can keep the HTTP/WebSocket shell and install a different route callback without inheriting this app's `/api/...` surface. Command execution is app-owned, so future OSC ingress can create the same commands without depending on HTTP route code. Control commands may update the display layer model, request debounced runtime-state persistence, start background shader builds, or publish an already-built render-layer snapshot, but they do not call render work or DeckLink scheduling directly.
`control/osc/OscControlServer` is currently a lifecycle/status stub. It consumes the startup OSC config and reports whether OSC is configured or disabled, but it deliberately does not open a UDP socket or dispatch parameter changes yet.
## Optional DeckLink Output
DeckLink output is an optional edge service in this app.
@@ -464,6 +466,7 @@ This app keeps the same core behavior but splits it into modules that can grow:
- `runtime/text/`: font atlas build and prepared text texture composition
- `control/`: control action results and runtime-state JSON presentation
- `control/http/`: local HTTP transport, static UI/OpenAPI serving helpers, and WebSocket updates
- `control/osc/`: OSC service lifecycle/status stub; no UDP listener or dispatch yet
- `app/RenderCadenceHttpRoutes`: this app's `/api/...` endpoint map behind the reusable HTTP server route callback
- `json/`: compact JSON serialization helpers
- `video/`: DeckLink output wrapper and scheduling thread

View File

@@ -51,6 +51,17 @@ void ApplyPort(const JsonValue& root, const char* key, unsigned short& target)
target = static_cast<unsigned short>(port);
}
void ApplyOptionalPort(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 >= 0.0 && port <= 65535.0)
target = static_cast<unsigned short>(port);
}
JsonValue NumberValue(double value)
{
return JsonValue(value);
@@ -136,7 +147,7 @@ bool ApplyAppConfigJson(const JsonValue& root, AppConfig& config, std::string* e
ApplyString(root, "shaderLibrary", config.shaderLibrary);
ApplyPort(root, "serverPort", config.http.preferredPort);
ApplyString(root, "oscBindAddress", config.oscBindAddress);
ApplyPort(root, "oscPort", config.oscPort);
ApplyOptionalPort(root, "oscPort", config.oscPort);
ApplyDouble(root, "oscSmoothing", config.oscSmoothing);
ApplyInputConfig(root, config);
ApplyOutputConfig(root, config);

View File

@@ -81,6 +81,17 @@ void ApplyPort(const JsonValue& root, const char* key, unsigned short& target)
target = static_cast<unsigned short>(port);
}
void ApplyOptionalPort(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 >= 0.0 && port <= 65535.0)
target = static_cast<unsigned short>(port);
}
void ApplyInputConfig(const JsonValue& root, AppConfig& config)
{
const JsonValue* input = Find(root, "input");
@@ -159,7 +170,7 @@ bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& err
ApplyString(root, "shaderLibrary", mConfig.shaderLibrary);
ApplyPort(root, "serverPort", mConfig.http.preferredPort);
ApplyString(root, "oscBindAddress", mConfig.oscBindAddress);
ApplyPort(root, "oscPort", mConfig.oscPort);
ApplyOptionalPort(root, "oscPort", mConfig.oscPort);
ApplyDouble(root, "oscSmoothing", mConfig.oscSmoothing);
ApplyInputConfig(root, mConfig);
ApplyOutputConfig(root, mConfig);

View File

@@ -8,6 +8,7 @@
#include "RuntimeLayerController.h"
#include "../logging/Logger.h"
#include "../control/RuntimeStateJson.h"
#include "../control/osc/OscControlServer.h"
#include "../json/JsonWriter.h"
#include "../preview/PreviewWindowThread.h"
#include "../telemetry/TelemetryHealthMonitor.h"
@@ -115,6 +116,7 @@ public:
StartPreviewWindow();
StartOptionalVideoOutput();
mTelemetryHealth.Start(mFrameExchange, *mOutput, mOutputThread, mRenderThread);
StartOscServer();
StartHttpServer();
Log("app", "RenderCadenceCompositor started.");
mStarted = true;
@@ -124,6 +126,7 @@ public:
void Stop()
{
mHttpServer.Stop();
mOscServer.Stop();
mTelemetryHealth.Stop();
mPreviewWindow.Stop();
mOutputThread.Stop();
@@ -325,10 +328,29 @@ private:
mVideoOutputEnabled,
mVideoOutputStatus,
mRuntimeLayers.ShaderCatalog(),
layerSnapshot
layerSnapshot,
&mOscServer.State()
});
}
void StartOscServer()
{
OscControlServerConfig oscConfig;
oscConfig.bindAddress = mConfig.oscBindAddress;
oscConfig.port = mConfig.oscPort;
oscConfig.smoothing = mConfig.oscSmoothing;
std::string error;
if (!mOscServer.Start(oscConfig, error))
{
LogWarning("osc", "OSC control stub did not start: " + error);
return;
}
if (!mOscServer.State().statusMessage.empty())
Log("osc", mOscServer.State().statusMessage);
}
std::string BuildConfigJson() const
{
AppConfig diskConfig = mConfig;
@@ -442,6 +464,7 @@ private:
TelemetryHealthMonitor mTelemetryHealth;
CadenceTelemetry mHttpTelemetry;
HttpControlServer mHttpServer;
OscControlServer mOscServer;
PreviewWindowThread mPreviewWindow;
RuntimeLayerController mRuntimeLayers;
std::function<VideoInputEdgeMetrics()> mVideoInputMetricsProvider;

View File

@@ -2,6 +2,7 @@
#include "../app/AppConfig.h"
#include "../app/AppConfigProvider.h"
#include "../control/osc/OscControlServer.h"
#include "../json/JsonWriter.h"
#include "RuntimeLayerModel.h"
#include "SupportedShaderCatalog.h"
@@ -22,6 +23,7 @@ struct RuntimeStateJsonInput
std::string videoOutputStatus;
const SupportedShaderCatalog& shaderCatalog;
const RuntimeLayerModelSnapshot& runtimeLayers;
const OscControlServerState* osc = nullptr;
};
inline void WriteVideoIoStatusJson(JsonWriter& writer, const RuntimeStateJsonInput& input)
@@ -279,6 +281,27 @@ inline void WriteLayersJson(JsonWriter& writer, const RuntimeStateJsonInput& inp
writer.EndArray();
}
inline void WriteOscJson(JsonWriter& writer, const RuntimeStateJsonInput& input)
{
const bool configured = input.osc ? input.osc->configured : input.config.oscPort != 0;
const bool listening = input.osc ? input.osc->listening : false;
const std::string bindAddress = input.osc ? input.osc->bindAddress : input.config.oscBindAddress;
const unsigned short port = input.osc ? input.osc->port : input.config.oscPort;
const double smoothing = input.osc ? input.osc->smoothing : input.config.oscSmoothing;
const std::string status = input.osc
? input.osc->statusMessage
: (configured ? "OSC ingress is not implemented yet." : "OSC ingress disabled by config.");
writer.BeginObject();
writer.KeyBool("configured", configured);
writer.KeyBool("listening", listening);
writer.KeyString("bindAddress", bindAddress);
writer.KeyUInt("port", port);
writer.KeyDouble("smoothing", smoothing);
writer.KeyString("statusMessage", status);
writer.EndObject();
}
inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
{
JsonWriter writer;
@@ -293,6 +316,8 @@ inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
writer.KeyBool("autoReload", input.config.autoReload);
writer.KeyUInt("maxTemporalHistoryFrames", static_cast<uint64_t>(input.config.maxTemporalHistoryFrames));
writer.KeyDouble("previewFps", input.config.previewFps);
writer.Key("osc");
WriteOscJson(writer, input);
writer.Key("input");
writer.BeginObject();
writer.KeyString("backend", input.config.input.backend);

View File

@@ -0,0 +1,40 @@
#include "OscControlServer.h"
#include <sstream>
#include <utility>
namespace RenderCadenceCompositor
{
bool OscControlServer::Start(OscControlServerConfig config, std::string& error)
{
error.clear();
Stop();
mConfig = std::move(config);
mState.bindAddress = mConfig.bindAddress;
mState.port = mConfig.port;
mState.smoothing = mConfig.smoothing;
if (mConfig.port == 0)
{
mState.configured = false;
mState.listening = false;
mState.statusMessage = "OSC ingress disabled by config.";
return true;
}
std::ostringstream status;
status << "OSC ingress stub configured at " << mConfig.bindAddress << ":" << mConfig.port
<< "; UDP listener is not implemented yet.";
mState.configured = true;
mState.listening = false;
mState.statusMessage = status.str();
return true;
}
void OscControlServer::Stop()
{
mConfig = OscControlServerConfig();
mState = OscControlServerState();
}
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include <string>
namespace RenderCadenceCompositor
{
struct OscControlServerConfig
{
std::string bindAddress = "127.0.0.1";
unsigned short port = 9000;
double smoothing = 0.0;
};
struct OscControlServerState
{
bool configured = false;
bool listening = false;
std::string bindAddress;
unsigned short port = 0;
double smoothing = 0.0;
std::string statusMessage;
};
class OscControlServer
{
public:
bool Start(OscControlServerConfig config, std::string& error);
void Stop();
bool IsConfigured() const { return mState.configured; }
bool IsListening() const { return mState.listening; }
const OscControlServerState& State() const { return mState; }
private:
OscControlServerConfig mConfig;
OscControlServerState mState;
};
}

View File

@@ -98,6 +98,11 @@ add_video_shader_test(RenderCadenceCompositorHttpControlServerTests
)
target_link_libraries(RenderCadenceCompositorHttpControlServerTests PRIVATE Ws2_32)
add_video_shader_test(OscControlServerTests
"${SRC_DIR}/control/osc/OscControlServer.cpp"
"${TEST_DIR}/OscControlServerTests.cpp"
)
add_video_shader_test(RenderCadenceCompositorAppConfigProviderTests
"${SRC_DIR}/app/AppConfig.cpp"
"${SRC_DIR}/app/AppRestart.cpp"

View File

@@ -0,0 +1,81 @@
#include "osc/OscControlServer.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 TestDisabledWhenPortIsZero()
{
using namespace RenderCadenceCompositor;
OscControlServer server;
OscControlServerConfig config;
config.bindAddress = "127.0.0.1";
config.port = 0;
config.smoothing = 0.25;
std::string error;
Expect(server.Start(config, error), "disabled OSC stub starts successfully");
Expect(error.empty(), "disabled OSC stub does not report an error");
Expect(!server.IsConfigured(), "port zero leaves OSC unconfigured");
Expect(!server.IsListening(), "port zero does not listen");
ExpectEquals(server.State().statusMessage, "OSC ingress disabled by config.", "disabled status is explicit");
}
void TestConfiguredStubDoesNotListenYet()
{
using namespace RenderCadenceCompositor;
OscControlServer server;
OscControlServerConfig config;
config.bindAddress = "0.0.0.0";
config.port = 9000;
config.smoothing = 0.18;
std::string error;
Expect(server.Start(config, error), "configured OSC stub starts successfully");
Expect(error.empty(), "configured OSC stub does not report an error");
Expect(server.IsConfigured(), "nonzero port marks OSC as configured");
Expect(!server.IsListening(), "stub does not claim to listen before UDP ingress exists");
ExpectEquals(server.State().bindAddress, "0.0.0.0", "bind address is retained");
Expect(server.State().statusMessage.find("UDP listener is not implemented yet") != std::string::npos, "status reports stub state");
}
}
int main()
{
TestDisabledWhenPortIsZero();
TestConfiguredStubDoesNotListenYet();
if (gFailures != 0)
{
std::cerr << gFailures << " OscControlServer test failure(s).\n";
return 1;
}
std::cout << "OscControlServer tests passed.\n";
return 0;
}

View File

@@ -115,6 +115,27 @@ void TestCommandLineOverrides()
Expect(config.http.preferredPort == 8282, "port CLI override applies");
}
void TestOscPortZeroIsAllowed()
{
using namespace RenderCadenceCompositor;
const std::filesystem::path path = std::filesystem::temp_directory_path() / "render-cadence-compositor-config-osc-disabled-test.json";
std::ofstream output(path, std::ios::binary);
output << "{ \"oscPort\": 0 }\n";
output.close();
std::string error;
AppConfigProvider provider;
Expect(provider.Load(path, error), "provider accepts oscPort zero");
Expect(provider.Config().oscPort == 0, "provider loads oscPort zero");
AppConfig parsed;
Expect(ParseAppConfigJson("{\"oscPort\":0}", parsed, error), "config JSON parser accepts oscPort zero");
Expect(parsed.oscPort == 0, "config JSON parser loads oscPort zero");
std::filesystem::remove(path);
}
void TestPreviewDefaultsAreOptIn()
{
using namespace RenderCadenceCompositor;
@@ -216,6 +237,7 @@ int main()
{
TestLoadsRuntimeHostConfig();
TestCommandLineOverrides();
TestOscPortZeroIsAllowed();
TestPreviewDefaultsAreOptIn();
TestConfigJsonRoundTrip();
TestOutputAlphaNormalizesLegacyKeying();

View File

@@ -119,6 +119,7 @@ int main()
ExpectContains(json, "\"height\":1080", "state JSON should expose output height");
ExpectContains(json, "\"input\":{\"backend\":\"decklink\",\"device\":\"default\",\"resolution\":\"1080p\",\"frameRate\":\"59.94\"}", "state JSON should expose nested input config");
ExpectContains(json, "\"output\":{\"backend\":\"decklink\",\"device\":\"default\",\"resolution\":\"1080p\",\"frameRate\":\"59.94\",\"pixelFormat\":\"auto\",\"systemFramePixelFormat\":\"8-bit BGRA\",\"keying\"", "state JSON should expose nested output config");
ExpectContains(json, "\"osc\":{\"configured\":true,\"listening\":false", "state JSON should expose OSC stub status");
ExpectContains(json, "\"videoOutput\":{\"enabled\":true,\"backend\":\"decklink\"", "state JSON should expose neutral video output status");
ExpectContains(json, "\"scheduleFailures\":2", "state JSON should expose neutral video output schedule failures");
ExpectContains(json, "\"backendMetrics\":{\"bufferedAvailable\":true,\"buffered\":4", "state JSON should expose backend-specific video output metrics");

View File

@@ -410,7 +410,7 @@ export function ConfigEditor({ onClose }) {
<h4>OSC</h4>
<div className="config-fields">
<TextField config={draft} label="Bind address" path="oscBindAddress" setConfig={setDraft} />
<NumberField config={draft} label="Port" min={1} path="oscPort" setConfig={setDraft} />
<NumberField config={draft} label="Port" min={0} path="oscPort" setConfig={setDraft} />
<NumberField config={draft} label="Smoothing" min={0} path="oscSmoothing" setConfig={setDraft} step={0.01} />
</div>
</section>