config UI updates
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m8s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
Aiden
2026-05-30 20:20:22 +10:00
parent 216a561ede
commit aa33d72b6e
7 changed files with 187 additions and 37 deletions

View File

@@ -1,29 +1,30 @@
{ {
"$schema": "./runtime-host.schema.json", "$schema": "./runtime-host.schema.json",
"shaderLibrary": "shaders", "autoReload": true,
"serverPort": 8080,
"oscBindAddress": "0.0.0.0",
"oscPort": 9000,
"oscSmoothing": 0.18,
"input": { "input": {
"backend": "ndi", "backend": "ndi",
"device": "AIDENLAPTOP (Test Pattern)", "device": "AIDENLAPTOP (Test Pattern)",
"resolution": "1080p", "frameRate": "59.94",
"frameRate": "59.94" "resolution": "1080p"
}, },
"maxTemporalHistoryFrames": 12,
"oscBindAddress": "0.0.0.0",
"oscPort": 9000,
"oscSmoothing": 0.18,
"output": { "output": {
"backend": "ndi", "backend": "ndi",
"device": "Shader", "device": "Shader",
"resolution": "1080p",
"frameRate": "59.94", "frameRate": "59.94",
"pixelFormat": "auto",
"keying": { "keying": {
"external": false, "alphaRequired": false,
"alphaRequired": true "external": false
} },
"pixelFormat": "auto",
"resolution": "1080p"
}, },
"autoReload": true,
"maxTemporalHistoryFrames": 12,
"previewEnabled": true, "previewEnabled": true,
"previewFps": 59.94 "previewFps": 59.94,
"runtimeShaderId": "happy-accident",
"serverPort": 8080,
"shaderLibrary": "shaders"
} }

View File

