Added config editor in front end
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m46s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
Aiden
2026-05-30 19:33:40 +10:00
parent f0f8b080ca
commit 8ffc011ca0
26 changed files with 1201 additions and 55 deletions

View File

@@ -21,7 +21,7 @@ The app is a native C++ OpenGL compositor with:
Primary source areas: Primary source areas:
- `src/app`: startup/shutdown orchestration, config loading, runtime layer controller - `src/app`: startup/shutdown orchestration, config loading, runtime layer controller
- `src/render`: cadence clock, input texture upload, simple renderer, readback, and runtime GL support - `src/render`: cadence clock, input texture upload, render-content boundary, readback, and runtime GL support
- `src/render/thread`: render thread lifecycle, cadence loop, metrics, and runtime shader commit mailbox - `src/render/thread`: render thread lifecycle, cadence loop, metrics, and runtime shader commit mailbox
- `src/render/runtime`: render-thread-owned runtime shader scene, renderer, text texture upload cache, and shared-context shader prepare worker - `src/render/runtime`: render-thread-owned runtime shader scene, renderer, text texture upload cache, and shared-context shader prepare worker
- `src/frames`: system-memory frame exchange - `src/frames`: system-memory frame exchange
@@ -99,6 +99,8 @@ The mutation path snapshots the current layer model and hands serialized state t
OSC-driven changes are intentionally not part of this autosave path yet. OSC-driven changes are intentionally not part of this autosave path yet.
The host configuration editor is separate from runtime layer persistence. The UI reads active and saved startup config through `/api/config`, saves `config/runtime-host.json` through `/api/config/save`, and requests a native host restart through `/api/app/restart`. Render cadence, video input/output selection, resolution, frame rate, output pixel format, HTTP port, and preview settings are still startup-owned; they are not hot-swapped inside the cadence path.
## Shader Reload ## Shader Reload
`POST /api/reload` and the control UI reload button: `POST /api/reload` and the control UI reload button:
@@ -129,11 +131,11 @@ The render path consumes published render-layer snapshots. It does not:
- handle HTTP or OSC - handle HTTP or OSC
- call DeckLink discovery/setup APIs - call DeckLink discovery/setup APIs
When a runtime shader build completes, the app publishes a render-layer artifact. The render thread-owned runtime scene diffs the snapshot and queues changed pass programs to the shared-context prepare worker. The render thread swaps in an already-prepared render plan at a frame boundary. When a runtime shader build completes, the app publishes a render-layer artifact. The render thread forwards pending layer snapshots to the active render-content adapter. The default `RuntimeShaderRenderContent` owns the runtime scene, diffs the snapshot, and queues changed pass programs to the shared-context prepare worker. The render thread swaps in an already-prepared render plan at a frame boundary through that adapter.
## Video And Preview ## Video And Preview
Video input and output are optional edges. `input.backend` and `output.backend` select the concrete backend through the app-side backend factory. DeckLink is the current concrete backend, and `none` disables that edge. `input` and `output` also carry the device selector plus resolution/frame-rate settings. Configured video modes are represented in `src/video/core` and translated to DeckLink display modes only inside `src/video/decklink`. Video input and output are optional edges. `input.backend` and `output.backend` select the concrete backend through the app-side backend factory. DeckLink and NDI are the current concrete backends, and `none` disables an edge. `input` and `output` also carry the device selector plus resolution/frame-rate settings. Configured video modes are represented in `src/video/core` and translated to backend-specific modes only inside the concrete edge.
The input edge writes CPU frames into `InputFrameMailbox`. The current DeckLink backend captures BGRA8 directly where possible, or raw UYVY8 for render-thread GPU decode. The input edge does not call GL, render, preview, screenshot, shader, or output scheduling code. The input edge writes CPU frames into `InputFrameMailbox`. The current DeckLink backend captures BGRA8 directly where possible, or raw UYVY8 for render-thread GPU decode. The input edge does not call GL, render, preview, screenshot, shader, or output scheduling code.

View File

@@ -6,7 +6,7 @@ This note captures the fork-readiness review for using this repository as a base
The repository is clean enough for an internal fork, but it needs a small hygiene pass before it becomes a comfortable long-lived base repo. The repository is clean enough for an internal fork, but it needs a small hygiene pass before it becomes a comfortable long-lived base repo.
The important architecture is already in place: render cadence, video input/output, frame exchange, readback, preview, control, and shader build work are mostly separated by role. The main replacement point is the render-thread draw path in `src/render/thread/RenderThread.cpp`, where cadence, input upload, readback, and frame publication wrap the actual GPU rendering call. The important architecture is already in place: render cadence, video input/output, frame exchange, readback, preview, control, and shader build work are mostly separated by role. The main replacement point is the render-content adapter in `src/render/RuntimeShaderRenderContent.*`, where the current shader-package renderer decides what to draw into the framebuffer handed to it by `RenderThread`.
For a new repo, keep the cadence and frame handoff machinery, then replace or narrow the runtime shader rendering layer. For a new repo, keep the cadence and frame handoff machinery, then replace or narrow the runtime shader rendering layer.
@@ -34,22 +34,22 @@ These are most likely to change when the fork renders something other than shade
- `runtime/templates/shader_wrapper.slang.in`: only needed for the current Slang package pipeline. - `runtime/templates/shader_wrapper.slang.in`: only needed for the current Slang package pipeline.
- Shader-specific UI affordances in `ui`, if the new renderer has a different control model. - Shader-specific UI affordances in `ui`, if the new renderer has a different control model.
The cleanest first fork step is to preserve `RenderThread`'s cadence/readback shell and introduce a narrow render-content interface behind the draw call. Then the new repo can swap the implementation without touching video I/O scheduling. The first fork step is now in place: `RenderThread` preserves the cadence/readback shell and calls a narrow render-content interface behind the draw call. A new repo can swap that implementation without touching video I/O scheduling.
## Current Swap Point ## Current Swap Point
The current draw decision happens inside the readback queue call in `src/render/thread/RenderThread.cpp`: The render cadence loop now calls `IRenderContent` inside the readback queue call in `src/render/thread/RenderThread.cpp`:
```cpp ```cpp
if (runtimeRenderScene.HasLayers()) renderContent.RenderFrame(RenderContentFrame{
runtimeRenderScene.RenderFrame(index, mConfig.width, mConfig.height, videoInputTexture); index,
else if (videoInputTexture != 0) mConfig.width,
renderer.RenderTexture(videoInputTexture); mConfig.height,
else videoInputTexture
renderer.RenderFrame(index); });
``` ```
That is the practical boundary for a fork: The default implementation is `RuntimeShaderRenderContent`, which owns the existing runtime shader scene plus simple fallback renderer. That is the practical boundary for a fork:
- keep the tick clock, input upload, readback queueing, and `SystemFrameExchange` publication around it - keep the tick clock, input upload, readback queueing, and `SystemFrameExchange` publication around it
- replace what draws into the current GL framebuffer - replace what draws into the current GL framebuffer
@@ -84,9 +84,9 @@ The generated Visual Studio `RUN_TESTS` target did not build missing test execut
## Recommended Fork Sequence ## Recommended Fork Sequence
1. Make the hygiene fixes above in this repo or immediately after the fork. 1. Make the hygiene fixes above in this repo or immediately after the fork.
2. Add a small render-content abstraction behind the `RenderThread` draw call. 2. Keep `IRenderContent` as the boundary behind the `RenderThread` draw call.
3. Port the existing runtime shader renderer behind that abstraction as the baseline implementation. 3. Keep `RuntimeShaderRenderContent` as the baseline implementation until the fork's renderer exists.
4. Add the new renderer beside it. 4. Add the new renderer beside it or replace the default adapter.
5. Verify that video output still consumes completed frames and never requests rendering directly. 5. Verify that video output still consumes completed frames and never requests rendering directly.
6. Only then remove shader package pieces that the new repo no longer needs. 6. Only then remove shader package pieces that the new repo no longer needs.

View File

