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:
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
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 "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
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
|
#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.";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
|||||||
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 "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.");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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--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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user