diff --git a/README.md b/README.md index 2bb33ab..1466fff 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/config/runtime-host.json b/config/runtime-host.json index 7eb8de9..868a1da 100644 --- a/config/runtime-host.json +++ b/config/runtime-host.json @@ -16,9 +16,10 @@ "device": "Shader", "resolution": "1080p", "frameRate": "59.94", + "pixelFormat": "auto", "keying": { - "external": true, - "alphaRequired": false + "external": false, + "alphaRequired": true } }, "autoReload": true, diff --git a/config/runtime-host.schema.json b/config/runtime-host.schema.json index c235f1e..9ab2e48 100644 --- a/config/runtime-host.schema.json +++ b/config/runtime-host.schema.json @@ -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": [ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index acafb85..2c19405 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/src/README.md b/src/README.md index 8f3c7fc..3c7283d 100644 --- a/src/README.md +++ b/src/README.md @@ -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 diff --git a/src/app/AppConfigJson.cpp b/src/app/AppConfigJson.cpp index 3235914..9c7f559 100644 --- a/src/app/AppConfigJson.cpp +++ b/src/app/AppConfigJson.cpp @@ -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) diff --git a/src/app/AppConfigProvider.cpp b/src/app/AppConfigProvider.cpp index 434803e..3de347a 100644 --- a/src/app/AppConfigProvider.cpp +++ b/src/app/AppConfigProvider.cpp @@ -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; } } diff --git a/src/app/RenderCadenceApp.h b/src/app/RenderCadenceApp.h index d6fcc65..1a129ea 100644 --- a/src/app/RenderCadenceApp.h +++ b/src/app/RenderCadenceApp.h @@ -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 #include #include +#include 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 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; diff --git a/src/control/RuntimeStateJson.h b/src/control/RuntimeStateJson.h index 8db4ebc..2eb19a4 100644 --- a/src/control/RuntimeStateJson.h +++ b/src/control/RuntimeStateJson.h @@ -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(); diff --git a/src/control/http/HttpControlServer.h b/src/control/http/HttpControlServer.h index c94d765..5f76fb6 100644 --- a/src/control/http/HttpControlServer.h +++ b/src/control/http/HttpControlServer.h @@ -27,6 +27,7 @@ struct HttpControlServerCallbacks { std::function getStateJson; std::function getConfigJson; + std::function getNdiSourcesJson; std::function addLayer; std::function removeLayer; std::function executePost; diff --git a/src/control/http/HttpControlServerRoutes.cpp b/src/control/http/HttpControlServerRoutes.cpp index e27ba04..a3a66bf 100644 --- a/src/control/http/HttpControlServerRoutes.cpp +++ b/src/control/http/HttpControlServerRoutes.cpp @@ -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/") diff --git a/src/video/decklink/DeckLinkSession.cpp b/src/video/decklink/DeckLinkSession.cpp index 5830a8b..d9f5791 100644 --- a/src/video/decklink/DeckLinkSession.cpp +++ b/src/video/decklink/DeckLinkSession.cpp @@ -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); diff --git a/src/video/ndi/NdiOutput.cpp b/src/video/ndi/NdiOutput.cpp index 039613d..7ec3813 100644 --- a/src/video/ndi/NdiOutput.cpp +++ b/src/video/ndi/NdiOutput.cpp @@ -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; } diff --git a/src/video/ndi/NdiSourceDiscovery.cpp b/src/video/ndi/NdiSourceDiscovery.cpp new file mode 100644 index 0000000..bd924ab --- /dev/null +++ b/src/video/ndi/NdiSourceDiscovery.cpp @@ -0,0 +1,72 @@ +#include "NdiSourceDiscovery.h" + +#include "NdiRuntime.h" + +#include + +#include +#include +#include + +namespace RenderCadenceCompositor +{ +bool DiscoverNdiSources(std::vector& 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; +} +} diff --git a/src/video/ndi/NdiSourceDiscovery.h b/src/video/ndi/NdiSourceDiscovery.h new file mode 100644 index 0000000..c6c4818 --- /dev/null +++ b/src/video/ndi/NdiSourceDiscovery.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace RenderCadenceCompositor +{ +struct NdiSourceInfo +{ + std::string name; + std::string urlAddress; +}; + +bool DiscoverNdiSources(std::vector& sources, std::string& error, uint32_t waitMilliseconds = 250); +} diff --git a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp index f09ab37..3f1bf73 100644 --- a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp +++ b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp @@ -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) diff --git a/tests/RenderCadenceCompositorHttpControlServerTests.cpp b/tests/RenderCadenceCompositorHttpControlServerTests.cpp index 85e18e0..0b8e81c 100644 --- a/tests/RenderCadenceCompositorHttpControlServerTests.cpp +++ b/tests/RenderCadenceCompositorHttpControlServerTests.cpp @@ -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(); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 3a6f41a..bef3f48 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -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 (
@@ -91,8 +101,7 @@ function App() {
- - + setConfigEditorOpen(true)} />
+ + {configEditorOpen && ( +
{ + if (event.target === event.currentTarget) setConfigEditorOpen(false); + }} + > +
+ setConfigEditorOpen(false)} /> +
+
+ )}
); } diff --git a/ui/src/components/ConfigEditor.jsx b/ui/src/components/ConfigEditor.jsx index fcf55ab..aae8c2f 100644 --- a/ui/src/components/ConfigEditor.jsx +++ b/ui/src/components/ConfigEditor.jsx @@ -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 ; + } + + return ( + +
+ setConfig((current) => writePath(current, "input.device", event.target.value))} + /> + +
+ + +
+ ); +} + function NumberField({ config, label, min, path, setConfig, step = 1 }) { return ( @@ -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 ( setConfig((current) => writeOutputAlpha(current, event.target.checked))} + /> + Output alpha + + ); +} + function ToggleField({ config, label, path, setConfig }) { return (