NDI discovery
This commit is contained in:
@@ -212,9 +212,10 @@ Current native test coverage includes:
|
||||
"device": "default",
|
||||
"resolution": "1080p",
|
||||
"frameRate": "59.94",
|
||||
"pixelFormat": "auto",
|
||||
"keying": {
|
||||
"external": true,
|
||||
"alphaRequired": false
|
||||
"alphaRequired": true
|
||||
}
|
||||
},
|
||||
"autoReload": true,
|
||||
@@ -222,11 +223,11 @@ Current native test coverage includes:
|
||||
}
|
||||
```
|
||||
|
||||
`input.backend` and `output.backend` select the concrete video I/O backend. Today the app supports `decklink` and `none`; future backends such as NDI, Spout, or file playback can be added behind the same factory boundary. `device` is currently accepted as a backend-neutral selector placeholder; DeckLink still chooses the first compatible device.
|
||||
`input.backend` and `output.backend` select the concrete video I/O backend. Today the app supports `decklink`, `ndi`, and `none`; future backends such as Spout or file playback can be added behind the same factory boundary. `device` is a backend-neutral selector placeholder: DeckLink still chooses the first compatible device, NDI input uses it as the source selector, and NDI output uses it as the sender name.
|
||||
|
||||
`input.resolution`/`input.frameRate` select the video capture mode. `output.resolution`/`output.frameRate` select the playout mode through a backend-neutral mode description; the current DeckLink backend maps that mode to a `BMDDisplayMode` at the DeckLink boundary. Supported modes still depend on the installed card and driver. The shader stack runs at input resolution and the final rendered frame is scaled once into the configured output mode. Common examples include `720p`/`50`, `720p`/`59.94`, `1080i`/`50`, `1080i`/`59.94`, `1080p`/`25`, `1080p`/`50`, `1080p`/`59.94`, and `2160p`/`59.94`.
|
||||
|
||||
The checked-in config uses the nested `input` and `output` objects as the supported shape.
|
||||
The checked-in config uses the nested `input` and `output` objects as the supported shape. When `input.backend` is `ndi`, the host-config editor uses NDI discovery to offer source-name suggestions in the `input.device` field while still allowing manual entry. The control UI presents `output.keying.external` and `output.keying.alphaRequired` as one **Output alpha** control; DeckLink maps that to external keying, while NDI only uses it to request an alpha-carrying system-frame format.
|
||||
|
||||
The control UI is available at:
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
"device": "Shader",
|
||||
"resolution": "1080p",
|
||||
"frameRate": "59.94",
|
||||
"pixelFormat": "auto",
|
||||
"keying": {
|
||||
"external": true,
|
||||
"alphaRequired": false
|
||||
"external": false,
|
||||
"alphaRequired": true
|
||||
}
|
||||
},
|
||||
"autoReload": true,
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"exclusiveMinimum": 0,
|
||||
"default": 59.94,
|
||||
"description": "Target repaint rate for the optional preview window. It does not change render/output cadence."
|
||||
},
|
||||
"runtimeShaderId": {
|
||||
"type": "string",
|
||||
"default": "happy-accident",
|
||||
"description": "Startup shader package id used when no saved runtime layer stack is restored."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -178,20 +183,30 @@
|
||||
"default": "59.94",
|
||||
"description": "Output render cadence and video mode frame rate."
|
||||
},
|
||||
"pixelFormat": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"bgra8",
|
||||
"uyvy8"
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "Requested system-memory output pixel format. Auto uses UYVY8 for DeckLink/NDI unless Output alpha is enabled, then BGRA8."
|
||||
},
|
||||
"keying": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "DeckLink keying options.",
|
||||
"description": "Output alpha options. The control UI exposes these as a single Output alpha control.",
|
||||
"properties": {
|
||||
"external": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Requests DeckLink external keying when the selected output device supports it."
|
||||
"description": "DeckLink external-keyer backing field. The control UI only writes this true for DeckLink output."
|
||||
},
|
||||
"alphaRequired": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Requires alpha-capable output format support during DeckLink output setup."
|
||||
"description": "General output alpha request. When true, automatic output pixel-format selection uses an alpha-carrying system-frame format."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -193,6 +193,19 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HostConfigResponse"
|
||||
/api/ndi/sources:
|
||||
get:
|
||||
tags: [Config]
|
||||
summary: Discover NDI sources
|
||||
description: Returns currently discoverable NDI sources for the host-config input device picker. Manual source names remain valid even when discovery returns no matches.
|
||||
operationId: getNdiSources
|
||||
responses:
|
||||
"200":
|
||||
description: Current NDI source discovery result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NdiSourcesResponse"
|
||||
/api/config/save:
|
||||
post:
|
||||
tags: [Config]
|
||||
@@ -576,6 +589,28 @@ components:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
NdiSourcesResponse:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
sources:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/NdiSource"
|
||||
additionalProperties: false
|
||||
NdiSource:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: User-visible NDI source name, usually `MACHINE (SOURCE)`.
|
||||
urlAddress:
|
||||
type: string
|
||||
description: SDK-provided URL/address for diagnostics. Saved host config still uses the source name.
|
||||
additionalProperties: false
|
||||
HostConfig:
|
||||
type: object
|
||||
properties:
|
||||
@@ -639,8 +674,10 @@ components:
|
||||
properties:
|
||||
external:
|
||||
type: boolean
|
||||
description: DeckLink external-keyer backing field. The bundled UI exposes this together with alphaRequired as Output alpha and only writes it true for DeckLink output.
|
||||
alphaRequired:
|
||||
type: boolean
|
||||
description: General output alpha request. When true, automatic output pixel-format selection uses an alpha-carrying system-frame format.
|
||||
additionalProperties: false
|
||||
additionalProperties: false
|
||||
RuntimeState:
|
||||
@@ -725,10 +762,15 @@ components:
|
||||
keying:
|
||||
type: object
|
||||
properties:
|
||||
outputAlpha:
|
||||
type: boolean
|
||||
description: Operator-facing derived alpha state. True when alphaRequired or the DeckLink external-keyer backing field is true.
|
||||
external:
|
||||
type: boolean
|
||||
description: DeckLink external-keyer backing field. The bundled UI exposes this together with alphaRequired as Output alpha and only writes it true for DeckLink output.
|
||||
alphaRequired:
|
||||
type: boolean
|
||||
description: General output alpha request. When true, automatic output pixel-format selection uses an alpha-carrying system-frame format.
|
||||
RuntimeStatus:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -211,8 +211,8 @@ Currently consumed fields:
|
||||
- `output.resolution`
|
||||
- `output.frameRate`
|
||||
- `output.pixelFormat` (`auto`, `bgra8`, or `uyvy8`)
|
||||
- `output.keying.external`
|
||||
- `output.keying.alphaRequired`
|
||||
- `output.keying.external` (DeckLink external-keyer backing field)
|
||||
- `output.keying.alphaRequired` (general output alpha request)
|
||||
- `autoReload`
|
||||
- `maxTemporalHistoryFrames`
|
||||
- `previewEnabled`
|
||||
@@ -220,9 +220,11 @@ Currently consumed fields:
|
||||
|
||||
`input.backend` and `output.backend` currently support `decklink`, `ndi`, and `none`. Backend creation is routed through the app-side video backend factory, so new concrete backends can be added without making `main` or the render cadence path own their startup details.
|
||||
|
||||
`output.pixelFormat=auto` chooses UYVY8 for DeckLink/NDI output unless alpha output is required, in which case it uses BGRA8. Explicit `uyvy8` requests are rejected back to BGRA8 when alpha is required. V210/YUVA remain explicit unsupported render-readback states until matching render-thread packers exist.
|
||||
`output.pixelFormat=auto` chooses UYVY8 for DeckLink/NDI output unless output alpha is enabled, in which case it uses BGRA8. Explicit `uyvy8` requests are rejected back to BGRA8 when alpha is required. V210/YUVA remain explicit unsupported render-readback states until matching render-thread packers exist.
|
||||
|
||||
The control UI includes a host-config editor backed by `GET /api/config` and `POST /api/config/save`. Saving rewrites `config/runtime-host.json` and marks the app as needing restart. Startup-owned services such as render dimensions, video backend selection, frame rate, output pixel format, HTTP port, and preview settings apply after `POST /api/app/restart` starts a fresh native host process.
|
||||
The control UI exposes keying as a single `Output alpha` control. It writes `output.keying.alphaRequired=true` for any alpha-capable output path and also writes `output.keying.external=true` when the selected output backend is DeckLink, because DeckLink external keying is the current hardware path that consumes that alpha. NDI ignores external keying and only uses the alpha requirement to select an alpha-carrying system-frame format.
|
||||
|
||||
The control UI includes a host-config editor backed by `GET /api/config` and `POST /api/config/save`. When input backend is NDI, the editor also calls `GET /api/ndi/sources` to populate the input device field from NDI discovery while preserving manual source-name entry. Saving rewrites `config/runtime-host.json` and marks the app as needing restart. Startup-owned services such as render dimensions, video backend selection, frame rate, output pixel format, HTTP port, and preview settings apply after `POST /api/app/restart` starts a fresh native host process.
|
||||
|
||||
When `previewEnabled` is true, the preview window runs on `PreviewWindowThread`. It paints BGRA8 system-memory frames directly and decodes UYVY8 frames to BGRA for Win32/GDI after render readback has already completed, so it does not bind GL and does not consume frames from video output. `previewFps` controls the preview repaint cadence; the default is 60 fps and `config/runtime-host.json` tracks the shipped 59.94 output cadence.
|
||||
|
||||
@@ -250,6 +252,8 @@ Current endpoints:
|
||||
|
||||
- `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/config`: returns active and saved startup host config
|
||||
- `GET /api/ndi/sources`: returns currently discoverable NDI source names for host-config input selection
|
||||
- `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`: serves Swagger UI
|
||||
|
||||
@@ -91,6 +91,10 @@ void ApplyOutputConfig(const JsonValue& root, AppConfig& config)
|
||||
ApplyBool(*keying, "external", config.output.externalKeyingEnabled);
|
||||
ApplyBool(*keying, "alphaRequired", config.output.outputAlphaRequired);
|
||||
}
|
||||
if (config.output.externalKeyingEnabled)
|
||||
config.output.outputAlphaRequired = true;
|
||||
if (config.output.backend != "decklink")
|
||||
config.output.externalKeyingEnabled = false;
|
||||
}
|
||||
|
||||
JsonValue InputConfigToJson(const VideoInputAppConfig& input)
|
||||
|
||||
@@ -111,6 +111,10 @@ void ApplyOutputConfig(const JsonValue& root, AppConfig& config)
|
||||
ApplyBool(*keying, "external", config.output.externalKeyingEnabled);
|
||||
ApplyBool(*keying, "alphaRequired", config.output.outputAlphaRequired);
|
||||
}
|
||||
if (config.output.externalKeyingEnabled)
|
||||
config.output.outputAlphaRequired = true;
|
||||
if (config.output.backend != "decklink")
|
||||
config.output.externalKeyingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
#include "RuntimeLayerController.h"
|
||||
#include "../logging/Logger.h"
|
||||
#include "../control/RuntimeStateJson.h"
|
||||
#include "../json/JsonWriter.h"
|
||||
#include "../preview/PreviewWindowThread.h"
|
||||
#include "../telemetry/TelemetryHealthMonitor.h"
|
||||
#include "../video/ndi/NdiSourceDiscovery.h"
|
||||
#include "VideoIOEdges.h"
|
||||
#include "VideoOutputThread.h"
|
||||
|
||||
@@ -20,6 +22,7 @@
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
namespace RenderCadenceCompositor
|
||||
{
|
||||
@@ -250,6 +253,9 @@ private:
|
||||
callbacks.getConfigJson = [this]() {
|
||||
return BuildConfigJson();
|
||||
};
|
||||
callbacks.getNdiSourcesJson = [this]() {
|
||||
return BuildNdiSourcesJson();
|
||||
};
|
||||
callbacks.addLayer = [this](const std::string& body) {
|
||||
return mRuntimeLayers.HandleAddLayer(body);
|
||||
};
|
||||
@@ -341,6 +347,31 @@ private:
|
||||
return SerializeJson(root);
|
||||
}
|
||||
|
||||
std::string BuildNdiSourcesJson() const
|
||||
{
|
||||
std::vector<NdiSourceInfo> sources;
|
||||
std::string error;
|
||||
const bool discovered = DiscoverNdiSources(sources, error);
|
||||
|
||||
JsonWriter writer;
|
||||
writer.BeginObject();
|
||||
writer.KeyBool("ok", discovered);
|
||||
if (!error.empty())
|
||||
writer.KeyString("error", error);
|
||||
writer.Key("sources");
|
||||
writer.BeginArray();
|
||||
for (const NdiSourceInfo& source : sources)
|
||||
{
|
||||
writer.BeginObject();
|
||||
writer.KeyString("name", source.name);
|
||||
writer.KeyString("urlAddress", source.urlAddress);
|
||||
writer.EndObject();
|
||||
}
|
||||
writer.EndArray();
|
||||
writer.EndObject();
|
||||
return writer.StringValue();
|
||||
}
|
||||
|
||||
ControlActionResult HandleConfigSave(const std::string& body)
|
||||
{
|
||||
AppConfig nextConfig;
|
||||
|
||||
@@ -310,6 +310,7 @@ inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
|
||||
writer.KeyString("systemFramePixelFormat", VideoIOPixelFormatName(input.config.output.systemFramePixelFormat));
|
||||
writer.Key("keying");
|
||||
writer.BeginObject();
|
||||
writer.KeyBool("outputAlpha", input.config.output.outputAlphaRequired || input.config.output.externalKeyingEnabled);
|
||||
writer.KeyBool("external", input.config.output.externalKeyingEnabled);
|
||||
writer.KeyBool("alphaRequired", input.config.output.outputAlphaRequired);
|
||||
writer.EndObject();
|
||||
|
||||
@@ -27,6 +27,7 @@ struct HttpControlServerCallbacks
|
||||
{
|
||||
std::function<std::string()> getStateJson;
|
||||
std::function<std::string()> getConfigJson;
|
||||
std::function<std::string()> getNdiSourcesJson;
|
||||
std::function<ControlActionResult(const std::string&)> addLayer;
|
||||
std::function<ControlActionResult(const std::string&)> removeLayer;
|
||||
std::function<ControlActionResult(const std::string&, const std::string&)> executePost;
|
||||
|
||||
@@ -36,6 +36,12 @@ HttpControlServer::HttpResponse HttpControlServer::ServeGet(const HttpRequest& r
|
||||
return JsonResponse("200 OK", mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}");
|
||||
if (request.path == "/api/config")
|
||||
return JsonResponse("200 OK", mCallbacks.getConfigJson ? mCallbacks.getConfigJson() : "{}");
|
||||
if (request.path == "/api/ndi/sources")
|
||||
return JsonResponse(
|
||||
"200 OK",
|
||||
mCallbacks.getNdiSourcesJson
|
||||
? mCallbacks.getNdiSourcesJson()
|
||||
: "{\"ok\":false,\"sources\":[],\"error\":\"NDI source discovery is not available.\"}");
|
||||
if (request.path == "/openapi.yaml" || request.path == "/docs/openapi.yaml")
|
||||
return ServeOpenApiSpec();
|
||||
if (request.path == "/docs" || request.path == "/docs/")
|
||||
|
||||
@@ -299,7 +299,7 @@ bool DeckLinkSession::SelectPreferredFormats(const VideoFormatSelection& videoMo
|
||||
|
||||
mState.outputPixelFormat = systemFramePixelFormat;
|
||||
if (outputAlphaRequired)
|
||||
mState.formatStatusMessage += "External keying requires alpha; using BGRA8 system frames. ";
|
||||
mState.formatStatusMessage += "Output alpha requires BGRA8 system frames. ";
|
||||
|
||||
int deckLinkOutputRowBytes = 0;
|
||||
if (output->RowBytesForPixelFormat(DeckLinkPixelFormatForVideoIO(mState.outputPixelFormat), mState.outputFrameSize.width, &deckLinkOutputRowBytes) != S_OK)
|
||||
@@ -380,7 +380,7 @@ bool DeckLinkSession::ConfigureOutput(OutputFrameCallback callback, const VideoF
|
||||
}
|
||||
else if (mState.supportsExternalKeying)
|
||||
{
|
||||
mState.statusMessage = "Selected DeckLink output supports external keying. Set output.keying.external to true in runtime-host.json to request it.";
|
||||
mState.statusMessage = "Selected DeckLink output supports external keying. Enable Output alpha in host config to request it.";
|
||||
}
|
||||
|
||||
const VideoPlayoutPolicy policy = NormalizeVideoPlayoutPolicy(mPlayoutPolicy);
|
||||
|
||||
@@ -148,11 +148,6 @@ bool NdiOutput::Initialize(const VideoOutputEdgeConfig& config, CompletionCallba
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.outputAlphaRequired)
|
||||
{
|
||||
mState.statusMessage = "NDI output can carry BGRA alpha when configured; output.keying.alphaRequired is a DeckLink-only requirement.";
|
||||
}
|
||||
|
||||
if (!AcquireNdiRuntime())
|
||||
{
|
||||
error = "NDI runtime initialization failed. The CPU/runtime might not support NDI.";
|
||||
@@ -175,6 +170,8 @@ bool NdiOutput::Initialize(const VideoOutputEdgeConfig& config, CompletionCallba
|
||||
|
||||
mInitialized.store(true, std::memory_order_release);
|
||||
mState.statusMessage = "NDI output sender '" + mSenderName + "' initialized.";
|
||||
if (config.outputAlphaRequired)
|
||||
mState.statusMessage += " Output alpha is enabled; external keying is ignored for NDI.";
|
||||
Log("ndi-output", mState.statusMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
72
src/video/ndi/NdiSourceDiscovery.cpp
Normal file
72
src/video/ndi/NdiSourceDiscovery.cpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#include "NdiSourceDiscovery.h"
|
||||
|
||||
#include "NdiRuntime.h"
|
||||
|
||||
#include <Processing.NDI.Lib.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
namespace RenderCadenceCompositor
|
||||
{
|
||||
bool DiscoverNdiSources(std::vector<NdiSourceInfo>& sources, std::string& error, uint32_t waitMilliseconds)
|
||||
{
|
||||
sources.clear();
|
||||
if (!AcquireNdiRuntime())
|
||||
{
|
||||
error = "NDI runtime initialization failed. The CPU/runtime might not support NDI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
NDIlib_find_create_t settings;
|
||||
std::memset(&settings, 0, sizeof(settings));
|
||||
settings.show_local_sources = true;
|
||||
|
||||
NDIlib_find_instance_t finder = NDIlib_find_create_v2(&settings);
|
||||
if (finder == nullptr)
|
||||
{
|
||||
ReleaseNdiRuntime();
|
||||
error = "NDI source finder creation failed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t sourceCount = 0;
|
||||
const NDIlib_source_t* ndiSources = NDIlib_find_get_current_sources(finder, &sourceCount);
|
||||
if (sourceCount == 0 && waitMilliseconds > 0)
|
||||
{
|
||||
NDIlib_find_wait_for_sources(finder, waitMilliseconds);
|
||||
ndiSources = NDIlib_find_get_current_sources(finder, &sourceCount);
|
||||
}
|
||||
|
||||
if (ndiSources != nullptr)
|
||||
{
|
||||
sources.reserve(sourceCount);
|
||||
for (uint32_t index = 0; index < sourceCount; ++index)
|
||||
{
|
||||
const char* name = ndiSources[index].p_ndi_name;
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
continue;
|
||||
|
||||
NdiSourceInfo source;
|
||||
source.name = name;
|
||||
if (ndiSources[index].p_url_address != nullptr)
|
||||
source.urlAddress = ndiSources[index].p_url_address;
|
||||
sources.push_back(std::move(source));
|
||||
}
|
||||
}
|
||||
|
||||
NDIlib_find_destroy(finder);
|
||||
ReleaseNdiRuntime();
|
||||
|
||||
std::sort(sources.begin(), sources.end(), [](const NdiSourceInfo& lhs, const NdiSourceInfo& rhs) {
|
||||
return lhs.name < rhs.name;
|
||||
});
|
||||
sources.erase(std::unique(sources.begin(), sources.end(), [](const NdiSourceInfo& lhs, const NdiSourceInfo& rhs) {
|
||||
return lhs.name == rhs.name;
|
||||
}), sources.end());
|
||||
|
||||
error.clear();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
16
src/video/ndi/NdiSourceDiscovery.h
Normal file
16
src/video/ndi/NdiSourceDiscovery.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace RenderCadenceCompositor
|
||||
{
|
||||
struct NdiSourceInfo
|
||||
{
|
||||
std::string name;
|
||||
std::string urlAddress;
|
||||
};
|
||||
|
||||
bool DiscoverNdiSources(std::vector<NdiSourceInfo>& sources, std::string& error, uint32_t waitMilliseconds = 250);
|
||||
}
|
||||
@@ -149,6 +149,46 @@ void TestConfigJsonRoundTrip()
|
||||
Expect(parsed.runtimeShaderId == "solid-color", "runtime shader id round trips");
|
||||
}
|
||||
|
||||
void TestOutputAlphaNormalizesLegacyKeying()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
|
||||
const std::string deckLinkJson =
|
||||
"{"
|
||||
"\"output\":{"
|
||||
"\"backend\":\"decklink\","
|
||||
"\"keying\":{\"external\":true,\"alphaRequired\":false}"
|
||||
"}"
|
||||
"}";
|
||||
|
||||
AppConfig parsed;
|
||||
std::string error;
|
||||
Expect(ParseAppConfigJson(deckLinkJson, parsed, error), "legacy DeckLink keying config parses");
|
||||
Expect(parsed.output.externalKeyingEnabled, "DeckLink external keying remains enabled");
|
||||
Expect(parsed.output.outputAlphaRequired, "DeckLink external keying implies output alpha");
|
||||
|
||||
const std::filesystem::path path = std::filesystem::temp_directory_path() / "render-cadence-compositor-config-alpha-test.json";
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output
|
||||
<< "{\n"
|
||||
<< " \"output\": {\n"
|
||||
<< " \"backend\": \"ndi\",\n"
|
||||
<< " \"keying\": {\n"
|
||||
<< " \"external\": true,\n"
|
||||
<< " \"alphaRequired\": false\n"
|
||||
<< " }\n"
|
||||
<< " }\n"
|
||||
<< "}\n";
|
||||
output.close();
|
||||
|
||||
AppConfigProvider provider;
|
||||
Expect(provider.Load(path, error), "legacy NDI keying config loads");
|
||||
Expect(provider.Config().output.outputAlphaRequired, "legacy external keying implies NDI output alpha");
|
||||
Expect(!provider.Config().output.externalKeyingEnabled, "NDI ignores external keying backing field");
|
||||
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
void TestHelpers()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
@@ -178,6 +218,7 @@ int main()
|
||||
TestCommandLineOverrides();
|
||||
TestPreviewDefaultsAreOptIn();
|
||||
TestConfigJsonRoundTrip();
|
||||
TestOutputAlphaNormalizesLegacyKeying();
|
||||
TestHelpers();
|
||||
|
||||
if (gFailures != 0)
|
||||
|
||||
@@ -83,6 +83,25 @@ void TestConfigEndpointUsesCallback()
|
||||
ExpectEquals(response.body, "{\"diskLoaded\":true}", "config endpoint returns callback JSON");
|
||||
}
|
||||
|
||||
void TestNdiSourcesEndpointUsesCallback()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
|
||||
HttpControlServer server;
|
||||
HttpControlServerCallbacks callbacks;
|
||||
callbacks.getNdiSourcesJson = []() { return std::string("{\"ok\":true,\"sources\":[{\"name\":\"DESKTOP (Camera)\"}]}"); };
|
||||
server.SetCallbacksForTest(callbacks);
|
||||
|
||||
HttpControlServer::HttpRequest request;
|
||||
request.method = "GET";
|
||||
request.path = "/api/ndi/sources";
|
||||
|
||||
const HttpControlServer::HttpResponse response = server.RouteRequestForTest(request);
|
||||
ExpectEquals(response.status, "200 OK", "NDI sources endpoint succeeds");
|
||||
ExpectEquals(response.contentType, "application/json", "NDI sources endpoint is JSON");
|
||||
Expect(response.body.find("DESKTOP (Camera)") != std::string::npos, "NDI sources endpoint returns callback JSON");
|
||||
}
|
||||
|
||||
void TestWebSocketAcceptKey()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
@@ -242,6 +261,7 @@ int main()
|
||||
TestParsesHttpRequest();
|
||||
TestStateEndpointUsesCallback();
|
||||
TestConfigEndpointUsesCallback();
|
||||
TestNdiSourcesEndpointUsesCallback();
|
||||
TestWebSocketAcceptKey();
|
||||
TestRootServesUiIndex();
|
||||
TestKnownPostEndpointReturnsActionError();
|
||||
|
||||
@@ -27,6 +27,7 @@ function App() {
|
||||
const [expandedLayerIds, setExpandedLayerIds] = useState([]);
|
||||
const [dragLayerId, setDragLayerId] = useState(null);
|
||||
const [dropTargetLayerId, setDropTargetLayerId] = useState(null);
|
||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||
|
||||
const layers = appState?.layers ?? [];
|
||||
const shaders = (appState?.shaders ?? []).filter((shader) => shader.available !== false);
|
||||
@@ -41,6 +42,15 @@ function App() {
|
||||
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
|
||||
}, [layers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configEditorOpen) return undefined;
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Escape") setConfigEditorOpen(false);
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [configEditorOpen]);
|
||||
|
||||
if (!appState) {
|
||||
return (
|
||||
<main className="layout">
|
||||
@@ -91,8 +101,7 @@ function App() {
|
||||
|
||||
<section className="dashboard-grid">
|
||||
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} />
|
||||
<StackPresetToolbar />
|
||||
<ConfigEditor />
|
||||
<StackPresetToolbar onOpenConfig={() => setConfigEditorOpen(true)} />
|
||||
</section>
|
||||
|
||||
<LayerStack
|
||||
@@ -108,6 +117,19 @@ function App() {
|
||||
/>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
{configEditorOpen && (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setConfigEditorOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="host-config-heading">
|
||||
<ConfigEditor onClose={() => setConfigEditorOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Power, RefreshCw, Save } from "lucide-react";
|
||||
import { Power, RefreshCw, Save, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { fetchJson, postJsonResult } from "../api/controlApi";
|
||||
@@ -50,6 +50,41 @@ function TextField({ config, label, path, setConfig }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSources, setConfig }) {
|
||||
if (readPath(config, "input.backend") !== "ndi") {
|
||||
return <TextField config={config} label="Device" path="input.device" setConfig={setConfig} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label="Device">
|
||||
<div className="config-combo-field">
|
||||
<input
|
||||
list="input-ndi-sources"
|
||||
type="text"
|
||||
value={readPath(config, "input.device") ?? ""}
|
||||
onChange={(event) => setConfig((current) => writePath(current, "input.device", event.target.value))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
disabled={ndiSourcesBusy}
|
||||
onClick={onRefreshNdiSources}
|
||||
title="Refresh NDI sources"
|
||||
>
|
||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="input-ndi-sources">
|
||||
<option value="default" />
|
||||
<option value="auto" />
|
||||
{ndiSources.map((source) => (
|
||||
<option key={source.name} value={source.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ config, label, min, path, setConfig, step = 1 }) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
@@ -64,12 +99,31 @@ function NumberField({ config, label, min, path, setConfig, step = 1 }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ config, label, options, path, setConfig }) {
|
||||
function outputAlphaEnabled(config) {
|
||||
const keying = readPath(config, "output.keying") ?? {};
|
||||
return Boolean(keying.alphaRequired || keying.external);
|
||||
}
|
||||
|
||||
function writeOutputAlpha(config, enabled, backend = readPath(config, "output.backend")) {
|
||||
let next = writePath(config, "output.keying.alphaRequired", enabled);
|
||||
next = writePath(next, "output.keying.external", enabled && backend === "decklink");
|
||||
return next;
|
||||
}
|
||||
|
||||
function writeOutputBackend(config, backend) {
|
||||
return writeOutputAlpha(writePath(config, "output.backend", backend), outputAlphaEnabled(config), backend);
|
||||
}
|
||||
|
||||
function SelectField({ config, label, onValueChange, options, path, setConfig }) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<select
|
||||
value={readPath(config, path) ?? options[0]}
|
||||
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
|
||||
onChange={(event) =>
|
||||
setConfig((current) =>
|
||||
onValueChange ? onValueChange(current, event.target.value) : writePath(current, path, event.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
@@ -81,6 +135,19 @@ function SelectField({ config, label, options, path, setConfig }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OutputAlphaField({ config, setConfig }) {
|
||||
return (
|
||||
<label className="toggle toggle--field config-toggle">
|
||||
<input
|
||||
checked={outputAlphaEnabled(config)}
|
||||
type="checkbox"
|
||||
onChange={(event) => setConfig((current) => writeOutputAlpha(current, event.target.checked))}
|
||||
/>
|
||||
<span>Output alpha</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleField({ config, label, path, setConfig }) {
|
||||
return (
|
||||
<label className="toggle toggle--field config-toggle">
|
||||
@@ -94,15 +161,18 @@ function ToggleField({ config, label, path, setConfig }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigEditor() {
|
||||
export function ConfigEditor({ onClose }) {
|
||||
const [draft, setDraft] = useState(null);
|
||||
const [saved, setSaved] = useState(null);
|
||||
const [path, setPath] = useState("");
|
||||
const [restartRequired, setRestartRequired] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [ndiSources, setNdiSources] = useState([]);
|
||||
const [ndiSourcesBusy, setNdiSourcesBusy] = useState(false);
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(draft ?? {}) !== JSON.stringify(saved ?? {}), [draft, saved]);
|
||||
const inputBackend = readPath(draft, "input.backend");
|
||||
|
||||
async function loadConfig() {
|
||||
setBusy(true);
|
||||
@@ -121,10 +191,32 @@ export function ConfigEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNdiSources() {
|
||||
setNdiSourcesBusy(true);
|
||||
try {
|
||||
const response = await fetchJson("/api/ndi/sources");
|
||||
if (response.ok === false) {
|
||||
throw new Error(response.error || "NDI source discovery failed.");
|
||||
}
|
||||
setNdiSources(response.sources ?? []);
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
setNdiSources([]);
|
||||
} finally {
|
||||
setNdiSourcesBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputBackend === "ndi") {
|
||||
loadNdiSources();
|
||||
}
|
||||
}, [inputBackend]);
|
||||
|
||||
async function saveConfig() {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
@@ -157,10 +249,17 @@ export function ConfigEditor() {
|
||||
return (
|
||||
<div className="panel config-panel">
|
||||
<div className="panel__header">
|
||||
<h3>Host config</h3>
|
||||
<button type="button" className="icon-button" onClick={loadConfig} title="Reload config">
|
||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
<h3 id="host-config-heading">Host config</h3>
|
||||
<div className="config-panel__actions">
|
||||
<button type="button" className="icon-button" onClick={loadConfig} title="Reload config">
|
||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button type="button" className="icon-button" onClick={onClose} title="Close">
|
||||
<X size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">{status || "Loading config."}</p>
|
||||
</div>
|
||||
@@ -171,7 +270,7 @@ export function ConfigEditor() {
|
||||
<div className="panel config-panel">
|
||||
<div className="panel__header config-panel__header">
|
||||
<div>
|
||||
<h3>Host config</h3>
|
||||
<h3 id="host-config-heading">Host config</h3>
|
||||
<p className="muted">{path || "runtime-host.json"}</p>
|
||||
</div>
|
||||
<div className="config-panel__actions">
|
||||
@@ -191,10 +290,50 @@ export function ConfigEditor() {
|
||||
<Power size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
{onClose && (
|
||||
<button type="button" className="icon-button" onClick={onClose} title="Close">
|
||||
<X size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-grid">
|
||||
<section className="config-section">
|
||||
<h4>Input</h4>
|
||||
<div className="config-fields">
|
||||
<SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} />
|
||||
<InputDeviceField
|
||||
config={draft}
|
||||
ndiSources={ndiSources}
|
||||
ndiSourcesBusy={ndiSourcesBusy}
|
||||
onRefreshNdiSources={loadNdiSources}
|
||||
setConfig={setDraft}
|
||||
/>
|
||||
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="input.resolution" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="input.frameRate" setConfig={setDraft} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>Output</h4>
|
||||
<div className="config-fields">
|
||||
<SelectField
|
||||
config={draft}
|
||||
label="Backend"
|
||||
onValueChange={writeOutputBackend}
|
||||
options={backendOptions}
|
||||
path="output.backend"
|
||||
setConfig={setDraft}
|
||||
/>
|
||||
<TextField config={draft} label="Device" path="output.device" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="output.resolution" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="output.frameRate" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Pixel format" options={pixelFormatOptions} path="output.pixelFormat" setConfig={setDraft} />
|
||||
<OutputAlphaField config={draft} setConfig={setDraft} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>Runtime</h4>
|
||||
<div className="config-fields config-fields--wide">
|
||||
@@ -208,29 +347,6 @@ export function ConfigEditor() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>Input</h4>
|
||||
<div className="config-fields">
|
||||
<SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} />
|
||||
<TextField config={draft} label="Device" path="input.device" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="input.resolution" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="input.frameRate" setConfig={setDraft} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>Output</h4>
|
||||
<div className="config-fields">
|
||||
<SelectField config={draft} label="Backend" options={backendOptions} path="output.backend" setConfig={setDraft} />
|
||||
<TextField config={draft} label="Device" path="output.device" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="output.resolution" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="output.frameRate" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Pixel format" options={pixelFormatOptions} path="output.pixelFormat" setConfig={setDraft} />
|
||||
<ToggleField config={draft} label="External key" path="output.keying.external" setConfig={setDraft} />
|
||||
<ToggleField config={draft} label="Alpha required" path="output.keying.alphaRequired" setConfig={setDraft} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>OSC</h4>
|
||||
<div className="config-fields">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
|
||||
import { postJson } from "../api/controlApi";
|
||||
|
||||
export function StackPresetToolbar() {
|
||||
export function StackPresetToolbar({ onOpenConfig }) {
|
||||
return (
|
||||
<div className="panel stack-panel">
|
||||
<div className="panel__header stack-panel__header">
|
||||
@@ -19,6 +19,10 @@ export function StackPresetToolbar() {
|
||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>Reload shaders</span>
|
||||
</button>
|
||||
<button type="button" className="button-with-icon stack-panel__config" onClick={onOpenConfig}>
|
||||
<Settings size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>Host config</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -510,6 +510,27 @@ pre {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
|
||||
.stack-panel__config {
|
||||
min-width: 8.5rem;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
align-items: start;
|
||||
justify-items: center;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
background: rgba(5, 8, 12, 0.72);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
width: min(100%, 1080px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
@@ -538,7 +559,7 @@ pre {
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -579,6 +600,20 @@ pre {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.config-combo-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 38px;
|
||||
gap: 0.45rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.config-combo-field .icon-button {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.config-toggle {
|
||||
align-self: end;
|
||||
min-height: 38px;
|
||||
@@ -1316,6 +1351,10 @@ pre {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.definition-grid,
|
||||
.summary-grid,
|
||||
.kv-rows,
|
||||
|
||||
Reference in New Issue
Block a user