@@ -16,6 +16,8 @@ servers:
tags: tags:
- name: State - name: State
description: Runtime state and status. description: Runtime state and status.
- name: Config
description: Startup host configuration and restart control.
- name: Static - name: Static
description: Bundled control UI and static assets served by the local host. description: Bundled control UI and static assets served by the local host.
- name: Docs - name: Docs
@@ -179,6 +181,52 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/RuntimeState" $ref: "#/components/schemas/RuntimeState"
/api/config:
get:
tags: [Config]
summary: Get active and saved host config
operationId: getHostConfig
responses:
"200":
description: Active startup config and the config currently saved on disk.
content:
application/json:
schema:
$ref: "#/components/schemas/HostConfigResponse"
/api/config/save:
post:
tags: [Config]
summary: Save host config
description: Saves `runtime-host.json`. Startup-owned services use the new values after app restart.
operationId: saveHostConfig
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/HostConfig"
responses:
"200":
$ref: "#/components/responses/ActionOk"
"400":
$ref: "#/components/responses/ActionError"
/api/app/restart:
post:
tags: [Config]
summary: Restart the native host
operationId: restartHost
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: false
responses:
"200":
$ref: "#/components/responses/ActionOk"
"400":
$ref: "#/components/responses/ActionError"
/ws: /ws:
get: get:
tags: [State] tags: [State]
@@ -511,6 +559,90 @@ components:
type: string type: string
example: live-show-look example: live-show-look
additionalProperties: false additionalProperties: false
HostConfigResponse:
type: object
properties:
ok:
type: boolean
path:
type: string
active:
$ref: "#/components/schemas/HostConfig"
disk:
$ref: "#/components/schemas/HostConfig"
diskLoaded:
type: boolean
restartRequired:
type: boolean
error:
type: string
HostConfig:
type: object
properties:
$schema:
type: string
shaderLibrary:
type: string
serverPort:
type: number
oscBindAddress:
type: string
oscPort:
type: number
oscSmoothing:
type: number
input:
$ref: "#/components/schemas/HostVideoInputConfig"
output:
$ref: "#/components/schemas/HostVideoOutputConfig"
autoReload:
type: boolean
maxTemporalHistoryFrames:
type: number
previewEnabled:
type: boolean
previewFps:
type: number
runtimeShaderId:
type: string
additionalProperties: false
HostVideoInputConfig:
type: object
properties:
backend:
type: string
enum: [decklink, ndi, none]
device:
type: string
resolution:
type: string
frameRate:
type: string
additionalProperties: false
HostVideoOutputConfig:
type: object
properties:
backend:
type: string
enum: [decklink, ndi, none]
device:
type: string
resolution:
type: string
frameRate:
type: string
pixelFormat:
type: string
enum: [auto, bgra8, uyvy8]
keying:
type: object
properties:
external:
type: boolean
alphaRequired:
type: boolean
additionalProperties: false
additionalProperties: false
RuntimeState: RuntimeState:
type: object type: object
properties: properties:

View File