@@ -1,21 +1,65 @@
#include "RuntimeLayerModel.h" #include "RuntimeLayerModel.h"
#include <sstream>
#include <utility> #include <utility>
namespace RenderCadenceCompositor namespace RenderCadenceCompositor
{ {
namespace
{
const char* LayerShaderLabel(std::size_t count)
{
return count == 1 ? "layer shader" : "layer shaders";
}
const char* BuildStateLabel(RuntimeLayerBuildState state)
{
switch (state)
{
case RuntimeLayerBuildState::Ready:
return "ready";
case RuntimeLayerBuildState::Failed:
return "failed";
case RuntimeLayerBuildState::Pending:
default:
return "pending";
}
}
}
RuntimeLayerModelSnapshot RuntimeLayerModel::Snapshot() const RuntimeLayerModelSnapshot RuntimeLayerModel::Snapshot() const
{ {
RuntimeLayerModelSnapshot snapshot; RuntimeLayerModelSnapshot snapshot;
snapshot.compileSucceeded = true; snapshot.compileSucceeded = true;
std::size_t readyCount = 0;
std::size_t failedCount = 0;
std::size_t pendingCount = 0;
std::vector<std::string> layerMessages;
for (const Layer& layer : mLayers) for (std::size_t layerIndex = 0; layerIndex < mLayers.size(); ++layerIndex)
{ {
const Layer& layer = mLayers[layerIndex];
snapshot.displayLayers.push_back(ToReadModel(layer)); snapshot.displayLayers.push_back(ToReadModel(layer));
if (!layer.message.empty() && snapshot.compileMessage.empty())
snapshot.compileMessage = layer.message;
if (layer.buildState == RuntimeLayerBuildState::Failed) if (layer.buildState == RuntimeLayerBuildState::Failed)
{
snapshot.compileSucceeded = false; snapshot.compileSucceeded = false;
++failedCount;
}
else if (layer.buildState == RuntimeLayerBuildState::Ready)
{
++readyCount;
}
else
{
++pendingCount;
}
if (!layer.message.empty())
{
std::ostringstream line;
line << "Layer " << (layerIndex + 1) << " " << (layer.shaderName.empty() ? layer.shaderId : layer.shaderName)
<< " (" << BuildStateLabel(layer.buildState) << "): " << layer.message;
layerMessages.push_back(line.str());
}
if (layer.renderReady) if (layer.renderReady)
{ {
RuntimeRenderLayerModel renderLayer; RuntimeRenderLayerModel renderLayer;
@@ -30,8 +74,35 @@ RuntimeLayerModelSnapshot RuntimeLayerModel::Snapshot() const
} }
} }
if (snapshot.compileMessage.empty()) if (mLayers.empty())
snapshot.compileMessage = mLayers.empty() ? "Runtime shader build disabled." : "Runtime shader build has not completed yet."; {
snapshot.compileMessage = "Runtime shader build disabled.";
}
else
{
std::ostringstream message;
if (failedCount > 0)
{
message << "Runtime stack build has failures: " << readyCount << "/" << mLayers.size() << " "
<< LayerShaderLabel(mLayers.size()) << " render-ready, " << failedCount << " failed";
if (pendingCount > 0)
message << ", " << pendingCount << " pending";
message << ".";
}
else if (pendingCount > 0)
{
message << "Runtime stack build in progress: " << readyCount << "/" << mLayers.size() << " "
<< LayerShaderLabel(mLayers.size()) << " render-ready, " << pendingCount << " pending.";
}
else
{
message << "Runtime stack ready: " << readyCount << "/" << mLayers.size() << " "
<< LayerShaderLabel(mLayers.size()) << " compiled and render-ready.";
}
for (const std::string& layerMessage : layerMessages)
message << "\n" << layerMessage;
snapshot.compileMessage = message.str();
}
return snapshot; return snapshot;
} }

View File

@@ -7,6 +7,8 @@
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <iomanip>
#include <sstream>
namespace namespace
{ {
@@ -28,6 +30,18 @@ std::filesystem::path FindRepoRoot()
} }
} }
const char* PluralSuffix(std::size_t count)
{
return count == 1 ? "" : "s";
}
std::string FormatMilliseconds(double milliseconds)
{
std::ostringstream output;
output << std::fixed << std::setprecision(2) << milliseconds;
return output.str();
}
} }
RuntimeSlangShaderCompiler::~RuntimeSlangShaderCompiler() RuntimeSlangShaderCompiler::~RuntimeSlangShaderCompiler()
@@ -159,7 +173,10 @@ RuntimeSlangShaderBuild RuntimeSlangShaderCompiler::BuildShader(const std::strin
build.artifact.fontAtlases = std::move(fontAtlasOutputs); build.artifact.fontAtlases = std::move(fontAtlasOutputs);
if (!build.artifact.passes.empty()) if (!build.artifact.passes.empty())
build.artifact.fragmentShaderSource = build.artifact.passes.front().fragmentShaderSource; build.artifact.fragmentShaderSource = build.artifact.passes.front().fragmentShaderSource;
build.artifact.message = shaderPackage.displayName + " Slang compile completed in " + std::to_string(milliseconds) + " ms."; build.artifact.message =
shaderPackage.displayName + " package build completed: " +
std::to_string(build.artifact.passes.size()) + " Slang pass" + PluralSuffix(build.artifact.passes.size()) +
" compiled in " + FormatMilliseconds(milliseconds) + " ms.";
build.message = build.artifact.message; build.message = build.artifact.message;
return build; return build;
} }

View File

