491 lines
14 KiB
C++
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.";
|
|
};
|
|
}
|