@@ -65,7 +65,8 @@ Included now:
- background Slang compile of `shaders/happy-accident` - background Slang compile of `shaders/happy-accident`
- app-owned display/render layer model for shader build readiness - app-owned display/render layer model for shader build readiness
- app-owned submission of a completed shader artifact - app-owned submission of a completed shader artifact
- render-thread-owned runtime render scene for ready shader layers - render-thread-owned render-content interface with runtime shader content as the default implementation
- render-thread-owned runtime render scene for ready shader layers inside the default render-content adapter
- shared-context GL prepare worker for runtime shader program compile/link - shared-context GL prepare worker for runtime shader program compile/link
- render-thread-only GL program swap once a prepared program is ready - render-thread-only GL program swap once a prepared program is ready
- manifest-driven stateless single-pass shader packages - manifest-driven stateless single-pass shader packages
@@ -221,6 +222,8 @@ Currently consumed fields:
`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 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.
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.
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. 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.
The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently. The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently.
@@ -391,7 +394,7 @@ The `/api/state` shader list uses the same support rules as runtime shader compi
Runtime shaders are exposed through `RuntimeLayerModel` as display layers with manifest parameter definitions, current parameter values, build status, and render-ready artifacts. POST controls mutate this app-owned model and may start background shader builds when the selected shader changes or when `/api/reload` is requested. Durable UI/API layer changes request a debounced background write to `runtime/runtime_state.json`. OSC ingress is not wired yet; when it is added, OSC-driven changes should stay out of autosave unless an explicit persistence policy is introduced. Runtime shaders are exposed through `RuntimeLayerModel` as display layers with manifest parameter definitions, current parameter values, build status, and render-ready artifacts. POST controls mutate this app-owned model and may start background shader builds when the selected shader changes or when `/api/reload` is requested. Durable UI/API layer changes request a debounced background write to `runtime/runtime_state.json`. OSC ingress is not wired yet; when it is added, OSC-driven changes should stay out of autosave unless an explicit persistence policy is introduced.
When a layer becomes render-ready, the app publishes the ready render-layer snapshot to the render thread. The render thread owns the GL-side `RuntimeRenderScene`, diffs that snapshot at a frame boundary, queues new or changed pass programs to the shared-context prepare worker, swaps in a full prepared render plan only after every pass is ready, removes obsolete GL programs, and renders ready layers in order. Stacked stateless full-frame shaders render through internal ping-pong targets so each layer can sample the previous layer through `gLayerInput`; multipass shaders route named intermediate outputs through their manifest-declared pass inputs, and the final ready layer renders to the output target. When a layer becomes render-ready, the app publishes the ready render-layer snapshot to the render thread. The render thread forwards those snapshots to `RuntimeShaderRenderContent`, the default `IRenderContent` implementation. That adapter owns the GL-side `RuntimeRenderScene`, diffs snapshots at a frame boundary, queues new or changed pass programs to the shared-context prepare worker, swaps in a full prepared render plan only after every pass is ready, removes obsolete GL programs, and renders ready layers in order. Stacked stateless full-frame shaders render through internal ping-pong targets so each layer can sample the previous layer through `gLayerInput`; multipass shaders route named intermediate outputs through their manifest-declared pass inputs, and the final ready layer renders to the output target.
Successful handoff signs: Successful handoff signs:
@@ -441,11 +444,13 @@ This app keeps the same core behavior but splits it into modules that can grow:
- `frames/`: system-memory handoff - `frames/`: system-memory handoff
- `platform/`: COM/Win32/hidden GL context support - `platform/`: COM/Win32/hidden GL context support
- `render/`: cadence thread, clock, and simple renderer - `render/`: cadence thread, clock, render-content boundary, and default runtime shader content
- `frames/InputFrameMailbox`: non-blocking bounded FIFO CPU input handoff with contiguous-copy fast path for matching row strides - `frames/InputFrameMailbox`: non-blocking bounded FIFO CPU input handoff with contiguous-copy fast path for matching row strides
- `render/InputFrameTexture`: render-thread-owned upload of the currently acquired CPU input frame into GL, including raw UYVY8 decode into the shader-visible input texture - `render/InputFrameTexture`: render-thread-owned upload of the currently acquired CPU input frame into GL, including raw UYVY8 decode into the shader-visible input texture
- `render/readback/`: PBO-backed BGRA8/UYVY8 readback and completed-frame publication - `render/readback/`: PBO-backed BGRA8/UYVY8 readback and completed-frame publication
- `render/thread/`: render thread lifecycle, GL startup/cadence loop, metrics, and runtime layer commit mailbox - `render/thread/`: render thread lifecycle, GL startup/cadence loop, metrics, and runtime layer commit mailbox
- `render/RenderContent`: narrow draw-call boundary used by the cadence/readback shell
- `render/RuntimeShaderRenderContent`: default content adapter that wraps runtime shader rendering and fallback presentation
- `render/runtime/RuntimeRenderScene`: render-thread-owned GL scene for ready runtime shader layers - `render/runtime/RuntimeRenderScene`: render-thread-owned GL scene for ready runtime shader layers
- `render/runtime/RuntimeShaderPrepareWorker`: shared-context runtime shader program compile/link worker - `render/runtime/RuntimeShaderPrepareWorker`: shared-context runtime shader program compile/link worker
- `runtime/catalog/`: supported shader catalog and package filtering - `runtime/catalog/`: supported shader catalog and package filtering
@@ -466,8 +471,6 @@ This app keeps the same core behavior but splits it into modules that can grow:
Only after this app matches the probe's smooth output: Only after this app matches the probe's smooth output:
1. replace `SimpleMotionRenderer` with a render-scene interface 1. broaden runtime snapshots/live state toward OSC and presets
2. port shader package rendering 2. add screenshot capture from system-memory frames
3. broaden runtime snapshots/live state toward OSC and presets 3. add scaling and additional input format support after the BGRA8/raw-UYVY8 input edge is stable
4. add screenshot capture from system-memory frames
5. add scaling and additional input format support after the BGRA8/raw-UYVY8 input edge is stable

View File

@@ -1,5 +1,6 @@
#include "app/AppConfig.h" #include "app/AppConfig.h"
#include "app/AppConfigProvider.h" #include "app/AppConfigProvider.h"
#include "app/AppRestart.h"
#include "app/RenderCadenceApp.h" #include "app/RenderCadenceApp.h"
#include "app/VideoBackendFactory.h" #include "app/VideoBackendFactory.h"
#include "frames/InputFrameMailbox.h" #include "frames/InputFrameMailbox.h"
@@ -92,6 +93,8 @@ VideoIOPixelFormat SelectSystemFramePixelFormat(const RenderCadenceCompositor::A
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
RenderCadenceCompositor::WaitForRestartParentIfRequested(argc, argv);
RenderCadenceCompositor::AppConfigProvider configProvider; RenderCadenceCompositor::AppConfigProvider configProvider;
std::string configError; std::string configError;
if (!configProvider.LoadDefault(configError)) if (!configProvider.LoadDefault(configError))
@@ -197,7 +200,8 @@ int main(int argc, char** argv)
renderThread, renderThread,
frameExchange, frameExchange,
appConfig, appConfig,
std::move(outputBackend)); std::move(outputBackend),
configProvider.SourcePath());
app.SetVideoInputMetricsProvider([inputBackend = inputBackend.get()]() { app.SetVideoInputMetricsProvider([inputBackend = inputBackend.get()]() {
return inputBackend ? inputBackend->Metrics() : RenderCadenceCompositor::VideoInputEdgeMetrics(); return inputBackend ? inputBackend->Metrics() : RenderCadenceCompositor::VideoInputEdgeMetrics();
}); });

212
src/app/AppConfigJson.cpp Normal file
View File

@@ -0,0 +1,212 @@
#include "AppConfigJson.h"
#include <fstream>
#include <sstream>
namespace RenderCadenceCompositor
{
namespace
{
const JsonValue* Find(const JsonValue& root, const char* key)
{
return root.find(key);
}
void ApplyString(const JsonValue& root, const char* key, std::string& target)
{
const JsonValue* value = Find(root, key);
if (value && value->isString())
target = value->asString();
}
void ApplyBool(const JsonValue& root, const char* key, bool& target)
{
const JsonValue* value = Find(root, key);
if (value && value->isBoolean())
target = value->asBoolean();
}
void ApplyDouble(const JsonValue& root, const char* key, double& target)
{
const JsonValue* value = Find(root, key);
if (value && value->isNumber())
target = value->asNumber();
}
void ApplySize(const JsonValue& root, const char* key, std::size_t& target)
{
const JsonValue* value = Find(root, key);
if (value && value->isNumber() && value->asNumber() >= 0.0)
target = static_cast<std::size_t>(value->asNumber());
}
void ApplyPort(const JsonValue& root, const char* key, unsigned short& target)
{
const JsonValue* value = Find(root, key);
if (!value || !value->isNumber())
return;
const double port = value->asNumber();
if (port >= 1.0 && port <= 65535.0)
target = static_cast<unsigned short>(port);
}
JsonValue NumberValue(double value)
{
return JsonValue(value);
}
JsonValue SizeValue(std::size_t value)
{
return JsonValue(static_cast<double>(value));
}
void ApplyInputConfig(const JsonValue& root, AppConfig& config)
{
const JsonValue* input = Find(root, "input");
if (!input || !input->isObject())
return;
ApplyString(*input, "backend", config.input.backend);
ApplyString(*input, "device", config.input.device);
ApplyString(*input, "resolution", config.input.resolution);
ApplyString(*input, "frameRate", config.input.frameRate);
}
void ApplyOutputConfig(const JsonValue& root, AppConfig& config)
{
const JsonValue* output = Find(root, "output");
if (!output || !output->isObject())
return;
ApplyString(*output, "backend", config.output.backend);
ApplyString(*output, "device", config.output.device);
ApplyString(*output, "resolution", config.output.resolution);
ApplyString(*output, "frameRate", config.output.frameRate);
ApplyString(*output, "pixelFormat", config.output.pixelFormat);
const JsonValue* keying = Find(*output, "keying");
if (keying && keying->isObject())
{
ApplyBool(*keying, "external", config.output.externalKeyingEnabled);
ApplyBool(*keying, "alphaRequired", config.output.outputAlphaRequired);
}
}
JsonValue InputConfigToJson(const VideoInputAppConfig& input)
{
JsonValue value = JsonValue::MakeObject();
value.set("backend", JsonValue(input.backend));
value.set("device", JsonValue(input.device));
value.set("resolution", JsonValue(input.resolution));
value.set("frameRate", JsonValue(input.frameRate));
return value;
}
JsonValue OutputConfigToJson(const VideoOutputAppConfig& output)
{
JsonValue keying = JsonValue::MakeObject();
keying.set("external", JsonValue(output.externalKeyingEnabled));
keying.set("alphaRequired", JsonValue(output.outputAlphaRequired));
JsonValue value = JsonValue::MakeObject();
value.set("backend", JsonValue(output.backend));
value.set("device", JsonValue(output.device));
value.set("resolution", JsonValue(output.resolution));
value.set("frameRate", JsonValue(output.frameRate));
value.set("pixelFormat", JsonValue(output.pixelFormat));
value.set("keying", keying);
return value;
}
}
bool ApplyAppConfigJson(const JsonValue& root, AppConfig& config, std::string* error)
{
if (!root.isObject())
{
if (error)
*error = "Config root must be a JSON object.";
return false;
}
ApplyString(root, "shaderLibrary", config.shaderLibrary);
ApplyPort(root, "serverPort", config.http.preferredPort);
ApplyString(root, "oscBindAddress", config.oscBindAddress);
ApplyPort(root, "oscPort", config.oscPort);
ApplyDouble(root, "oscSmoothing", config.oscSmoothing);
ApplyInputConfig(root, config);
ApplyOutputConfig(root, config);
ApplyBool(root, "autoReload", config.autoReload);
ApplySize(root, "maxTemporalHistoryFrames", config.maxTemporalHistoryFrames);
ApplyBool(root, "previewEnabled", config.previewEnabled);
ApplyDouble(root, "previewFps", config.previewFps);
ApplyString(root, "runtimeShaderId", config.runtimeShaderId);
if (error)
error->clear();
return true;
}
bool ParseAppConfigJson(const std::string& text, AppConfig& config, std::string& error)
{
JsonValue root;
std::string parseError;
if (!ParseJson(text, root, parseError))
{
error = parseError.empty() ? "Config JSON could not be parsed." : parseError;
return false;
}
config = DefaultAppConfig();
return ApplyAppConfigJson(root, config, &error);
}
JsonValue AppConfigToJsonValue(const AppConfig& config)
{
JsonValue root = JsonValue::MakeObject();
root.set("$schema", JsonValue("./runtime-host.schema.json"));
root.set("shaderLibrary", JsonValue(config.shaderLibrary));
root.set("serverPort", NumberValue(config.http.preferredPort));
root.set("oscBindAddress", JsonValue(config.oscBindAddress));
root.set("oscPort", NumberValue(config.oscPort));
root.set("oscSmoothing", NumberValue(config.oscSmoothing));
root.set("input", InputConfigToJson(config.input));
root.set("output", OutputConfigToJson(config.output));
root.set("autoReload", JsonValue(config.autoReload));
root.set("maxTemporalHistoryFrames", SizeValue(config.maxTemporalHistoryFrames));
root.set("previewEnabled", JsonValue(config.previewEnabled));
root.set("previewFps", NumberValue(config.previewFps));
root.set("runtimeShaderId", JsonValue(config.runtimeShaderId));
return root;
}
std::string AppConfigToJson(const AppConfig& config)
{
return SerializeJson(AppConfigToJsonValue(config), true) + "\n";
}
bool SaveAppConfigToFile(const AppConfig& config, const std::filesystem::path& path, std::string& error)
{
if (path.empty())
{
error = "Config path is not available.";
return false;
}
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output)
{
error = "Could not open config file for writing: " + path.string();
return false;
}
output << AppConfigToJson(config);
if (!output)
{
error = "Could not write config file: " + path.string();
return false;
}
error.clear();
return true;
}
}

16
src/app/AppConfigJson.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include "AppConfig.h"
#include "RuntimeJson.h"
#include <filesystem>
#include <string>
namespace RenderCadenceCompositor
{
bool ApplyAppConfigJson(const JsonValue& root, AppConfig& config, std::string* error = nullptr);
bool ParseAppConfigJson(const std::string& text, AppConfig& config, std::string& error);
JsonValue AppConfigToJsonValue(const AppConfig& config);
std::string AppConfigToJson(const AppConfig& config);
bool SaveAppConfigToFile(const AppConfig& config, const std::filesystem::path& path, std::string& error);
}

View File

@@ -1,5 +1,6 @@
#include "AppConfigProvider.h" #include "AppConfigProvider.h"
#include "AppRestart.h"
#include "RuntimeJson.h" #include "RuntimeJson.h"
#include <cstdint> #include <cstdint>
@@ -162,6 +163,7 @@ bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& err
ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames); ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames);
ApplyBool(root, "previewEnabled", mConfig.previewEnabled); ApplyBool(root, "previewEnabled", mConfig.previewEnabled);
ApplyDouble(root, "previewFps", mConfig.previewFps); ApplyDouble(root, "previewFps", mConfig.previewFps);
ApplyString(root, "runtimeShaderId", mConfig.runtimeShaderId);
mLoadedFromFile = true; mLoadedFromFile = true;
error.clear(); error.clear();
@@ -173,6 +175,11 @@ void AppConfigProvider::ApplyCommandLine(int argc, char** argv)
for (int index = 1; index < argc; ++index) for (int index = 1; index < argc; ++index)
{ {
const std::string argument = argv[index]; const std::string argument = argv[index];
if (argument == "--restart-wait-pid")
{
SkipRestartParentArgs(index, argc, argv);
continue;
}
if (argument == "--shader" && index + 1 < argc) if (argument == "--shader" && index + 1 < argc)
{ {
mConfig.runtimeShaderId = argv[++index]; mConfig.runtimeShaderId = argv[++index];

117
src/app/AppRestart.cpp Normal file
View File

@@ -0,0 +1,117 @@
#include "AppRestart.h"
#include <windows.h>
#include <chrono>
#include <cstdlib>
#include <string>
#include <thread>
#include <vector>
namespace RenderCadenceCompositor
{
namespace
{
constexpr const char* kRestartWaitPidArgument = "--restart-wait-pid";
bool ParseParentPid(int argc, char** argv, DWORD& parentPid)
{
for (int index = 1; index + 1 < argc; ++index)
{
if (std::string(argv[index]) != kRestartWaitPidArgument)
continue;
const unsigned long parsed = std::strtoul(argv[index + 1], nullptr, 10);
if (parsed == 0)
return false;
parentPid = static_cast<DWORD>(parsed);
return true;
}
return false;
}
std::string CurrentModulePath()
{
char modulePath[MAX_PATH] = {};
const DWORD length = GetModuleFileNameA(nullptr, modulePath, static_cast<DWORD>(sizeof(modulePath)));
if (length == 0 || length >= sizeof(modulePath))
return std::string();
return std::string(modulePath, modulePath + length);
}
std::string RestartCommandLine()
{
std::string commandLine = GetCommandLineA();
commandLine += " ";
commandLine += kRestartWaitPidArgument;
commandLine += " ";
commandLine += std::to_string(GetCurrentProcessId());
return commandLine;
}
}
void WaitForRestartParentIfRequested(int argc, char** argv)
{
DWORD parentPid = 0;
if (!ParseParentPid(argc, argv, parentPid))
return;
HANDLE parentProcess = OpenProcess(SYNCHRONIZE, FALSE, parentPid);
if (parentProcess == nullptr)
return;
WaitForSingleObject(parentProcess, 10000);
CloseHandle(parentProcess);
}
void SkipRestartParentArgs(int& index, int argc, char** argv)
{
if (index < argc && std::string(argv[index]) == kRestartWaitPidArgument)
{
if (index + 1 < argc)
++index;
}
}
bool ScheduleProcessRestart(std::string& error)
{
const std::string modulePath = CurrentModulePath();
if (modulePath.empty())
{
error = "Could not resolve current executable path for restart.";
return false;
}
std::thread([modulePath]() {
std::this_thread::sleep_for(std::chrono::milliseconds(350));
std::string commandLine = RestartCommandLine();
std::vector<char> mutableCommandLine(commandLine.begin(), commandLine.end());
mutableCommandLine.push_back('\0');
STARTUPINFOA startupInfo = {};
startupInfo.cb = sizeof(startupInfo);
PROCESS_INFORMATION processInfo = {};
const BOOL created = CreateProcessA(
modulePath.c_str(),
mutableCommandLine.data(),
nullptr,
nullptr,
FALSE,
0,
nullptr,
nullptr,
&startupInfo,
&processInfo);
if (created)
{
CloseHandle(processInfo.hProcess);
CloseHandle(processInfo.hThread);
ExitProcess(0);
}
}).detach();
error.clear();
return true;
}
}

10
src/app/AppRestart.h Normal file
View File

@@ -0,0 +1,10 @@
#pragma once
#include <string>
namespace RenderCadenceCompositor
{
void WaitForRestartParentIfRequested(int argc, char** argv);
void SkipRestartParentArgs(int& index, int argc, char** argv);
bool ScheduleProcessRestart(std::string& error);
}

View File

@@ -1,7 +1,9 @@
#pragma once #pragma once
#include "AppConfig.h" #include "AppConfig.h"
#include "AppConfigJson.h"
#include "AppConfigProvider.h" #include "AppConfigProvider.h"
#include "AppRestart.h"
#include "RuntimeLayerController.h" #include "RuntimeLayerController.h"
#include "../logging/Logger.h" #include "../logging/Logger.h"
#include "../control/RuntimeStateJson.h" #include "../control/RuntimeStateJson.h"
@@ -10,6 +12,7 @@
#include "VideoIOEdges.h" #include "VideoIOEdges.h"
#include "VideoOutputThread.h" #include "VideoOutputThread.h"
#include <atomic>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <functional> #include <functional>
@@ -56,10 +59,12 @@ public:
RenderThread& renderThread, RenderThread& renderThread,
SystemFrameExchange& frameExchange, SystemFrameExchange& frameExchange,
AppConfig config, AppConfig config,
std::unique_ptr<IVideoOutputEdge> output) : std::unique_ptr<IVideoOutputEdge> output,
std::filesystem::path configPath = std::filesystem::path()) :
mRenderThread(renderThread), mRenderThread(renderThread),
mFrameExchange(frameExchange), mFrameExchange(frameExchange),
mConfig(config), mConfig(config),
mConfigPath(std::move(configPath)),
mOutput(std::move(output)), mOutput(std::move(output)),
mOutputThread(*mOutput, mFrameExchange, VideoOutputThreadConfig{ mOutputThread(*mOutput, mFrameExchange, VideoOutputThreadConfig{
mConfig.outputThread.targetBufferedFrames, mConfig.outputThread.targetBufferedFrames,
@@ -242,6 +247,9 @@ private:
callbacks.getStateJson = [this]() { callbacks.getStateJson = [this]() {
return BuildStateJson(); return BuildStateJson();
}; };
callbacks.getConfigJson = [this]() {
return BuildConfigJson();
};
callbacks.addLayer = [this](const std::string& body) { callbacks.addLayer = [this](const std::string& body) {
return mRuntimeLayers.HandleAddLayer(body); return mRuntimeLayers.HandleAddLayer(body);
}; };
@@ -249,6 +257,11 @@ private:
return mRuntimeLayers.HandleRemoveLayer(body); return mRuntimeLayers.HandleRemoveLayer(body);
}; };
callbacks.executePost = [this](const std::string& path, const std::string& body) { callbacks.executePost = [this](const std::string& path, const std::string& body) {
if (path == "/api/config/save")
return HandleConfigSave(body);
if (path == "/api/app/restart")
return HandleAppRestart();
RuntimeControlCommand command; RuntimeControlCommand command;
std::string error; std::string error;
if (!ParseRuntimeControlCommand(path, body, command, error)) if (!ParseRuntimeControlCommand(path, body, command, error))
@@ -303,6 +316,56 @@ private:
}); });
} }
std::string BuildConfigJson() const
{
AppConfig diskConfig = mConfig;
std::string loadError;
bool diskLoaded = false;
if (!mConfigPath.empty())
{
AppConfigProvider provider;
diskLoaded = provider.Load(mConfigPath, loadError);
if (diskLoaded)
diskConfig = provider.Config();
}
JsonValue root = JsonValue::MakeObject();
root.set("ok", JsonValue(true));
root.set("path", JsonValue(mConfigPath.string()));
root.set("active", AppConfigToJsonValue(mConfig));
root.set("disk", AppConfigToJsonValue(diskConfig));
root.set("diskLoaded", JsonValue(diskLoaded));
root.set("restartRequired", JsonValue(mRestartRequired.load(std::memory_order_relaxed)));
if (!loadError.empty())
root.set("error", JsonValue(loadError));
return SerializeJson(root);
}
ControlActionResult HandleConfigSave(const std::string& body)
{
AppConfig nextConfig;
std::string error;
if (!ParseAppConfigJson(body, nextConfig, error))
return ControlActionResult{ false, error };
if (!SaveAppConfigToFile(nextConfig, mConfigPath, error))
return ControlActionResult{ false, error };
mRestartRequired.store(true, std::memory_order_relaxed);
Log("app", "Saved runtime host config to " + mConfigPath.string() + "; restart required for startup-owned services.");
return ControlActionResult{ true };
}
ControlActionResult HandleAppRestart()
{
std::string error;
if (!ScheduleProcessRestart(error))
return ControlActionResult{ false, error };
Log("app", "App restart requested from HTTP control.");
return ControlActionResult{ true };
}
void ApplyVideoInputMetrics(CadenceTelemetrySnapshot& telemetry) void ApplyVideoInputMetrics(CadenceTelemetrySnapshot& telemetry)
{ {
if (!mVideoInputMetricsProvider) if (!mVideoInputMetricsProvider)
@@ -335,6 +398,7 @@ private:
RenderThread& mRenderThread; RenderThread& mRenderThread;
SystemFrameExchange& mFrameExchange; SystemFrameExchange& mFrameExchange;
AppConfig mConfig; AppConfig mConfig;
std::filesystem::path mConfigPath;
std::unique_ptr<IVideoOutputEdge> mOutput; std::unique_ptr<IVideoOutputEdge> mOutput;
VideoOutputThread<SystemFrameExchange> mOutputThread; VideoOutputThread<SystemFrameExchange> mOutputThread;
TelemetryHealthMonitor mTelemetryHealth; TelemetryHealthMonitor mTelemetryHealth;
@@ -344,6 +408,7 @@ private:
RuntimeLayerController mRuntimeLayers; RuntimeLayerController mRuntimeLayers;
std::function<VideoInputEdgeMetrics()> mVideoInputMetricsProvider; std::function<VideoInputEdgeMetrics()> mVideoInputMetricsProvider;
uint64_t mLastInputCapturedFrames = 0; uint64_t mLastInputCapturedFrames = 0;
std::atomic<bool> mRestartRequired{ false };
bool mStarted = false; bool mStarted = false;
bool mVideoOutputEnabled = false; bool mVideoOutputEnabled = false;
std::string mVideoOutputStatus = "Video output not started."; std::string mVideoOutputStatus = "Video output not started.";

View File

@@ -26,6 +26,7 @@ struct HttpControlServerConfig
struct HttpControlServerCallbacks struct HttpControlServerCallbacks
{ {
std::function<std::string()> getStateJson; std::function<std::string()> getStateJson;
std::function<std::string()> getConfigJson;
std::function<ControlActionResult(const std::string&)> addLayer; std::function<ControlActionResult(const std::string&)> addLayer;
std::function<ControlActionResult(const std::string&)> removeLayer; std::function<ControlActionResult(const std::string&)> removeLayer;
std::function<ControlActionResult(const std::string&, const std::string&)> executePost; std::function<ControlActionResult(const std::string&, const std::string&)> executePost;

View File

@@ -23,6 +23,8 @@ bool IsKnownPostEndpoint(const std::string& path)
|| path == "/api/layers/reset-parameters" || path == "/api/layers/reset-parameters"
|| path == "/api/stack-presets/save" || path == "/api/stack-presets/save"
|| path == "/api/stack-presets/load" || path == "/api/stack-presets/load"
|| path == "/api/config/save"
|| path == "/api/app/restart"
|| path == "/api/reload" || path == "/api/reload"
|| path == "/api/screenshot"; || path == "/api/screenshot";
} }
@@ -32,6 +34,8 @@ HttpControlServer::HttpResponse HttpControlServer::ServeGet(const HttpRequest& r
{ {
if (request.path == "/api/state") if (request.path == "/api/state")
return JsonResponse("200 OK", mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}"); return JsonResponse("200 OK", mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}");
if (request.path == "/api/config")
return JsonResponse("200 OK", mCallbacks.getConfigJson ? mCallbacks.getConfigJson() : "{}");
if (request.path == "/openapi.yaml" || request.path == "/docs/openapi.yaml") if (request.path == "/openapi.yaml" || request.path == "/docs/openapi.yaml")
return ServeOpenApiSpec(); return ServeOpenApiSpec();
if (request.path == "/docs" || request.path == "/docs/") if (request.path == "/docs" || request.path == "/docs/")

View File

@@ -0,0 +1,33 @@
#pragma once
#include "GLExtensions.h"
#include "platform/HiddenGlWindow.h"
#include <cstdint>
#include <memory>
#include <string>
struct RenderContentGlConfig
{
unsigned width = 0;
unsigned height = 0;
std::unique_ptr<HiddenGlWindow> sharedPrepareWindow;
};
struct RenderContentFrame
{
uint64_t frameIndex = 0;
unsigned width = 0;
unsigned height = 0;
GLuint videoInputTexture = 0;
};
class IRenderContent
{
public:
virtual ~IRenderContent() = default;
virtual bool InitializeGl(RenderContentGlConfig config, std::string& error) = 0;
virtual void RenderFrame(const RenderContentFrame& frame) = 0;
virtual void ShutdownGl() = 0;
};

View File

@@ -0,0 +1,71 @@
#include "RuntimeShaderRenderContent.h"
#include <utility>
RuntimeShaderRenderContent::~RuntimeShaderRenderContent()
{
ShutdownGl();
}
bool RuntimeShaderRenderContent::InitializeGl(RenderContentGlConfig config, std::string& error)
{
ShutdownGl();
mWidth = config.width;
mHeight = config.height;
if (mWidth == 0 || mHeight == 0)
{
error = "Render content dimensions are invalid.";
return false;
}
if (!mRuntimeRenderScene.StartPrepareWorker(std::move(config.sharedPrepareWindow), error))
{
if (error.empty())
error = "Runtime shader prepare worker initialization failed.";
ShutdownGl();
return false;
}
if (!mFallbackRenderer.InitializeGl(mWidth, mHeight))
{
error = "Fallback renderer initialization failed.";
ShutdownGl();
return false;
}
error.clear();
return true;
}
void RuntimeShaderRenderContent::RenderFrame(const RenderContentFrame& frame)
{
if (mRuntimeRenderScene.HasLayers())
{
mRuntimeRenderScene.RenderFrame(frame.frameIndex, frame.width, frame.height, frame.videoInputTexture);
return;
}
if (frame.videoInputTexture != 0)
{
mFallbackRenderer.RenderTexture(frame.videoInputTexture);
return;
}
mFallbackRenderer.RenderFrame(frame.frameIndex);
}
void RuntimeShaderRenderContent::ShutdownGl()
{
mRuntimeRenderScene.ShutdownGl();
mFallbackRenderer.ShutdownGl();
mWidth = 0;
mHeight = 0;
}
bool RuntimeShaderRenderContent::CommitRenderLayers(
const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers,
std::string& error,
bool* structuralChange)
{
return mRuntimeRenderScene.CommitRenderLayers(layers, error, structuralChange);
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include "RenderContent.h"
#include "RuntimeLayerModel.h"
#include "runtime/RuntimeRenderScene.h"
#include "SimpleMotionRenderer.h"
#include <string>
#include <vector>
class RuntimeShaderRenderContent final : public IRenderContent
{
public:
RuntimeShaderRenderContent() = default;
RuntimeShaderRenderContent(const RuntimeShaderRenderContent&) = delete;
RuntimeShaderRenderContent& operator=(const RuntimeShaderRenderContent&) = delete;
~RuntimeShaderRenderContent() override;
bool InitializeGl(RenderContentGlConfig config, std::string& error) override;
void RenderFrame(const RenderContentFrame& frame) override;
void ShutdownGl() override;
bool CommitRenderLayers(
const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers,
std::string& error,
bool* structuralChange = nullptr);
private:
SimpleMotionRenderer mFallbackRenderer;
RuntimeRenderScene mRuntimeRenderScene;
unsigned mWidth = 0;
unsigned mHeight = 0;
};

View File

@@ -8,8 +8,8 @@
#include "InputFrameTexture.h" #include "InputFrameTexture.h"
#include "readback/OutputReadbackPipeline.h" #include "readback/OutputReadbackPipeline.h"
#include "GLExtensions.h" #include "GLExtensions.h"
#include "RuntimeRenderScene.h" #include "RenderContent.h"
#include "SimpleMotionRenderer.h" #include "RuntimeShaderRenderContent.h"
#include <memory> #include <memory>
#include <thread> #include <thread>
@@ -41,19 +41,13 @@ void RenderThread::ThreadMain()
return; return;
} }
SimpleMotionRenderer renderer; RuntimeShaderRenderContent renderContent;
RuntimeRenderScene runtimeRenderScene;
OutputReadbackPipeline readback; OutputReadbackPipeline readback;
InputFrameTexture inputTexture; InputFrameTexture inputTexture;
if (!runtimeRenderScene.StartPrepareWorker(std::move(prepareWindow), error)) if (!renderContent.InitializeGl(RenderContentGlConfig{ mConfig.width, mConfig.height, std::move(prepareWindow) }, error) ||
{
SignalStartupFailure(error.empty() ? "Runtime shader prepare worker initialization failed." : error);
return;
}
if (!renderer.InitializeGl(mConfig.width, mConfig.height) ||
!readback.Initialize(mConfig.width, mConfig.height, mConfig.outputPixelFormat, mConfig.pboDepth)) !readback.Initialize(mConfig.width, mConfig.height, mConfig.outputPixelFormat, mConfig.pboDepth))
{ {
SignalStartupFailure("Render pipeline initialization failed."); SignalStartupFailure(error.empty() ? "Render pipeline initialization failed." : error);
return; return;
} }
@@ -82,16 +76,16 @@ void RenderThread::ThreadMain()
continue; continue;
} }
TryCommitReadyRuntimeShader(runtimeRenderScene); TryCommitReadyRuntimeShader(renderContent);
const GLuint videoInputTexture = inputTexture.PollAndUpload(mInputMailbox); const GLuint videoInputTexture = inputTexture.PollAndUpload(mInputMailbox);
PublishInputMetrics(inputTexture); PublishInputMetrics(inputTexture);
if (!readback.RenderAndQueue(frameIndex, [this, &renderer, &runtimeRenderScene, videoInputTexture](uint64_t index) { if (!readback.RenderAndQueue(frameIndex, [this, &renderContent, videoInputTexture](uint64_t index) {
if (runtimeRenderScene.HasLayers()) renderContent.RenderFrame(RenderContentFrame{
runtimeRenderScene.RenderFrame(index, mConfig.width, mConfig.height, videoInputTexture); index,
else if (videoInputTexture != 0) mConfig.width,
renderer.RenderTexture(videoInputTexture); mConfig.height,
else videoInputTexture
renderer.RenderFrame(index); });
})) }))
{ {
mPboQueueMisses.fetch_add(1, std::memory_order_relaxed); mPboQueueMisses.fetch_add(1, std::memory_order_relaxed);
@@ -120,8 +114,7 @@ void RenderThread::ThreadMain()
readback.Shutdown(); readback.Shutdown();
inputTexture.ShutdownGl(); inputTexture.ShutdownGl();
runtimeRenderScene.ShutdownGl(); renderContent.ShutdownGl();
renderer.ShutdownGl();
window.ClearCurrent(); window.ClearCurrent();
mRunning.store(false, std::memory_order_release); mRunning.store(false, std::memory_order_release);
RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread stopped."); RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread stopped.");

View File

@@ -3,7 +3,6 @@
#include "RenderCadenceClock.h" #include "RenderCadenceClock.h"
#include "RuntimeLayerModel.h" #include "RuntimeLayerModel.h"
#include "RuntimeShaderArtifact.h" #include "RuntimeShaderArtifact.h"
#include "RuntimeRenderScene.h"
#include "VideoIOFormat.h" #include "VideoIOFormat.h"
#include <atomic> #include <atomic>
@@ -18,6 +17,7 @@ class SystemFrameExchange;
class InputFrameMailbox; class InputFrameMailbox;
class InputFrameTexture; class InputFrameTexture;
class OutputReadbackPipeline; class OutputReadbackPipeline;
class RuntimeShaderRenderContent;
class RenderThread class RenderThread
{ {
@@ -81,7 +81,7 @@ private:
void CountAcquireMiss(); void CountAcquireMiss();
void PublishReadbackMetrics(const OutputReadbackPipeline& readback); void PublishReadbackMetrics(const OutputReadbackPipeline& readback);
void PublishInputMetrics(const InputFrameTexture& inputTexture); void PublishInputMetrics(const InputFrameTexture& inputTexture);
void TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene); void TryCommitReadyRuntimeShader(RuntimeShaderRenderContent& renderContent);
bool TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact); bool TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact);
bool TryTakePendingRenderLayers(std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers); bool TryTakePendingRenderLayers(std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers);

View File

@@ -1,6 +1,7 @@
#include "RenderThread.h" #include "RenderThread.h"
#include "../logging/Logger.h" #include "../logging/Logger.h"
#include "RuntimeShaderRenderContent.h"
#include <mutex> #include <mutex>
#include <utility> #include <utility>
@@ -47,14 +48,14 @@ bool RenderThread::TryTakePendingRenderLayers(std::vector<RenderCadenceComposito
return true; return true;
} }
void RenderThread::TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene) void RenderThread::TryCommitReadyRuntimeShader(RuntimeShaderRenderContent& renderContent)
{ {
std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> layers; std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> layers;
std::string commitError; std::string commitError;
if (TryTakePendingRenderLayers(layers)) if (TryTakePendingRenderLayers(layers))
{ {
bool structuralChange = false; bool structuralChange = false;
if (!runtimeRenderScene.CommitRenderLayers(layers, commitError, &structuralChange)) if (!renderContent.CommitRenderLayers(layers, commitError, &structuralChange))
{ {
RenderCadenceCompositor::TryLog( RenderCadenceCompositor::TryLog(
RenderCadenceCompositor::LogLevel::Error, RenderCadenceCompositor::LogLevel::Error,
@@ -84,7 +85,7 @@ void RenderThread::TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRender
layer.shaderId = artifact.shaderId; layer.shaderId = artifact.shaderId;
layer.artifact = artifact; layer.artifact = artifact;
layers.push_back(std::move(layer)); layers.push_back(std::move(layer));
if (!runtimeRenderScene.CommitRenderLayers(layers, commitError)) if (!renderContent.CommitRenderLayers(layers, commitError))
{ {
RenderCadenceCompositor::TryLog( RenderCadenceCompositor::TryLog(
RenderCadenceCompositor::LogLevel::Error, RenderCadenceCompositor::LogLevel::Error,

View File

@@ -71,7 +71,9 @@ add_video_shader_test(RenderCadenceCompositorSupportedShaderCatalogTests
add_video_shader_test(RenderCadenceCompositorRuntimeStateJsonTests add_video_shader_test(RenderCadenceCompositorRuntimeStateJsonTests
"${SRC_DIR}/app/AppConfig.cpp" "${SRC_DIR}/app/AppConfig.cpp"
"${SRC_DIR}/app/AppRestart.cpp"
"${SRC_DIR}/app/AppConfigProvider.cpp" "${SRC_DIR}/app/AppConfigProvider.cpp"
"${SRC_DIR}/app/AppConfigJson.cpp"
${VIDEO_MODE_SOURCES} ${VIDEO_MODE_SOURCES}
${VIDEO_FORMAT_SOURCES} ${VIDEO_FORMAT_SOURCES}
"${SRC_DIR}/json/JsonWriter.cpp" "${SRC_DIR}/json/JsonWriter.cpp"
@@ -97,7 +99,9 @@ target_link_libraries(RenderCadenceCompositorHttpControlServerTests PRIVATE Ws2_
add_video_shader_test(RenderCadenceCompositorAppConfigProviderTests add_video_shader_test(RenderCadenceCompositorAppConfigProviderTests
"${SRC_DIR}/app/AppConfig.cpp" "${SRC_DIR}/app/AppConfig.cpp"
"${SRC_DIR}/app/AppRestart.cpp"
"${SRC_DIR}/app/AppConfigProvider.cpp" "${SRC_DIR}/app/AppConfigProvider.cpp"
"${SRC_DIR}/app/AppConfigJson.cpp"
${VIDEO_MODE_SOURCES} ${VIDEO_MODE_SOURCES}
"${SRC_DIR}/video/decklink/DeckLinkDisplayMode.cpp" "${SRC_DIR}/video/decklink/DeckLinkDisplayMode.cpp"
${RUNTIME_JSON_SOURCES} ${RUNTIME_JSON_SOURCES}

View File

@@ -1,4 +1,5 @@
#include "AppConfigProvider.h" #include "AppConfigProvider.h"
#include "AppConfigJson.h"
#include "DeckLinkDisplayMode.h" #include "DeckLinkDisplayMode.h"
#include <chrono> #include <chrono>
@@ -51,7 +52,8 @@ std::filesystem::path WriteConfigFixture()
<< " \"autoReload\": false,\n" << " \"autoReload\": false,\n"
<< " \"maxTemporalHistoryFrames\": 8,\n" << " \"maxTemporalHistoryFrames\": 8,\n"
<< " \"previewEnabled\": true,\n" << " \"previewEnabled\": true,\n"
<< " \"previewFps\": 24\n" << " \"previewFps\": 24,\n"
<< " \"runtimeShaderId\": \"solid-color\"\n"
<< "}\n"; << "}\n";
return path; return path;
} }
@@ -87,6 +89,7 @@ void TestLoadsRuntimeHostConfig()
Expect(config.maxTemporalHistoryFrames == 8, "history length loads"); Expect(config.maxTemporalHistoryFrames == 8, "history length loads");
Expect(config.previewEnabled, "preview enabled toggle loads"); Expect(config.previewEnabled, "preview enabled toggle loads");
Expect(config.previewFps == 24.0, "preview fps loads"); Expect(config.previewFps == 24.0, "preview fps loads");
Expect(config.runtimeShaderId == "solid-color", "runtime shader id loads");
Expect(config.output.externalKeyingEnabled, "external keying loads"); Expect(config.output.externalKeyingEnabled, "external keying loads");
Expect(config.output.outputAlphaRequired, "output alpha requirement loads"); Expect(config.output.outputAlphaRequired, "output alpha requirement loads");
@@ -121,6 +124,31 @@ void TestPreviewDefaultsAreOptIn()
Expect(config.previewFps == 60.0, "preview fps default is 60"); Expect(config.previewFps == 60.0, "preview fps default is 60");
} }
void TestConfigJsonRoundTrip()
{
using namespace RenderCadenceCompositor;
AppConfig config = DefaultAppConfig();
config.shaderLibrary = "custom-shaders";
config.output.backend = "ndi";
config.output.device = "Program";
config.output.pixelFormat = "uyvy8";
config.previewEnabled = true;
config.runtimeShaderId = "solid-color";
const std::string json = AppConfigToJson(config);
AppConfig parsed;
std::string error;
Expect(ParseAppConfigJson(json, parsed, error), "serialized config parses");
Expect(error.empty(), "serialized config parse has no error");
Expect(parsed.shaderLibrary == "custom-shaders", "shader library round trips");
Expect(parsed.output.backend == "ndi", "output backend round trips");
Expect(parsed.output.device == "Program", "output device round trips");
Expect(parsed.output.pixelFormat == "uyvy8", "output pixel format round trips");
Expect(parsed.previewEnabled, "preview enabled round trips");
Expect(parsed.runtimeShaderId == "solid-color", "runtime shader id round trips");
}
void TestHelpers() void TestHelpers()
{ {
using namespace RenderCadenceCompositor; using namespace RenderCadenceCompositor;
@@ -149,6 +177,7 @@ int main()
TestLoadsRuntimeHostConfig(); TestLoadsRuntimeHostConfig();
TestCommandLineOverrides(); TestCommandLineOverrides();
TestPreviewDefaultsAreOptIn(); TestPreviewDefaultsAreOptIn();
TestConfigJsonRoundTrip();
TestHelpers(); TestHelpers();
if (gFailures != 0) if (gFailures != 0)

View File

@@ -64,6 +64,25 @@ void TestStateEndpointUsesCallback()
ExpectEquals(response.body, "{\"ok\":true}", "state endpoint returns callback JSON"); ExpectEquals(response.body, "{\"ok\":true}", "state endpoint returns callback JSON");
} }
void TestConfigEndpointUsesCallback()
{
using namespace RenderCadenceCompositor;
HttpControlServer server;
HttpControlServerCallbacks callbacks;
callbacks.getConfigJson = []() { return std::string("{\"diskLoaded\":true}"); };
server.SetCallbacksForTest(callbacks);
HttpControlServer::HttpRequest request;
request.method = "GET";
request.path = "/api/config";
const HttpControlServer::HttpResponse response = server.RouteRequestForTest(request);
ExpectEquals(response.status, "200 OK", "config endpoint succeeds");
ExpectEquals(response.contentType, "application/json", "config endpoint is JSON");
ExpectEquals(response.body, "{\"diskLoaded\":true}", "config endpoint returns callback JSON");
}
void TestWebSocketAcceptKey() void TestWebSocketAcceptKey()
{ {
using namespace RenderCadenceCompositor; using namespace RenderCadenceCompositor;
@@ -171,6 +190,29 @@ void TestGenericPostCallbackHandlesControlRoutes()
Expect(response.body.find("\"ok\":true") != std::string::npos, "generic control callback returns action success"); Expect(response.body.find("\"ok\":true") != std::string::npos, "generic control callback returns action success");
} }
void TestGenericPostCallbackHandlesConfigRoutes()
{
using namespace RenderCadenceCompositor;
HttpControlServer server;
HttpControlServerCallbacks callbacks;
callbacks.executePost = [](const std::string& path, const std::string& body) {
ExpectEquals(path, "/api/config/save", "generic callback receives config route path");
Expect(body.find("runtimeShaderId") != std::string::npos, "generic callback receives config request body");
return ControlActionResult{ true, std::string() };
};
server.SetCallbacksForTest(callbacks);
HttpControlServer::HttpRequest request;
request.method = "POST";
request.path = "/api/config/save";
request.body = "{\"runtimeShaderId\":\"solid-color\"}";
const HttpControlServer::HttpResponse response = server.RouteRequestForTest(request);
ExpectEquals(response.status, "200 OK", "config save callback success returns 200");
Expect(response.body.find("\"ok\":true") != std::string::npos, "config save callback returns action success");
}
void TestReloadRouteParsesAsControlCommand() void TestReloadRouteParsesAsControlCommand()
{ {
using namespace RenderCadenceCompositor; using namespace RenderCadenceCompositor;
@@ -199,11 +241,13 @@ int main()
{ {
TestParsesHttpRequest(); TestParsesHttpRequest();
TestStateEndpointUsesCallback(); TestStateEndpointUsesCallback();
TestConfigEndpointUsesCallback();
TestWebSocketAcceptKey(); TestWebSocketAcceptKey();
TestRootServesUiIndex(); TestRootServesUiIndex();
TestKnownPostEndpointReturnsActionError(); TestKnownPostEndpointReturnsActionError();
TestLayerPostEndpointsUseCallbacks(); TestLayerPostEndpointsUseCallbacks();
TestGenericPostCallbackHandlesControlRoutes(); TestGenericPostCallbackHandlesControlRoutes();
TestGenericPostCallbackHandlesConfigRoutes();
TestReloadRouteParsesAsControlCommand(); TestReloadRouteParsesAsControlCommand();
TestUnknownEndpointReturns404(); TestUnknownEndpointReturns404();

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LayerStack } from "./components/LayerStack"; import { LayerStack } from "./components/LayerStack";
import { ConfigEditor } from "./components/ConfigEditor";
import { StackPresetToolbar } from "./components/StackPresetToolbar"; import { StackPresetToolbar } from "./components/StackPresetToolbar";
import { StatusPanels } from "./components/StatusPanels"; import { StatusPanels } from "./components/StatusPanels";
import { useRuntimeState } from "./hooks/useRuntimeState"; import { useRuntimeState } from "./hooks/useRuntimeState";
@@ -91,6 +92,7 @@ function App() {
<section className="dashboard-grid"> <section className="dashboard-grid">
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} /> <StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} />
<StackPresetToolbar /> <StackPresetToolbar />
<ConfigEditor />
</section> </section>
<LayerStack <LayerStack

View File

@@ -5,3 +5,21 @@ export function postJson(path, payload) {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function fetchJson(path) {
const response = await fetch(path);
const body = await response.json();
if (!response.ok) {
throw new Error(body?.error || `Request failed: ${response.status}`);
}
return body;
}
export async function postJsonResult(path, payload) {
const response = await postJson(path, payload);
const body = await response.json();
if (!response.ok || body?.ok === false) {
throw new Error(body?.error || `Request failed: ${response.status}`);
}
return body;
}

View File

@@ -0,0 +1,251 @@
import { Power, RefreshCw, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { fetchJson, postJsonResult } from "../api/controlApi";
const backendOptions = ["decklink", "ndi", "none"];
const resolutionOptions = ["720p", "1080p", "2160p"];
const frameRateOptions = ["23.98", "24", "25", "29.97", "30", "50", "59.94", "60"];
const pixelFormatOptions = ["auto", "bgra8", "uyvy8"];
function cloneConfig(config) {
return JSON.parse(JSON.stringify(config ?? {}));
}
function readPath(object, path) {
return path.split(".").reduce((value, key) => value?.[key], object);
}
function writePath(object, path, value) {
const keys = path.split(".");
const root = cloneConfig(object);
let target = root;
for (let index = 0; index < keys.length - 1; ++index) {
const key = keys[index];
target[key] = target[key] ?? {};
target = target[key];
}
target[keys[keys.length - 1]] = value;
return root;
}
function Field({ label, children }) {
return (
<label className="config-field">
<span>{label}</span>
{children}
</label>
);
}
function TextField({ config, label, path, setConfig }) {
return (
<Field label={label}>
<input
type="text"
value={readPath(config, path) ?? ""}
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
/>
</Field>
);
}
function NumberField({ config, label, min, path, setConfig, step = 1 }) {
return (
<Field label={label}>
<input
min={min}
step={step}
type="number"
value={readPath(config, path) ?? 0}
onChange={(event) => setConfig((current) => writePath(current, path, Number(event.target.value)))}
/>
</Field>
);
}
function SelectField({ config, label, options, path, setConfig }) {
return (
<Field label={label}>
<select
value={readPath(config, path) ?? options[0]}
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
);
}
function ToggleField({ config, label, path, setConfig }) {
return (
<label className="toggle toggle--field config-toggle">
<input
checked={Boolean(readPath(config, path))}
type="checkbox"
onChange={(event) => setConfig((current) => writePath(current, path, event.target.checked))}
/>
<span>{label}</span>
</label>
);
}
export function ConfigEditor() {
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 dirty = useMemo(() => JSON.stringify(draft ?? {}) !== JSON.stringify(saved ?? {}), [draft, saved]);
async function loadConfig() {
setBusy(true);
setStatus("");
try {
const response = await fetchJson("/api/config");
const nextConfig = response.disk ?? response.active ?? {};
setDraft(cloneConfig(nextConfig));
setSaved(cloneConfig(nextConfig));
setPath(response.path ?? "");
setRestartRequired(Boolean(response.restartRequired));
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
useEffect(() => {
loadConfig();
}, []);
async function saveConfig() {
if (!draft) return;
setBusy(true);
setStatus("");
try {
await postJsonResult("/api/config/save", draft);
setSaved(cloneConfig(draft));
setRestartRequired(true);
setStatus("Saved");
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
async function restartApp() {
setBusy(true);
setStatus("");
try {
await postJsonResult("/api/app/restart", {});
setStatus("Restarting");
} catch (error) {
setStatus(error.message);
setBusy(false);
}
}
if (!draft) {
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>
</div>
<p className="muted">{status || "Loading config."}</p>
</div>
);
}
return (
<div className="panel config-panel">
<div className="panel__header config-panel__header">
<div>
<h3>Host config</h3>
<p className="muted">{path || "runtime-host.json"}</p>
</div>
<div className="config-panel__actions">
<button type="button" className="icon-button" onClick={loadConfig} disabled={busy} title="Reload config">
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
<button type="button" className="button-with-icon" onClick={saveConfig} disabled={busy || !dirty}>
<Save size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Save</span>
</button>
<button
type="button"
className="button-with-icon config-panel__restart"
onClick={restartApp}
disabled={busy || dirty || !restartRequired}
>
<Power size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Restart</span>
</button>
</div>
</div>
<div className="config-grid">
<section className="config-section">
<h4>Runtime</h4>
<div className="config-fields config-fields--wide">
<TextField config={draft} label="Shader library" path="shaderLibrary" setConfig={setDraft} />
<TextField config={draft} label="Startup shader" path="runtimeShaderId" setConfig={setDraft} />
<NumberField config={draft} label="Server port" min={1} path="serverPort" setConfig={setDraft} />
<NumberField config={draft} label="Temporal cap" min={0} path="maxTemporalHistoryFrames" setConfig={setDraft} />
<ToggleField config={draft} label="Auto reload" path="autoReload" setConfig={setDraft} />
<ToggleField config={draft} label="Preview" path="previewEnabled" setConfig={setDraft} />
<NumberField config={draft} label="Preview fps" min={1} path="previewFps" setConfig={setDraft} step={0.01} />
</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">
<TextField config={draft} label="Bind address" path="oscBindAddress" setConfig={setDraft} />
<NumberField config={draft} label="Port" min={1} path="oscPort" setConfig={setDraft} />
<NumberField config={draft} label="Smoothing" min={0} path="oscSmoothing" setConfig={setDraft} step={0.01} />
</div>
</section>
</div>
{(status || dirty || restartRequired) && (
<p className={`config-status${status && status !== "Saved" && status !== "Restarting" ? " config-status--error" : ""}`}>
{status || (dirty ? "Unsaved changes" : "Restart required")}
</p>
)}
</div>
);
}

View File

@@ -239,7 +239,8 @@ pre {
.panel--compiler, .panel--compiler,
.panel--telemetry, .panel--telemetry,
.stack-panel { .stack-panel,
.config-panel {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -509,6 +510,96 @@ pre {
min-width: 8.75rem; min-width: 8.75rem;
} }
.config-panel {
display: grid;
gap: 0.9rem;
}
.config-panel__header {
margin-bottom: 0;
}
.config-panel__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.config-panel__restart {
background: #b42318;
border-color: #8f1d13;
color: #fff7f5;
}
.config-panel__restart:hover:not(:disabled) {
background: #912018;
}
.config-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 1rem;
}
.config-section {
min-width: 0;
display: grid;
align-content: start;
gap: 0.65rem;
padding: 0.8rem;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: rgba(255, 255, 255, 0.025);
}
.config-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.config-fields--wide {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.config-field {
min-width: 0;
display: grid;
gap: 0.25rem;
color: var(--app-muted);
font-size: 0.78rem;
}
.config-field input,
.config-field select {
min-height: 38px;
padding: 0.5rem 0.6rem;
color: var(--app-text);
font-size: 0.9rem;
}
.config-toggle {
align-self: end;
min-height: 38px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: var(--app-surface-2);
color: var(--app-text);
}
.config-status {
margin: 0;
color: #c5efd3;
font-size: 0.84rem;
font-weight: 700;
}
.config-status--error {
color: #ffd0cf;
}
.stack-panel__screenshot { .stack-panel__screenshot {
min-width: 8.75rem; min-width: 8.75rem;
} }
@@ -1192,7 +1283,9 @@ pre {
} }
.dashboard-grid, .dashboard-grid,
.stack-panel__grid { .stack-panel__grid,
.config-grid,
.config-fields--wide {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1226,6 +1319,7 @@ pre {
.definition-grid, .definition-grid,
.summary-grid, .summary-grid,
.kv-rows, .kv-rows,
.config-fields,
.parameter-grid, .parameter-grid,
.parameter, .parameter,
.parameter__header { .parameter__header {