@@ -222,6 +222,42 @@ void TestAddAndRemoveLayers()
std::filesystem::remove_all(root); std::filesystem::remove_all(root);
} }
void TestSnapshotCompileMessageSummarizesLayerStack()
{
std::filesystem::path root;
RenderCadenceCompositor::SupportedShaderCatalog catalog = MakeCatalog(root);
RenderCadenceCompositor::RuntimeLayerModel model;
std::string error;
std::string firstLayerId;
std::string secondLayerId;
Expect(model.AddLayer(catalog, "solid", firstLayerId, error), "first layer can be added for summary test");
Expect(model.AddLayer(catalog, "solid", secondLayerId, error), "second layer can be added for summary test");
RuntimeShaderArtifact firstArtifact;
firstArtifact.layerId = firstLayerId;
firstArtifact.shaderId = "solid";
firstArtifact.displayName = "Solid";
firstArtifact.fragmentShaderSource = "void main(){}";
firstArtifact.message = "Solid package build completed: 1 Slang pass compiled in 10.00 ms.";
Expect(model.MarkBuildReady(firstArtifact, error), "first ready artifact updates summary test model");
RuntimeShaderArtifact secondArtifact = firstArtifact;
secondArtifact.layerId = secondLayerId;
secondArtifact.message = "Solid package build completed: 1 Slang pass compiled in 11.00 ms.";
Expect(model.MarkBuildReady(secondArtifact, error), "second ready artifact updates summary test model");
const RenderCadenceCompositor::RuntimeLayerModelSnapshot snapshot = model.Snapshot();
Expect(snapshot.compileMessage.find("Runtime stack ready: 2/2 layer shaders compiled and render-ready.") != std::string::npos,
"compile message summarizes the whole layer stack");
Expect(snapshot.compileMessage.find("Layer 1 Solid (ready): Solid package build completed") != std::string::npos,
"compile message includes first layer detail");
Expect(snapshot.compileMessage.find("Layer 2 Solid (ready): Solid package build completed") != std::string::npos,
"compile message includes second layer detail");
std::filesystem::remove_all(root);
}
void TestInitializeFromRuntimeStateRestoresLayerStack() void TestInitializeFromRuntimeStateRestoresLayerStack()
{ {
std::filesystem::path root = MakeTestRoot(); std::filesystem::path root = MakeTestRoot();
@@ -542,6 +578,7 @@ int main()
TestRejectsUnsupportedStartupShader(); TestRejectsUnsupportedStartupShader();
TestBuildFailureStaysDisplaySide(); TestBuildFailureStaysDisplaySide();
TestAddAndRemoveLayers(); TestAddAndRemoveLayers();
TestSnapshotCompileMessageSummarizesLayerStack();
TestInitializeFromRuntimeStateRestoresLayerStack(); TestInitializeFromRuntimeStateRestoresLayerStack();
TestInvalidRuntimeStateCanFallBackToConfiguredShader(); TestInvalidRuntimeStateCanFallBackToConfiguredShader();
TestLayerControlsUpdateDisplayAndRenderModels(); TestLayerControlsUpdateDisplayAndRenderModels();

View File

@@ -1,4 +1,4 @@
import { Power, RefreshCw, Save, X } from "lucide-react"; import { Pencil, Power, RefreshCw, Save, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { fetchJson, postJsonResult } from "../api/controlApi"; import { fetchJson, postJsonResult } from "../api/controlApi";
@@ -50,7 +50,15 @@ function TextField({ config, label, path, setConfig }) {
); );
} }
function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSources, setConfig }) { function InputDeviceField({
config,
manualOpen,
ndiSources,
ndiSourcesBusy,
onManualOpenChange,
onRefreshNdiSources,
setConfig,
}) {
if (readPath(config, "input.backend") !== "ndi") { if (readPath(config, "input.backend") !== "ndi") {
return <TextField config={config} label="Device" path="input.device" setConfig={setConfig} />; return <TextField config={config} label="Device" path="input.device" setConfig={setConfig} />;
} }
@@ -61,7 +69,9 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
(name) => !presetOptions.includes(name) (name) => !presetOptions.includes(name)
); );
const selectOptions = [...presetOptions, ...discoveredOptions]; const selectOptions = [...presetOptions, ...discoveredOptions];
const selectValue = selectOptions.includes(currentDevice) ? currentDevice : currentDevice ? "__current__" : "default"; const customDevice = Boolean(currentDevice) && !selectOptions.includes(currentDevice);
const selectValue = customDevice ? "__custom__" : currentDevice || "default";
const showManualField = manualOpen || customDevice;
return ( return (
<Field label="Device"> <Field label="Device">
@@ -70,10 +80,12 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
<select <select
value={selectValue} value={selectValue}
onChange={(event) => { onChange={(event) => {
if (event.target.value === "__current__") return; if (event.target.value === "__custom__") return;
onManualOpenChange(false);
setConfig((current) => writePath(current, "input.device", event.target.value)); setConfig((current) => writePath(current, "input.device", event.target.value));
}} }}
> >
{customDevice && <option value="__custom__">Custom source</option>}
<optgroup label="Preset"> <optgroup label="Preset">
{presetOptions.map((option) => ( {presetOptions.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
@@ -81,9 +93,6 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
</option> </option>
))} ))}
</optgroup> </optgroup>
{currentDevice && !selectOptions.includes(currentDevice) && (
<option value="__current__">Current: {currentDevice}</option>
)}
<optgroup label="Discovered NDI sources"> <optgroup label="Discovered NDI sources">
{discoveredOptions.length > 0 ? ( {discoveredOptions.length > 0 ? (
discoveredOptions.map((sourceName) => ( discoveredOptions.map((sourceName) => (
@@ -107,14 +116,24 @@ function InputDeviceField({ config, ndiSources, ndiSourcesBusy, onRefreshNdiSour
> >
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" /> <RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
</button> </button>
<button
type="button"
className="icon-button"
onClick={() => onManualOpenChange(!manualOpen)}
title={showManualField ? "Hide manual source entry" : "Edit source manually"}
>
<Pencil size={15} strokeWidth={1.9} aria-hidden="true" />
</button>
</div> </div>
<input {showManualField && (
aria-label="Manual NDI source name" <input
placeholder="Manual source name" aria-label="Manual NDI source name"
type="text" placeholder="Manual source name"
value={currentDevice} type="text"
onChange={(event) => setConfig((current) => writePath(current, "input.device", event.target.value))} value={currentDevice}
/> onChange={(event) => setConfig((current) => writePath(current, "input.device", event.target.value))}
/>
)}
</div> </div>
</Field> </Field>
); );
@@ -203,6 +222,7 @@ export function ConfigEditor({ onClose }) {
const [restartRequired, setRestartRequired] = useState(false); const [restartRequired, setRestartRequired] = useState(false);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [manualInputDeviceOpen, setManualInputDeviceOpen] = useState(false);
const [ndiSources, setNdiSources] = useState([]); const [ndiSources, setNdiSources] = useState([]);
const [ndiSourcesBusy, setNdiSourcesBusy] = useState(false); const [ndiSourcesBusy, setNdiSourcesBusy] = useState(false);
@@ -249,6 +269,8 @@ export function ConfigEditor({ onClose }) {
useEffect(() => { useEffect(() => {
if (inputBackend === "ndi") { if (inputBackend === "ndi") {
loadNdiSources(); loadNdiSources();
} else {
setManualInputDeviceOpen(false);
} }
}, [inputBackend]); }, [inputBackend]);
@@ -340,8 +362,10 @@ export function ConfigEditor({ onClose }) {
<SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} /> <SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} />
<InputDeviceField <InputDeviceField
config={draft} config={draft}
manualOpen={manualInputDeviceOpen}
ndiSources={ndiSources} ndiSources={ndiSources}
ndiSourcesBusy={ndiSourcesBusy} ndiSourcesBusy={ndiSourcesBusy}
onManualOpenChange={setManualInputDeviceOpen}
onRefreshNdiSources={loadNdiSources} onRefreshNdiSources={loadNdiSources}
setConfig={setDraft} setConfig={setDraft}
/> />

View File

@@ -74,7 +74,7 @@ export function StatusPanels({ app, performance, runtime, video, videoOutput })
{ {
label: "Output", label: "Output",
value: formatEndpoint(app.output), value: formatEndpoint(app.output),
meta: `${formatVideoMode(app.output)} / ${video.width || 0} x ${video.height || 0}`, meta: formatVideoMode(app.output),
}, },
{ {
label: "Schedule", label: "Schedule",

View File

@@ -667,7 +667,7 @@ pre {
.config-device-picker__source { .config-device-picker__source {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 38px; grid-template-columns: minmax(0, 1fr) 38px 38px;
gap: 0.45rem; gap: 0.45rem;
align-items: stretch; align-items: stretch;
} }