Files
video-shader-toys/src/app/RenderCadenceApp.h
Aiden 27690c3afa
All checks were successful
CI / React UI Build (push) Successful in 12s
CI / Native Windows Build And Tests (push) Successful in 2m20s
CI / Windows Release Package (push) Successful in 2m56s
added optional web component UI control
2026-05-30 22:57:59 +10:00

491 lines
14 KiB
C++

#pragma once
#include "AppConfig.h"
#include "AppConfigJson.h"
#include "AppConfigProvider.h"
#include "AppRestart.h"
#include "RenderCadenceHttpRoutes.h"
#include "RuntimeContentController.h"
#include "../logging/Logger.h"
#include "../control/RuntimeStateJson.h"
#include "../control/osc/OscControlServer.h"
#include "../json/JsonWriter.h"
#include "../preview/PreviewWindowThread.h"
#include "../telemetry/TelemetryHealthMonitor.h"
#include "../video/ndi/NdiSourceDiscovery.h"
#include "VideoIOEdges.h"
#include "VideoOutputThread.h"
#include <atomic>
#include <chrono>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <type_traits>
#include <vector>
namespace RenderCadenceCompositor
{
namespace detail
{
template <typename RenderThread>
auto StartRenderThread(RenderThread& renderThread, std::string& error, int) -> decltype(renderThread.Start(error), bool())
{
return renderThread.Start(error);
}
template <typename RenderThread>
bool StartRenderThreadWithoutError(RenderThread& renderThread, std::true_type)
{
return renderThread.Start();
}
template <typename RenderThread>
bool StartRenderThreadWithoutError(RenderThread& renderThread, std::false_type)
{
renderThread.Start();
return true;
}
template <typename RenderThread>
auto StartRenderThread(RenderThread& renderThread, std::string&, long) -> decltype(renderThread.Start(), bool())
{
return StartRenderThreadWithoutError(renderThread, std::is_same<decltype(renderThread.Start()), bool>());
}
}
template <typename RenderThread, typename SystemFrameExchange>
class RenderCadenceApp
{
public:
RenderCadenceApp(
RenderThread& renderThread,
SystemFrameExchange& frameExchange,
AppConfig config,
std::unique_ptr<IVideoOutputEdge> output,
std::unique_ptr<IRuntimeContentController> runtimeContent,
std::filesystem::path configPath = std::filesystem::path()) :
mRenderThread(renderThread),
mFrameExchange(frameExchange),
mConfig(config),
mConfigPath(std::move(configPath)),
mOutput(std::move(output)),
mRuntimeContent(std::move(runtimeContent)),
mOutputThread(*mOutput, mFrameExchange, VideoOutputThreadConfig{
mConfig.outputThread.targetBufferedFrames,
mConfig.outputThread.idleSleep
}),
mTelemetryHealth(mConfig.telemetry)
{
}
RenderCadenceApp(const RenderCadenceApp&) = delete;
RenderCadenceApp& operator=(const RenderCadenceApp&) = delete;
~RenderCadenceApp()
{
Stop();
}
bool Start(std::string& error)
{
if (mRuntimeContent)
mRuntimeContent->Initialize(mConfig);
Log("app", "Starting render thread.");
if (!detail::StartRenderThread(mRenderThread, error, 0))
{
LogError("app", "Render thread start failed: " + error);
Stop();
return false;
}
if (mRuntimeContent)
mRuntimeContent->Start();
if (!BuildSettledOutputReserve(error))
{
LogError("app", error);
Stop();
return false;
}
StartPreviewWindow();
StartOptionalVideoOutput();
mTelemetryHealth.Start(mFrameExchange, *mOutput, mOutputThread, mRenderThread);
StartOscServer();
StartHttpServer();
Log("app", "RenderCadenceCompositor started.");
mStarted = true;
return true;
}
void Stop()
{
mHttpServer.Stop();
mOscServer.Stop();
mTelemetryHealth.Stop();
mPreviewWindow.Stop();
mOutputThread.Stop();
mOutput->Stop();
if (mRuntimeContent)
mRuntimeContent->Stop();
mRenderThread.Stop();
mOutput->ReleaseResources();
if (mStarted)
Log("app", "RenderCadenceCompositor shutdown complete.");
mStarted = false;
}
bool Started() const { return mStarted; }
const IVideoOutputEdge& Output() const { return *mOutput; }
void SetVideoInputMetricsProvider(std::function<VideoInputEdgeMetrics()> provider)
{
mVideoInputMetricsProvider = std::move(provider);
}
private:
void StartOptionalVideoOutput()
{
if (mConfig.output.backend == "none")
{
mVideoOutputEnabled = false;
mVideoOutputStatus = "Video output backend disabled by config.";
Log("app", mVideoOutputStatus);
return;
}
std::string outputError;
Log("app", "Initializing optional video output backend: " + mConfig.output.backend + ".");
VideoOutputEdgeConfig outputConfig;
outputConfig.outputVideoMode = mConfig.output.videoMode;
outputConfig.systemFramePixelFormat = mConfig.output.systemFramePixelFormat;
outputConfig.externalKeyingEnabled = mConfig.output.externalKeyingEnabled;
outputConfig.outputAlphaRequired = mConfig.output.outputAlphaRequired;
if (!mOutput->Initialize(
outputConfig,
[this](const VideoIOCompletion& completion) {
mFrameExchange.ReleaseScheduledByBytes(completion.outputFrameBuffer);
},
outputError))
{
DisableVideoOutput("Video output unavailable: " + outputError);
return;
}
if (mOutput->RequiresPreroll())
{
Log("app", "Starting video output thread.");
if (!mOutputThread.Start())
{
DisableVideoOutput("Video output thread failed to start.");
return;
}
Log("app", "Waiting for video output preroll frames.");
if (!WaitForPreroll())
{
DisableVideoOutput("Timed out waiting for video output preroll frames.");
return;
}
Log("app", "Starting scheduled video playback.");
if (!mOutput->StartScheduledPlayback(outputError))
{
DisableVideoOutput("Scheduled video playback failed: " + outputError);
return;
}
}
else
{
Log("app", "Starting video output backend without preroll.");
if (!mOutput->StartScheduledPlayback(outputError))
{
DisableVideoOutput("Video output failed to start: " + outputError);
return;
}
if (!mOutputThread.Start())
{
DisableVideoOutput("Video output thread failed to start.");
return;
}
}
mVideoOutputEnabled = true;
mVideoOutputStatus = mConfig.output.backend + " scheduled output running.";
Log("app", mVideoOutputStatus);
Log(
"app",
"Video output mode: " + mOutput->State().outputDisplayModeName +
", frame budget " + std::to_string(mOutput->State().frameBudgetMilliseconds) + " ms.");
}
bool BuildSettledOutputReserve(std::string& error)
{
const auto reserveTimeout = mConfig.warmupTimeout;
Log("app",
"Building output preroll reserve: waiting for " + std::to_string(mConfig.warmupCompletedFrames) +
" completed frame(s).");
if (mFrameExchange.WaitForCompletedDepth(mConfig.warmupCompletedFrames, reserveTimeout))
{
return true;
}
error = "Timed out waiting for output preroll reserve.";
return false;
}
void DisableVideoOutput(const std::string& reason)
{
mOutputThread.Stop();
mOutput->Stop();
mOutput->ReleaseResources();
mFrameExchange.Clear();
mVideoOutputEnabled = false;
mVideoOutputStatus = reason;
LogWarning("app", reason + " Continuing without video output.");
}
void StartHttpServer()
{
RenderCadenceHttpRouteCallbacks routeCallbacks;
routeCallbacks.getStateJson = [this]() {
return BuildStateJson();
};
routeCallbacks.getConfigJson = [this]() {
return BuildConfigJson();
};
routeCallbacks.getNdiSourcesJson = [this]() {
return BuildNdiSourcesJson();
};
routeCallbacks.resolveShaderAssetPath = [this](
const std::string& shaderId,
const std::string& assetPath,
std::filesystem::path& resolvedPath,
std::string& assetError) {
if (!mRuntimeContent)
{
assetError = "No runtime content controller is active.";
return false;
}
return mRuntimeContent->ResolveAssetPath(shaderId, assetPath, resolvedPath, assetError);
};
routeCallbacks.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();
if (mRuntimeContent)
return mRuntimeContent->HandlePost(path, body);
return ControlActionResult{ false, "No runtime content controller is active." };
};
HttpControlServerCallbacks callbacks;
callbacks.getWebSocketStateJson = routeCallbacks.getStateJson;
callbacks.routeRequest = [this, routeCallbacks](const HttpRequest& request) {
return RouteRenderCadenceHttpRequest(request, mHttpServer, routeCallbacks);
};
std::string error;
if (!mHttpServer.Start(
FindRepoPath("ui/dist"),
FindRepoPath("docs"),
mConfig.http,
callbacks,
error))
{
LogWarning("http", "HTTP control server did not start: " + error);
return;
}
}
void StartPreviewWindow()
{
if (!mConfig.previewEnabled)
return;
PreviewWindowConfig previewConfig;
previewConfig.enabled = true;
previewConfig.fps = mConfig.previewFps;
std::string error;
if (mPreviewWindow.Start(mFrameExchange, previewConfig, error))
{
Log("preview", "Preview window thread started.");
return;
}
LogWarning("preview", "Preview window did not start: " + error);
}
std::string BuildStateJson()
{
CadenceTelemetrySnapshot telemetry = mHttpTelemetry.Sample(mFrameExchange, *mOutput, mOutputThread, mRenderThread);
ApplyVideoInputMetrics(telemetry);
RuntimeStateJsonInput stateInput{
mConfig,
telemetry,
mHttpServer.Port(),
mVideoOutputEnabled,
mVideoOutputStatus,
&mOscServer.State()
};
if (mRuntimeContent)
{
stateInput.writeRuntimeJson = [this, telemetry](JsonWriter& writer) {
mRuntimeContent->WriteRuntimeJson(writer, telemetry);
};
stateInput.writeCatalogJson = [this](JsonWriter& writer) {
mRuntimeContent->WriteCatalogJson(writer);
};
stateInput.writeLayersJson = [this, telemetry](JsonWriter& writer) {
mRuntimeContent->WriteLayersJson(writer, telemetry);
};
}
return RuntimeStateToJson(stateInput);
}
void StartOscServer()
{
OscControlServerConfig oscConfig;
oscConfig.bindAddress = mConfig.oscBindAddress;
oscConfig.port = mConfig.oscPort;
oscConfig.smoothing = mConfig.oscSmoothing;
std::string error;
if (!mOscServer.Start(oscConfig, error))
{
LogWarning("osc", "OSC control stub did not start: " + error);
return;
}
if (!mOscServer.State().statusMessage.empty())
Log("osc", mOscServer.State().statusMessage);
}
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);
}
std::string BuildNdiSourcesJson() const
{
std::vector<NdiSourceInfo> sources;
std::string error;
const bool discovered = DiscoverNdiSources(sources, error);
JsonWriter writer;
writer.BeginObject();
writer.KeyBool("ok", discovered);
if (!error.empty())
writer.KeyString("error", error);
writer.Key("sources");
writer.BeginArray();
for (const NdiSourceInfo& source : sources)
{
writer.BeginObject();
writer.KeyString("name", source.name);
writer.KeyString("urlAddress", source.urlAddress);
writer.EndObject();
}
writer.EndArray();
writer.EndObject();
return writer.StringValue();
}
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)
return;
const VideoInputEdgeMetrics inputMetrics = mVideoInputMetricsProvider();
telemetry.inputConvertMilliseconds = inputMetrics.convertMilliseconds;
telemetry.inputSubmitMilliseconds = inputMetrics.submitMilliseconds;
telemetry.inputNoSignalFrames = inputMetrics.noInputSourceFrames;
telemetry.inputUnsupportedFrames = inputMetrics.unsupportedFrames;
telemetry.inputSubmitMisses = inputMetrics.submitMisses;
telemetry.inputCaptureFormat = inputMetrics.captureFormat ? inputMetrics.captureFormat : "none";
if (telemetry.sampleSeconds > 0.0)
telemetry.inputCaptureFps = static_cast<double>(inputMetrics.capturedFrames - mLastInputCapturedFrames) / telemetry.sampleSeconds;
mLastInputCapturedFrames = inputMetrics.capturedFrames;
}
bool WaitForPreroll() const
{
const auto deadline = std::chrono::steady_clock::now() + mConfig.prerollTimeout;
while (std::chrono::steady_clock::now() < deadline)
{
if (mFrameExchange.Metrics().scheduledCount >= mConfig.outputThread.targetBufferedFrames)
return true;
std::this_thread::sleep_for(mConfig.prerollPoll);
}
return false;
}
RenderThread& mRenderThread;
SystemFrameExchange& mFrameExchange;
AppConfig mConfig;
std::filesystem::path mConfigPath;
std::unique_ptr<IVideoOutputEdge> mOutput;
std::unique_ptr<IRuntimeContentController> mRuntimeContent;
VideoOutputThread<SystemFrameExchange> mOutputThread;
TelemetryHealthMonitor mTelemetryHealth;
CadenceTelemetry mHttpTelemetry;
HttpControlServer mHttpServer;
OscControlServer mOscServer;
PreviewWindowThread mPreviewWindow;
std::function<VideoInputEdgeMetrics()> mVideoInputMetricsProvider;
uint64_t mLastInputCapturedFrames = 0;
std::atomic<bool> mRestartRequired{ false };
bool mStarted = false;
bool mVideoOutputEnabled = false;
std::string mVideoOutputStatus = "Video output not started.";
};
}