diff --git a/docs/CURRENT_SYSTEM_ARCHITECTURE.md b/docs/CURRENT_SYSTEM_ARCHITECTURE.md index 8349888..abbc519 100644 --- a/docs/CURRENT_SYSTEM_ARCHITECTURE.md +++ b/docs/CURRENT_SYSTEM_ARCHITECTURE.md @@ -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. diff --git a/docs/FORKING_RENDER_CADENCE_BASE.md b/docs/FORKING_RENDER_CADENCE_BASE.md index d51fc99..86ed89c 100644 --- a/docs/FORKING_RENDER_CADENCE_BASE.md +++ b/docs/FORKING_RENDER_CADENCE_BASE.md @@ -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. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ff28049..acafb85 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/src/README.md b/src/README.md index 22187e9..8f3c7fc 100644 --- a/src/README.md +++ b/src/README.md @@ -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 diff --git a/src/RenderCadenceCompositor.cpp b/src/RenderCadenceCompositor.cpp index 5523ade..6f6665c 100644 --- a/src/RenderCadenceCompositor.cpp +++ b/src/RenderCadenceCompositor.cpp @@ -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(); }); diff --git a/src/app/AppConfigJson.cpp b/src/app/AppConfigJson.cpp new file mode 100644 index 0000000..3235914 --- /dev/null +++ b/src/app/AppConfigJson.cpp @@ -0,0 +1,212 @@ +#include "AppConfigJson.h" + +#include +#include + +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(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(port); +} + +JsonValue NumberValue(double value) +{ + return JsonValue(value); +} + +JsonValue SizeValue(std::size_t value) +{ + return JsonValue(static_cast(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; +} +} diff --git a/src/app/AppConfigJson.h b/src/app/AppConfigJson.h new file mode 100644 index 0000000..4926900 --- /dev/null +++ b/src/app/AppConfigJson.h @@ -0,0 +1,16 @@ +#pragma once + +#include "AppConfig.h" +#include "RuntimeJson.h" + +#include +#include + +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); +} diff --git a/src/app/AppConfigProvider.cpp b/src/app/AppConfigProvider.cpp index a8a931e..434803e 100644 --- a/src/app/AppConfigProvider.cpp +++ b/src/app/AppConfigProvider.cpp @@ -1,5 +1,6 @@ #include "AppConfigProvider.h" +#include "AppRestart.h" #include "RuntimeJson.h" #include @@ -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]; diff --git a/src/app/AppRestart.cpp b/src/app/AppRestart.cpp new file mode 100644 index 0000000..51ea9b3 --- /dev/null +++ b/src/app/AppRestart.cpp @@ -0,0 +1,117 @@ +#include "AppRestart.h" + +#include + +#include +#include +#include +#include +#include + +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(parsed); + return true; + } + return false; +} + +std::string CurrentModulePath() +{ + char modulePath[MAX_PATH] = {}; + const DWORD length = GetModuleFileNameA(nullptr, modulePath, static_cast(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 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; +} +} diff --git a/src/app/AppRestart.h b/src/app/AppRestart.h new file mode 100644 index 0000000..88b6b7a --- /dev/null +++ b/src/app/AppRestart.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace RenderCadenceCompositor +{ +void WaitForRestartParentIfRequested(int argc, char** argv); +void SkipRestartParentArgs(int& index, int argc, char** argv); +bool ScheduleProcessRestart(std::string& error); +} diff --git a/src/app/RenderCadenceApp.h b/src/app/RenderCadenceApp.h index bd8fe16..d6fcc65 100644 --- a/src/app/RenderCadenceApp.h +++ b/src/app/RenderCadenceApp.h @@ -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 #include #include #include @@ -56,10 +59,12 @@ public: RenderThread& renderThread, SystemFrameExchange& frameExchange, AppConfig config, - std::unique_ptr output) : + std::unique_ptr 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 mOutput; VideoOutputThread mOutputThread; TelemetryHealthMonitor mTelemetryHealth; @@ -344,6 +408,7 @@ private: RuntimeLayerController mRuntimeLayers; std::function mVideoInputMetricsProvider; uint64_t mLastInputCapturedFrames = 0; + std::atomic mRestartRequired{ false }; bool mStarted = false; bool mVideoOutputEnabled = false; std::string mVideoOutputStatus = "Video output not started."; diff --git a/src/control/http/HttpControlServer.h b/src/control/http/HttpControlServer.h index 519f3c8..c94d765 100644 --- a/src/control/http/HttpControlServer.h +++ b/src/control/http/HttpControlServer.h @@ -26,6 +26,7 @@ struct HttpControlServerConfig struct HttpControlServerCallbacks { std::function getStateJson; + std::function getConfigJson; std::function addLayer; std::function removeLayer; std::function executePost; diff --git a/src/control/http/HttpControlServerRoutes.cpp b/src/control/http/HttpControlServerRoutes.cpp index 9a005c4..e27ba04 100644 --- a/src/control/http/HttpControlServerRoutes.cpp +++ b/src/control/http/HttpControlServerRoutes.cpp @@ -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/") diff --git a/src/render/RenderContent.h b/src/render/RenderContent.h new file mode 100644 index 0000000..e16046e --- /dev/null +++ b/src/render/RenderContent.h @@ -0,0 +1,33 @@ +#pragma once + +#include "GLExtensions.h" +#include "platform/HiddenGlWindow.h" + +#include +#include +#include + +struct RenderContentGlConfig +{ + unsigned width = 0; + unsigned height = 0; + std::unique_ptr 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; +}; diff --git a/src/render/RuntimeShaderRenderContent.cpp b/src/render/RuntimeShaderRenderContent.cpp new file mode 100644 index 0000000..d66bf55 --- /dev/null +++ b/src/render/RuntimeShaderRenderContent.cpp @@ -0,0 +1,71 @@ +#include "RuntimeShaderRenderContent.h" + +#include + +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& layers, + std::string& error, + bool* structuralChange) +{ + return mRuntimeRenderScene.CommitRenderLayers(layers, error, structuralChange); +} diff --git a/src/render/RuntimeShaderRenderContent.h b/src/render/RuntimeShaderRenderContent.h new file mode 100644 index 0000000..4b153f5 --- /dev/null +++ b/src/render/RuntimeShaderRenderContent.h @@ -0,0 +1,33 @@ +#pragma once + +#include "RenderContent.h" +#include "RuntimeLayerModel.h" +#include "runtime/RuntimeRenderScene.h" +#include "SimpleMotionRenderer.h" + +#include +#include + +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& layers, + std::string& error, + bool* structuralChange = nullptr); + +private: + SimpleMotionRenderer mFallbackRenderer; + RuntimeRenderScene mRuntimeRenderScene; + unsigned mWidth = 0; + unsigned mHeight = 0; +}; diff --git a/src/render/thread/RenderThread.cpp b/src/render/thread/RenderThread.cpp index 00f42d1..bb818cd 100644 --- a/src/render/thread/RenderThread.cpp +++ b/src/render/thread/RenderThread.cpp @@ -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 #include @@ -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."); diff --git a/src/render/thread/RenderThread.h b/src/render/thread/RenderThread.h index 15bb32f..df3c8a4 100644 --- a/src/render/thread/RenderThread.h +++ b/src/render/thread/RenderThread.h @@ -3,7 +3,6 @@ #include "RenderCadenceClock.h" #include "RuntimeLayerModel.h" #include "RuntimeShaderArtifact.h" -#include "RuntimeRenderScene.h" #include "VideoIOFormat.h" #include @@ -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& layers); diff --git a/src/render/thread/RenderThreadRuntimeShaders.cpp b/src/render/thread/RenderThreadRuntimeShaders.cpp index ded8d3d..a400bfd 100644 --- a/src/render/thread/RenderThreadRuntimeShaders.cpp +++ b/src/render/thread/RenderThreadRuntimeShaders.cpp @@ -1,6 +1,7 @@ #include "RenderThread.h" #include "../logging/Logger.h" +#include "RuntimeShaderRenderContent.h" #include #include @@ -47,14 +48,14 @@ bool RenderThread::TryTakePendingRenderLayers(std::vector 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, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c9a0450..90baebb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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} diff --git a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp index ae5877b..f09ab37 100644 --- a/tests/RenderCadenceCompositorAppConfigProviderTests.cpp +++ b/tests/RenderCadenceCompositorAppConfigProviderTests.cpp @@ -1,4 +1,5 @@ #include "AppConfigProvider.h" +#include "AppConfigJson.h" #include "DeckLinkDisplayMode.h" #include @@ -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) diff --git a/tests/RenderCadenceCompositorHttpControlServerTests.cpp b/tests/RenderCadenceCompositorHttpControlServerTests.cpp index e12faa7..85e18e0 100644 --- a/tests/RenderCadenceCompositorHttpControlServerTests.cpp +++ b/tests/RenderCadenceCompositorHttpControlServerTests.cpp @@ -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(); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 3b857a5..3a6f41a 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -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() {
+
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 ( + + ); +} + +function TextField({ config, label, path, setConfig }) { + return ( + + setConfig((current) => writePath(current, path, event.target.value))} + /> + + ); +} + +function NumberField({ config, label, min, path, setConfig, step = 1 }) { + return ( + + setConfig((current) => writePath(current, path, Number(event.target.value)))} + /> + + ); +} + +function SelectField({ config, label, options, path, setConfig }) { + return ( + + + + ); +} + +function ToggleField({ config, label, path, setConfig }) { + return ( + + ); +} + +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 ( +
+
+

Host config

+ +
+

{status || "Loading config."}

+
+ ); + } + + return ( +
+
+
+

Host config

+

{path || "runtime-host.json"}

+
+
+ + + +
+
+ +
+
+

Runtime

+
+ + + + + + + +
+
+ +
+

Input

+
+ + + + +
+
+ +
+

Output

+
+ + + + + + + +
+
+ +
+

OSC

+
+ + + +
+
+
+ + {(status || dirty || restartRequired) && ( +

+ {status || (dirty ? "Unsaved changes" : "Restart required")} +

+ )} +
+ ); +} diff --git a/ui/src/styles.css b/ui/src/styles.css index 481518d..1208f26 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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 {