UI updates and preroll buffer to 8 frames
Some checks failed
CI / Native Windows Build And Tests (push) Has been cancelled
CI / React UI Build (push) Has been cancelled
CI / Windows Release Package (push) Has been cancelled

This commit is contained in:
2026-05-05 20:56:53 +10:00
parent 44316b29c2
commit be315111ea
9 changed files with 763 additions and 378 deletions

View File

@@ -63,6 +63,7 @@ constexpr GLuint kDecodedVideoTextureUnit = 1;
constexpr GLuint kSourceHistoryTextureUnitBase = 2; constexpr GLuint kSourceHistoryTextureUnitBase = 2;
constexpr GLuint kPackedVideoTextureUnit = 2; constexpr GLuint kPackedVideoTextureUnit = 2;
constexpr GLuint kGlobalParamsBindingPoint = 0; constexpr GLuint kGlobalParamsBindingPoint = 0;
constexpr unsigned kPrerollFrameCount = 8;
const char* kVertexShaderSource = const char* kVertexShaderSource =
"#version 430 core\n" "#version 430 core\n"
"out vec2 vTexCoord;\n" "out vec2 vTexCoord;\n"
@@ -1233,7 +1234,7 @@ bool OpenGLComposite::Start()
mTotalPlayoutFrames = 0; mTotalPlayoutFrames = 0;
// Preroll frames // Preroll frames
for (unsigned i = 0; i < 5; i++) for (unsigned i = 0; i < kPrerollFrameCount; i++)
{ {
// Take each video frame from the front of the queue and move it to the back // Take each video frame from the front of the queue and move it to the back
IDeckLinkMutableVideoFrame* outputVideoFrame = mDLOutputVideoFrameQueue.front(); IDeckLinkMutableVideoFrame* outputVideoFrame = mDLOutputVideoFrameQueue.front();

View File

@@ -53,6 +53,25 @@ bool MatchesControlKey(const std::string& candidate, const std::string& key)
return candidate == key || SimplifyControlKey(candidate) == SimplifyControlKey(key); return candidate == key || SimplifyControlKey(candidate) == SimplifyControlKey(key);
} }
bool TryParseLayerIdNumber(const std::string& layerId, uint64_t& number)
{
const std::string prefix = "layer-";
if (layerId.rfind(prefix, 0) != 0 || layerId.size() == prefix.size())
return false;
uint64_t parsed = 0;
for (std::size_t index = prefix.size(); index < layerId.size(); ++index)
{
const unsigned char ch = static_cast<unsigned char>(layerId[index]);
if (!std::isdigit(ch))
return false;
parsed = parsed * 10 + static_cast<uint64_t>(ch - '0');
}
number = parsed;
return true;
}
std::vector<double> JsonArrayToNumbers(const JsonValue& value) std::vector<double> JsonArrayToNumbers(const JsonValue& value)
{ {
std::vector<double> numbers; std::vector<double> numbers;
@@ -583,6 +602,7 @@ bool RuntimeHost::Initialize(std::string& error)
return false; return false;
if (!ScanShaderPackages(error)) if (!ScanShaderPackages(error))
return false; return false;
NormalizePersistentLayerIdsLocked();
for (LayerPersistentState& layer : mPersistentState.layers) for (LayerPersistentState& layer : mPersistentState.layers)
{ {
@@ -1469,15 +1489,31 @@ bool RuntimeHost::WriteTextFile(const std::filesystem::path& path, const std::st
std::error_code fsError; std::error_code fsError;
std::filesystem::create_directories(path.parent_path(), fsError); std::filesystem::create_directories(path.parent_path(), fsError);
std::ofstream output(path, std::ios::binary); const std::filesystem::path temporaryPath = path.string() + ".tmp";
std::ofstream output(temporaryPath, std::ios::binary | std::ios::trunc);
if (!output) if (!output)
{ {
error = "Could not write file: " + path.string(); error = "Could not write file: " + temporaryPath.string();
return false; return false;
} }
output << contents; output << contents;
return output.good(); output.close();
if (!output.good())
{
error = "Could not finish writing file: " + temporaryPath.string();
return false;
}
if (!MoveFileExA(temporaryPath.string().c_str(), path.string().c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
{
const DWORD lastError = GetLastError();
std::filesystem::remove(temporaryPath, fsError);
error = "Could not replace file: " + path.string() + " (Win32 error " + std::to_string(lastError) + ")";
return false;
}
return true;
} }
bool RuntimeHost::ResolvePaths(std::string& error) bool RuntimeHost::ResolvePaths(std::string& error)
@@ -1728,6 +1764,38 @@ bool RuntimeHost::DeserializeLayerStackLocked(const JsonValue& layersValue, std:
return true; return true;
} }
void RuntimeHost::NormalizePersistentLayerIdsLocked()
{
std::set<std::string> usedIds;
uint64_t maxLayerNumber = mNextLayerId;
for (LayerPersistentState& layer : mPersistentState.layers)
{
uint64_t layerNumber = 0;
const bool hasReusableId = !layer.id.empty() &&
usedIds.find(layer.id) == usedIds.end() &&
TryParseLayerIdNumber(layer.id, layerNumber);
if (hasReusableId)
{
usedIds.insert(layer.id);
maxLayerNumber = std::max(maxLayerNumber, layerNumber);
continue;
}
do
{
++maxLayerNumber;
layer.id = "layer-" + std::to_string(maxLayerNumber);
}
while (usedIds.find(layer.id) != usedIds.end());
usedIds.insert(layer.id);
}
mNextLayerId = maxLayerNumber;
}
std::vector<std::string> RuntimeHost::GetStackPresetNamesLocked() const std::vector<std::string> RuntimeHost::GetStackPresetNamesLocked() const
{ {
std::vector<std::string> presetNames; std::vector<std::string> presetNames;

View File

@@ -112,6 +112,7 @@ private:
JsonValue BuildStateValue() const; JsonValue BuildStateValue() const;
JsonValue SerializeLayerStackLocked() const; JsonValue SerializeLayerStackLocked() const;
bool DeserializeLayerStackLocked(const JsonValue& layersValue, std::vector<LayerPersistentState>& layers, std::string& error); bool DeserializeLayerStackLocked(const JsonValue& layersValue, std::vector<LayerPersistentState>& layers, std::string& error);
void NormalizePersistentLayerIdsLocked();
std::vector<std::string> GetStackPresetNamesLocked() const; std::vector<std::string> GetStackPresetNamesLocked() const;
std::string MakeSafePresetFileStem(const std::string& presetName) const; std::string MakeSafePresetFileStem(const std::string& presetName) const;
JsonValue SerializeParameterValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) const; JsonValue SerializeParameterValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) const;

View File

@@ -47,8 +47,11 @@ function App() {
return ( return (
<main className="layout"> <main className="layout">
<section className="panel"> <section className="panel">
<h2>Loading</h2> <h3>Loading</h3>
<p className="muted">Waiting for control state from the native host.</p> <p className="muted">Waiting for control state from the native host.</p>
<div className="progress-track" aria-hidden="true">
<div className="progress-bar is-indeterminate" />
</div>
</section> </section>
</main> </main>
); );
@@ -58,7 +61,7 @@ function App() {
<main className="layout"> <main className="layout">
<header className="app-header"> <header className="app-header">
<div> <div>
<h1>Video Shader Toys</h1> <h2>Video Shader Toys</h2>
<p className="muted">Live shader stack, DeckLink status, and runtime controls.</p> <p className="muted">Live shader stack, DeckLink status, and runtime controls.</p>
</div> </div>
<div className={`status-pill${runtime.compileSucceeded ? " status-pill--ready" : " status-pill--error"}`}> <div className={`status-pill${runtime.compileSucceeded ? " status-pill--ready" : " status-pill--error"}`}>
@@ -66,6 +69,27 @@ function App() {
</div> </div>
</header> </header>
<section className="panel app-summary" aria-label="Runtime summary">
<dl className="summary-grid">
<div className="summary-item">
<dt>Shaders</dt>
<dd>{shaders.length}</dd>
</div>
<div className="summary-item">
<dt>Layers</dt>
<dd>{layers.length}</dd>
</div>
<div className="summary-item">
<dt>Signal</dt>
<dd>{video.hasSignal ? "Present" : "Missing"}</dd>
</div>
<div className="summary-item">
<dt>Render</dt>
<dd>{Number(performance.renderMs ?? 0).toFixed(2)} ms</dd>
</div>
</dl>
</section>
<section className="dashboard-grid"> <section className="dashboard-grid">
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} /> <StatusPanels app={app} performance={performance} runtime={runtime} video={video} />
<StackPresetToolbar <StackPresetToolbar

View File

@@ -1,18 +1,12 @@
export function KvList({ values }) { export function KvList({ values, variant = "cards" }) {
return ( return (
<dl className="kv"> <dl className={variant === "rows" ? "kv-rows" : "definition-grid compact"}>
{values.map(([key, value]) => ( {values.map(([key, value]) => (
<FragmentRow key={key} label={key} value={value} /> <div className={variant === "rows" ? "kv-row" : "definition-card"} key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))} ))}
</dl> </dl>
); );
} }
function FragmentRow({ label, value }) {
return (
<>
<dt>{label}</dt>
<dd>{value}</dd>
</>
);
}

View File

@@ -83,8 +83,10 @@ export function LayerStack({
return ( return (
<section className="panel"> <section className="panel">
<div className="panel__header"> <div className="panel__header">
<h2>Layers</h2> <div>
<p className="muted">Drag layers to reorder them. Each layer processes the output of the one above it.</p> <h3>Layers</h3>
<p className="muted">Drag layers to reorder them. Each layer processes the output of the one above it.</p>
</div>
</div> </div>
<div className="layer-stack"> <div className="layer-stack">

View File

@@ -11,73 +11,73 @@ export function StackPresetToolbar({
<div className="panel stack-panel"> <div className="panel stack-panel">
<div className="panel__header stack-panel__header"> <div className="panel__header stack-panel__header">
<div> <div>
<h2>Stack Presets</h2> <h3>Stack presets</h3>
<p className="muted">Save or recall the current layer chain.</p> <p className="muted">Save or recall the current layer chain.</p>
</div> </div>
<button type="button" className="stack-panel__reload" onClick={() => postJson("/api/reload", {})}> <button type="button" className="stack-panel__reload" onClick={() => postJson("/api/reload", {})}>
Reload Shader Reload shader
</button> </button>
</div> </div>
<div className="stack-panel__grid"> <div className="stack-panel__grid">
<div className="toolbar__group"> <div className="toolbar__group">
<label htmlFor="preset-name">Save Stack</label> <label htmlFor="preset-name">Save stack</label>
<div className="toolbar__inline"> <div className="toolbar__inline">
<input <input
id="preset-name" id="preset-name"
type="text" type="text"
placeholder="Preset name" placeholder="Preset name"
value={presetName} value={presetName}
onChange={(event) => onPresetNameChange(event.target.value)} onChange={(event) => onPresetNameChange(event.target.value)}
/> />
<button <button
type="button" type="button"
disabled={!presetName.trim()} disabled={!presetName.trim()}
onClick={() => { onClick={() => {
const trimmedName = presetName.trim(); const trimmedName = presetName.trim();
if (!trimmedName) { if (!trimmedName) {
return; return;
} }
postJson("/api/stack-presets/save", { presetName: trimmedName }); postJson("/api/stack-presets/save", { presetName: trimmedName });
onSelectedPresetNameChange( onSelectedPresetNameChange(
trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
); );
}} }}
> >
Save Save
</button> </button>
</div>
</div> </div>
</div>
<div className="toolbar__group"> <div className="toolbar__group">
<label htmlFor="preset-select">Recall Stack</label> <label htmlFor="preset-select">Recall stack</label>
<div className="toolbar__inline"> <div className="toolbar__inline">
<select <select
id="preset-select" id="preset-select"
value={selectedPresetName} value={selectedPresetName}
onChange={(event) => onSelectedPresetNameChange(event.target.value)} onChange={(event) => onSelectedPresetNameChange(event.target.value)}
> >
{stackPresets.length === 0 ? <option value="">No presets</option> : null} {stackPresets.length === 0 ? <option value="">No presets</option> : null}
{stackPresets.map((preset) => ( {stackPresets.map((preset) => (
<option key={preset} value={preset}> <option key={preset} value={preset}>
{preset} {preset}
</option> </option>
))} ))}
</select> </select>
<button <button
type="button" type="button"
disabled={!selectedPresetName} disabled={!selectedPresetName}
onClick={() => { onClick={() => {
if (selectedPresetName) { if (selectedPresetName) {
postJson("/api/stack-presets/load", { presetName: selectedPresetName }); postJson("/api/stack-presets/load", { presetName: selectedPresetName });
} }
}} }}
> >
Recall Recall
</button> </button>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }

View File

@@ -5,40 +5,66 @@ function formatNumber(value, digits = 3) {
} }
export function StatusPanels({ app, performance, runtime, video }) { export function StatusPanels({ app, performance, runtime, video }) {
const budgetUsedPercent = Math.max(0, Math.min(100, Number(performance.budgetUsedPercent) || 0));
return ( return (
<> <>
<div className="panel panel--runtime"> <div className="panel panel--telemetry">
<h2>Runtime</h2> <div className="telemetry-header">
<KvList <h3>Status</h3>
values={[ <div className="status-badges" aria-label="Current status">
["Layer Count", `${runtime.layerCount || 0}`], <span className={`mini-status${runtime.compileSucceeded ? " mini-status--ready" : " mini-status--error"}`}>
["Auto Reload", app.autoReload ? "On" : "Off"], {runtime.compileSucceeded ? "Ready" : "Error"}
["Temporal Cap", `${app.maxTemporalHistoryFrames ?? 0}`], </span>
["Control URL", `http://127.0.0.1:${app.serverPort}`], <span className={`mini-status${video.hasSignal ? " mini-status--ready" : " mini-status--error"}`}>
["Compile Status", runtime.compileSucceeded ? "Ready" : "Error"], {video.hasSignal ? "Signal" : "No signal"}
["Render Time", `${formatNumber(performance.renderMs, 2)} ms`], </span>
["Smoothed Time", `${formatNumber(performance.smoothedRenderMs, 2)} ms`], </div>
["Frame Budget", `${formatNumber(performance.frameBudgetMs, 2)} ms`], </div>
["Budget Used", `${formatNumber(performance.budgetUsedPercent, 1)}%`],
]}
/>
</div>
<div className="panel panel--video"> <div className="telemetry-sections">
<h2>Video</h2> <section className="telemetry-section" aria-labelledby="runtime-status-heading">
<KvList <h4 id="runtime-status-heading">Runtime</h4>
values={[ <KvList
["Signal", video.hasSignal ? "Present" : "Missing"], variant="rows"
["Input Mode", video.modeName || "Unknown"], values={[
["Input Resolution", `${video.width || 0} x ${video.height || 0}`], ["Layers", `${runtime.layerCount || 0}`],
["Output Mode", `${app.outputVideoFormat || "Unknown"}${app.outputFrameRate ? ` ${app.outputFrameRate}` : ""}`], ["Auto reload", app.autoReload ? "On" : "Off"],
]} ["Temporal cap", `${app.maxTemporalHistoryFrames ?? 0}`],
/> ["Control URL", `127.0.0.1:${app.serverPort}`],
["Render", `${formatNumber(performance.renderMs, 2)} ms`],
["Smoothed", `${formatNumber(performance.smoothedRenderMs, 2)} ms`],
["Frame budget", `${formatNumber(performance.frameBudgetMs, 2)} ms`],
]}
/>
<div className="meter-row">
<span>Budget used</span>
<div className="progress-track" aria-hidden="true">
<div className="progress-bar" style={{ width: `${budgetUsedPercent}%` }} />
</div>
<strong>{formatNumber(performance.budgetUsedPercent, 1)}%</strong>
</div>
</section>
<section className="telemetry-section" aria-labelledby="video-status-heading">
<h4 id="video-status-heading">Video</h4>
<KvList
variant="rows"
values={[
["Input mode", video.modeName || "Unknown"],
["Input size", `${video.width || 0} x ${video.height || 0}`],
["Output", `${app.outputVideoFormat || "Unknown"}${app.outputFrameRate ? ` ${app.outputFrameRate}` : ""}`],
]}
/>
</section>
</div>
</div> </div>
<div className="panel panel--compiler"> <div className="panel panel--compiler">
<h2>Compiler</h2> <h3>Compiler</h3>
<pre>{runtime.compileMessage || "No compiler output."}</pre> <pre className="log-panel" aria-live="polite">
{runtime.compileMessage || "No compiler output."}
</pre>
</div> </div>
</> </>
); );

File diff suppressed because it is too large Load Diff