Added config editor in front end
This commit is contained in:
@@ -21,7 +21,7 @@ The app is a native C++ OpenGL compositor with:
|
||||
Primary source areas:
|
||||
|
||||
- `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/runtime`: render-thread-owned runtime shader scene, renderer, text texture upload cache, and shared-context shader prepare worker
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
- 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 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.
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
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
|
||||
if (runtimeRenderScene.HasLayers())
|
||||
runtimeRenderScene.RenderFrame(index, mConfig.width, mConfig.height, videoInputTexture);
|
||||
else if (videoInputTexture != 0)
|
||||
renderer.RenderTexture(videoInputTexture);
|
||||
else
|
||||
renderer.RenderFrame(index);
|
||||
renderContent.RenderFrame(RenderContentFrame{
|
||||
index,
|
||||
mConfig.width,
|
||||
mConfig.height,
|
||||
videoInputTexture
|
||||
});
|
||||
```
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
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.
|
||||
3. Port the existing runtime shader renderer behind that abstraction as the baseline implementation.
|
||||
4. Add the new renderer beside it.
|
||||
2. Keep `IRenderContent` as the boundary behind the `RenderThread` draw call.
|
||||
3. Keep `RuntimeShaderRenderContent` as the baseline implementation until the fork's renderer exists.
|
||||
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.
|
||||
6. Only then remove shader package pieces that the new repo no longer needs.
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ servers:
|
||||
tags:
|
||||
- name: State
|
||||
description: Runtime state and status.
|
||||
- name: Config
|
||||
description: Startup host configuration and restart control.
|
||||
- name: Static
|
||||
description: Bundled control UI and static assets served by the local host.
|
||||
- name: Docs
|
||||
@@ -179,6 +181,52 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
tags: [State]
|
||||
@@ -511,6 +559,90 @@ components:
|
||||
type: string
|
||||
example: live-show-look
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -65,7 +65,8 @@ Included now:
|
||||
- background Slang compile of `shaders/happy-accident`
|
||||
- app-owned display/render layer model for shader build readiness
|
||||
- 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
|
||||
- render-thread-only GL program swap once a prepared program is ready
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -441,11 +444,13 @@ This app keeps the same core behavior but splits it into modules that can grow:
|
||||
|
||||
- `frames/`: system-memory handoff
|
||||
- `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
|
||||
- `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/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/RuntimeShaderPrepareWorker`: shared-context runtime shader program compile/link worker
|
||||
- `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:
|
||||
|
||||
1. replace `SimpleMotionRenderer` with a render-scene interface
|
||||
2. port shader package rendering
|
||||
3. broaden runtime snapshots/live state toward OSC and presets
|
||||
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
|
||||
1. broaden runtime snapshots/live state toward OSC and presets
|
||||
2. add screenshot capture from system-memory frames
|
||||
3. add scaling and additional input format support after the BGRA8/raw-UYVY8 input edge is stable
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "app/AppConfig.h"
|
||||
#include "app/AppConfigProvider.h"
|
||||
#include "app/AppRestart.h"
|
||||
#include "app/RenderCadenceApp.h"
|
||||
#include "app/VideoBackendFactory.h"
|
||||
#include "frames/InputFrameMailbox.h"
|
||||
@@ -92,6 +93,8 @@ VideoIOPixelFormat SelectSystemFramePixelFormat(const RenderCadenceCompositor::A
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
RenderCadenceCompositor::WaitForRestartParentIfRequested(argc, argv);
|
||||
|
||||
RenderCadenceCompositor::AppConfigProvider configProvider;
|
||||
std::string configError;
|
||||
if (!configProvider.LoadDefault(configError))
|
||||
@@ -197,7 +200,8 @@ int main(int argc, char** argv)
|
||||
renderThread,
|
||||
frameExchange,
|
||||
appConfig,
|
||||
std::move(outputBackend));
|
||||
std::move(outputBackend),
|
||||
configProvider.SourcePath());
|
||||
app.SetVideoInputMetricsProvider([inputBackend = inputBackend.get()]() {
|
||||
return inputBackend ? inputBackend->Metrics() : RenderCadenceCompositor::VideoInputEdgeMetrics();
|
||||
});
|
||||
|
||||
212
src/app/AppConfigJson.cpp
Normal file
212
src/app/AppConfigJson.cpp
Normal 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
16
src/app/AppConfigJson.h
Normal 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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "AppConfigProvider.h"
|
||||
|
||||
#include "AppRestart.h"
|
||||
#include "RuntimeJson.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -162,6 +163,7 @@ bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& err
|
||||
ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames);
|
||||
ApplyBool(root, "previewEnabled", mConfig.previewEnabled);
|
||||
ApplyDouble(root, "previewFps", mConfig.previewFps);
|
||||
ApplyString(root, "runtimeShaderId", mConfig.runtimeShaderId);
|
||||
|
||||
mLoadedFromFile = true;
|
||||
error.clear();
|
||||
@@ -173,6 +175,11 @@ void AppConfigProvider::ApplyCommandLine(int argc, char** argv)
|
||||
for (int index = 1; index < argc; ++index)
|
||||
{
|
||||
const std::string argument = argv[index];
|
||||
if (argument == "--restart-wait-pid")
|
||||
{
|
||||
SkipRestartParentArgs(index, argc, argv);
|
||||
continue;
|
||||
}
|
||||
if (argument == "--shader" && index + 1 < argc)
|
||||
{
|
||||
mConfig.runtimeShaderId = argv[++index];
|
||||
|
||||
117
src/app/AppRestart.cpp
Normal file
117
src/app/AppRestart.cpp
Normal 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
10
src/app/AppRestart.h
Normal 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);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "AppConfig.h"
|
||||
#include "AppConfigJson.h"
|
||||
#include "AppConfigProvider.h"
|
||||
#include "AppRestart.h"
|
||||
#include "RuntimeLayerController.h"
|
||||
#include "../logging/Logger.h"
|
||||
#include "../control/RuntimeStateJson.h"
|
||||
@@ -10,6 +12,7 @@
|
||||
#include "VideoIOEdges.h"
|
||||
#include "VideoOutputThread.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
@@ -56,10 +59,12 @@ public:
|
||||
RenderThread& renderThread,
|
||||
SystemFrameExchange& frameExchange,
|
||||
AppConfig config,
|
||||
std::unique_ptr<IVideoOutputEdge> output) :
|
||||
std::unique_ptr<IVideoOutputEdge> output,
|
||||
std::filesystem::path configPath = std::filesystem::path()) :
|
||||
mRenderThread(renderThread),
|
||||
mFrameExchange(frameExchange),
|
||||
mConfig(config),
|
||||
mConfigPath(std::move(configPath)),
|
||||
mOutput(std::move(output)),
|
||||
mOutputThread(*mOutput, mFrameExchange, VideoOutputThreadConfig{
|
||||
mConfig.outputThread.targetBufferedFrames,
|
||||
@@ -242,6 +247,9 @@ private:
|
||||
callbacks.getStateJson = [this]() {
|
||||
return BuildStateJson();
|
||||
};
|
||||
callbacks.getConfigJson = [this]() {
|
||||
return BuildConfigJson();
|
||||
};
|
||||
callbacks.addLayer = [this](const std::string& body) {
|
||||
return mRuntimeLayers.HandleAddLayer(body);
|
||||
};
|
||||
@@ -249,6 +257,11 @@ private:
|
||||
return mRuntimeLayers.HandleRemoveLayer(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;
|
||||
std::string 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)
|
||||
{
|
||||
if (!mVideoInputMetricsProvider)
|
||||
@@ -335,6 +398,7 @@ private:
|
||||
RenderThread& mRenderThread;
|
||||
SystemFrameExchange& mFrameExchange;
|
||||
AppConfig mConfig;
|
||||
std::filesystem::path mConfigPath;
|
||||
std::unique_ptr<IVideoOutputEdge> mOutput;
|
||||
VideoOutputThread<SystemFrameExchange> mOutputThread;
|
||||
TelemetryHealthMonitor mTelemetryHealth;
|
||||
@@ -344,6 +408,7 @@ private:
|
||||
RuntimeLayerController mRuntimeLayers;
|
||||
std::function<VideoInputEdgeMetrics()> mVideoInputMetricsProvider;
|
||||
uint64_t mLastInputCapturedFrames = 0;
|
||||
std::atomic<bool> mRestartRequired{ false };
|
||||
bool mStarted = false;
|
||||
bool mVideoOutputEnabled = false;
|
||||
std::string mVideoOutputStatus = "Video output not started.";
|
||||
|
||||
@@ -26,6 +26,7 @@ struct HttpControlServerConfig
|
||||
struct HttpControlServerCallbacks
|
||||
{
|
||||
std::function<std::string()> getStateJson;
|
||||
std::function<std::string()> getConfigJson;
|
||||
std::function<ControlActionResult(const std::string&)> addLayer;
|
||||
std::function<ControlActionResult(const std::string&)> removeLayer;
|
||||
std::function<ControlActionResult(const std::string&, const std::string&)> executePost;
|
||||
|
||||
@@ -23,6 +23,8 @@ bool IsKnownPostEndpoint(const std::string& path)
|
||||
|| path == "/api/layers/reset-parameters"
|
||||
|| path == "/api/stack-presets/save"
|
||||
|| path == "/api/stack-presets/load"
|
||||
|| path == "/api/config/save"
|
||||
|| path == "/api/app/restart"
|
||||
|| path == "/api/reload"
|
||||
|| path == "/api/screenshot";
|
||||
}
|
||||
@@ -32,6 +34,8 @@ HttpControlServer::HttpResponse HttpControlServer::ServeGet(const HttpRequest& r
|
||||
{
|
||||
if (request.path == "/api/state")
|
||||
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")
|
||||
return ServeOpenApiSpec();
|
||||
if (request.path == "/docs" || request.path == "/docs/")
|
||||
|
||||
33
src/render/RenderContent.h
Normal file
33
src/render/RenderContent.h
Normal 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;
|
||||
};
|
||||
71
src/render/RuntimeShaderRenderContent.cpp
Normal file
71
src/render/RuntimeShaderRenderContent.cpp
Normal 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);
|
||||
}
|
||||
33
src/render/RuntimeShaderRenderContent.h
Normal file
33
src/render/RuntimeShaderRenderContent.h
Normal 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;
|
||||
};
|
||||
@@ -8,8 +8,8 @@
|
||||
#include "InputFrameTexture.h"
|
||||
#include "readback/OutputReadbackPipeline.h"
|
||||
#include "GLExtensions.h"
|
||||
#include "RuntimeRenderScene.h"
|
||||
#include "SimpleMotionRenderer.h"
|
||||
#include "RenderContent.h"
|
||||
#include "RuntimeShaderRenderContent.h"
|
||||
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
@@ -41,19 +41,13 @@ void RenderThread::ThreadMain()
|
||||
return;
|
||||
}
|
||||
|
||||
SimpleMotionRenderer renderer;
|
||||
RuntimeRenderScene runtimeRenderScene;
|
||||
RuntimeShaderRenderContent renderContent;
|
||||
OutputReadbackPipeline readback;
|
||||
InputFrameTexture inputTexture;
|
||||
if (!runtimeRenderScene.StartPrepareWorker(std::move(prepareWindow), error))
|
||||
{
|
||||
SignalStartupFailure(error.empty() ? "Runtime shader prepare worker initialization failed." : error);
|
||||
return;
|
||||
}
|
||||
if (!renderer.InitializeGl(mConfig.width, mConfig.height) ||
|
||||
if (!renderContent.InitializeGl(RenderContentGlConfig{ mConfig.width, mConfig.height, std::move(prepareWindow) }, error) ||
|
||||
!readback.Initialize(mConfig.width, mConfig.height, mConfig.outputPixelFormat, mConfig.pboDepth))
|
||||
{
|
||||
SignalStartupFailure("Render pipeline initialization failed.");
|
||||
SignalStartupFailure(error.empty() ? "Render pipeline initialization failed." : error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,16 +76,16 @@ void RenderThread::ThreadMain()
|
||||
continue;
|
||||
}
|
||||
|
||||
TryCommitReadyRuntimeShader(runtimeRenderScene);
|
||||
TryCommitReadyRuntimeShader(renderContent);
|
||||
const GLuint videoInputTexture = inputTexture.PollAndUpload(mInputMailbox);
|
||||
PublishInputMetrics(inputTexture);
|
||||
if (!readback.RenderAndQueue(frameIndex, [this, &renderer, &runtimeRenderScene, videoInputTexture](uint64_t index) {
|
||||
if (runtimeRenderScene.HasLayers())
|
||||
runtimeRenderScene.RenderFrame(index, mConfig.width, mConfig.height, videoInputTexture);
|
||||
else if (videoInputTexture != 0)
|
||||
renderer.RenderTexture(videoInputTexture);
|
||||
else
|
||||
renderer.RenderFrame(index);
|
||||
if (!readback.RenderAndQueue(frameIndex, [this, &renderContent, videoInputTexture](uint64_t index) {
|
||||
renderContent.RenderFrame(RenderContentFrame{
|
||||
index,
|
||||
mConfig.width,
|
||||
mConfig.height,
|
||||
videoInputTexture
|
||||
});
|
||||
}))
|
||||
{
|
||||
mPboQueueMisses.fetch_add(1, std::memory_order_relaxed);
|
||||
@@ -120,8 +114,7 @@ void RenderThread::ThreadMain()
|
||||
|
||||
readback.Shutdown();
|
||||
inputTexture.ShutdownGl();
|
||||
runtimeRenderScene.ShutdownGl();
|
||||
renderer.ShutdownGl();
|
||||
renderContent.ShutdownGl();
|
||||
window.ClearCurrent();
|
||||
mRunning.store(false, std::memory_order_release);
|
||||
RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread stopped.");
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include "RenderCadenceClock.h"
|
||||
#include "RuntimeLayerModel.h"
|
||||
#include "RuntimeShaderArtifact.h"
|
||||
#include "RuntimeRenderScene.h"
|
||||
#include "VideoIOFormat.h"
|
||||
|
||||
#include <atomic>
|
||||
@@ -18,6 +17,7 @@ class SystemFrameExchange;
|
||||
class InputFrameMailbox;
|
||||
class InputFrameTexture;
|
||||
class OutputReadbackPipeline;
|
||||
class RuntimeShaderRenderContent;
|
||||
|
||||
class RenderThread
|
||||
{
|
||||
@@ -81,7 +81,7 @@ private:
|
||||
void CountAcquireMiss();
|
||||
void PublishReadbackMetrics(const OutputReadbackPipeline& readback);
|
||||
void PublishInputMetrics(const InputFrameTexture& inputTexture);
|
||||
void TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene);
|
||||
void TryCommitReadyRuntimeShader(RuntimeShaderRenderContent& renderContent);
|
||||
bool TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact);
|
||||
bool TryTakePendingRenderLayers(std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "RenderThread.h"
|
||||
|
||||
#include "../logging/Logger.h"
|
||||
#include "RuntimeShaderRenderContent.h"
|
||||
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
@@ -47,14 +48,14 @@ bool RenderThread::TryTakePendingRenderLayers(std::vector<RenderCadenceComposito
|
||||
return true;
|
||||
}
|
||||
|
||||
void RenderThread::TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene)
|
||||
void RenderThread::TryCommitReadyRuntimeShader(RuntimeShaderRenderContent& renderContent)
|
||||
{
|
||||
std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> layers;
|
||||
std::string commitError;
|
||||
if (TryTakePendingRenderLayers(layers))
|
||||
{
|
||||
bool structuralChange = false;
|
||||
if (!runtimeRenderScene.CommitRenderLayers(layers, commitError, &structuralChange))
|
||||
if (!renderContent.CommitRenderLayers(layers, commitError, &structuralChange))
|
||||
{
|
||||
RenderCadenceCompositor::TryLog(
|
||||
RenderCadenceCompositor::LogLevel::Error,
|
||||
@@ -84,7 +85,7 @@ void RenderThread::TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRender
|
||||
layer.shaderId = artifact.shaderId;
|
||||
layer.artifact = artifact;
|
||||
layers.push_back(std::move(layer));
|
||||
if (!runtimeRenderScene.CommitRenderLayers(layers, commitError))
|
||||
if (!renderContent.CommitRenderLayers(layers, commitError))
|
||||
{
|
||||
RenderCadenceCompositor::TryLog(
|
||||
RenderCadenceCompositor::LogLevel::Error,
|
||||
|
||||
@@ -71,7 +71,9 @@ add_video_shader_test(RenderCadenceCompositorSupportedShaderCatalogTests
|
||||
|
||||
add_video_shader_test(RenderCadenceCompositorRuntimeStateJsonTests
|
||||
"${SRC_DIR}/app/AppConfig.cpp"
|
||||
"${SRC_DIR}/app/AppRestart.cpp"
|
||||
"${SRC_DIR}/app/AppConfigProvider.cpp"
|
||||
"${SRC_DIR}/app/AppConfigJson.cpp"
|
||||
${VIDEO_MODE_SOURCES}
|
||||
${VIDEO_FORMAT_SOURCES}
|
||||
"${SRC_DIR}/json/JsonWriter.cpp"
|
||||
@@ -97,7 +99,9 @@ target_link_libraries(RenderCadenceCompositorHttpControlServerTests PRIVATE Ws2_
|
||||
|
||||
add_video_shader_test(RenderCadenceCompositorAppConfigProviderTests
|
||||
"${SRC_DIR}/app/AppConfig.cpp"
|
||||
"${SRC_DIR}/app/AppRestart.cpp"
|
||||
"${SRC_DIR}/app/AppConfigProvider.cpp"
|
||||
"${SRC_DIR}/app/AppConfigJson.cpp"
|
||||
${VIDEO_MODE_SOURCES}
|
||||
"${SRC_DIR}/video/decklink/DeckLinkDisplayMode.cpp"
|
||||
${RUNTIME_JSON_SOURCES}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "AppConfigProvider.h"
|
||||
#include "AppConfigJson.h"
|
||||
#include "DeckLinkDisplayMode.h"
|
||||
|
||||
#include <chrono>
|
||||
@@ -51,7 +52,8 @@ std::filesystem::path WriteConfigFixture()
|
||||
<< " \"autoReload\": false,\n"
|
||||
<< " \"maxTemporalHistoryFrames\": 8,\n"
|
||||
<< " \"previewEnabled\": true,\n"
|
||||
<< " \"previewFps\": 24\n"
|
||||
<< " \"previewFps\": 24,\n"
|
||||
<< " \"runtimeShaderId\": \"solid-color\"\n"
|
||||
<< "}\n";
|
||||
return path;
|
||||
}
|
||||
@@ -87,6 +89,7 @@ void TestLoadsRuntimeHostConfig()
|
||||
Expect(config.maxTemporalHistoryFrames == 8, "history length loads");
|
||||
Expect(config.previewEnabled, "preview enabled toggle 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.outputAlphaRequired, "output alpha requirement loads");
|
||||
|
||||
@@ -121,6 +124,31 @@ void TestPreviewDefaultsAreOptIn()
|
||||
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()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
@@ -149,6 +177,7 @@ int main()
|
||||
TestLoadsRuntimeHostConfig();
|
||||
TestCommandLineOverrides();
|
||||
TestPreviewDefaultsAreOptIn();
|
||||
TestConfigJsonRoundTrip();
|
||||
TestHelpers();
|
||||
|
||||
if (gFailures != 0)
|
||||
|
||||
@@ -64,6 +64,25 @@ void TestStateEndpointUsesCallback()
|
||||
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()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
@@ -171,6 +190,29 @@ void TestGenericPostCallbackHandlesControlRoutes()
|
||||
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()
|
||||
{
|
||||
using namespace RenderCadenceCompositor;
|
||||
@@ -199,11 +241,13 @@ int main()
|
||||
{
|
||||
TestParsesHttpRequest();
|
||||
TestStateEndpointUsesCallback();
|
||||
TestConfigEndpointUsesCallback();
|
||||
TestWebSocketAcceptKey();
|
||||
TestRootServesUiIndex();
|
||||
TestKnownPostEndpointReturnsActionError();
|
||||
TestLayerPostEndpointsUseCallbacks();
|
||||
TestGenericPostCallbackHandlesControlRoutes();
|
||||
TestGenericPostCallbackHandlesConfigRoutes();
|
||||
TestReloadRouteParsesAsControlCommand();
|
||||
TestUnknownEndpointReturns404();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { LayerStack } from "./components/LayerStack";
|
||||
import { ConfigEditor } from "./components/ConfigEditor";
|
||||
import { StackPresetToolbar } from "./components/StackPresetToolbar";
|
||||
import { StatusPanels } from "./components/StatusPanels";
|
||||
import { useRuntimeState } from "./hooks/useRuntimeState";
|
||||
@@ -91,6 +92,7 @@ function App() {
|
||||
<section className="dashboard-grid">
|
||||
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} />
|
||||
<StackPresetToolbar />
|
||||
<ConfigEditor />
|
||||
</section>
|
||||
|
||||
<LayerStack
|
||||
|
||||
@@ -5,3 +5,21 @@ export function postJson(path, 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;
|
||||
}
|
||||
|
||||
251
ui/src/components/ConfigEditor.jsx
Normal file
251
ui/src/components/ConfigEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,8 @@ pre {
|
||||
|
||||
.panel--compiler,
|
||||
.panel--telemetry,
|
||||
.stack-panel {
|
||||
.stack-panel,
|
||||
.config-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -509,6 +510,96 @@ pre {
|
||||
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 {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
@@ -1192,7 +1283,9 @@ pre {
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.stack-panel__grid {
|
||||
.stack-panel__grid,
|
||||
.config-grid,
|
||||
.config-fields--wide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1226,6 +1319,7 @@ pre {
|
||||
.definition-grid,
|
||||
.summary-grid,
|
||||
.kv-rows,
|
||||
.config-fields,
|
||||
.parameter-grid,
|
||||
.parameter,
|
||||
.parameter__header {
|
||||
|
||||
Reference in New Issue
Block a user