49 Commits

Author SHA1 Message Date
Aiden
3ffb562ff7 docs update
All checks were successful
CI / React UI Build (push) Successful in 49s
CI / Native Windows Build And Tests (push) Successful in 3m27s
CI / Windows Release Package (push) Successful in 3m39s
2026-05-13 01:06:20 +10:00
Aiden
c2d548499c Timing is finally good
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m57s
CI / Windows Release Package (push) Has been skipped
2026-05-13 00:58:32 +10:00
Aiden
6a0340d1b4 proactive realignment 2026-05-13 00:28:11 +10:00
Aiden
5c1fc2a6cf telemetry and timing updates
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m58s
CI / Windows Release Package (push) Has been skipped
2026-05-13 00:21:28 +10:00
Aiden
d411453f80 timing refactor
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 3m1s
CI / Windows Release Package (push) Successful in 3m20s
2026-05-12 23:39:57 +10:00
Aiden
4a049a557a Render timing 2026-05-12 22:18:27 +10:00
Aiden
13586c611a Start up settle 2026-05-12 22:04:46 +10:00
Aiden
3a83d9617f Clock updates 2026-05-12 21:44:26 +10:00
Aiden
5c66cfdc64 Input telemetry
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m56s
CI / Windows Release Package (push) Has been skipped
2026-05-12 21:13:22 +10:00
Aiden
d72272b5a8 2 frame buffer
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m59s
CI / Windows Release Package (push) Has been skipped
2026-05-12 21:08:02 +10:00
Aiden
c25ae7b25b input buffer
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Windows Release Package (push) Has been cancelled
CI / Native Windows Build And Tests (push) Has been cancelled
2026-05-12 21:05:42 +10:00
Aiden
a39be6fb20 Alignment
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m59s
CI / Windows Release Package (push) Has been skipped
2026-05-12 20:38:26 +10:00
Aiden
0a1fe440d9 docs pass 2026-05-12 20:32:32 +10:00
Aiden
3e45bba54b Update InputFrameMailbox.cpp
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m59s
CI / Windows Release Package (push) Has been skipped
2026-05-12 20:30:19 +10:00
Aiden
fd4b70ec9c Input GPU decoding
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 3m4s
CI / Windows Release Package (push) Has been skipped
2026-05-12 20:26:03 +10:00
Aiden
ce28904891 input testing
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 3m2s
CI / Windows Release Package (push) Has been skipped
2026-05-12 20:06:23 +10:00
Aiden
2c5e925b97 Video input fallback
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 3m7s
CI / Windows Release Package (push) Has been skipped
2026-05-12 18:53:46 +10:00
Aiden
957c0be05a Draw video if everything bypassed 2026-05-12 18:41:48 +10:00
Aiden
0a8b335048 INput
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m59s
CI / Windows Release Package (push) Has been skipped
2026-05-12 18:39:08 +10:00
Aiden
6e32941675 Fixed trigger
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m57s
CI / Windows Release Package (push) Has been skipped
2026-05-12 18:11:43 +10:00
Aiden
5fb4607d8c Clean up
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m55s
CI / Windows Release Package (push) Has been skipped
2026-05-12 18:03:54 +10:00
Aiden
f43b6f6519 shader control
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 3m3s
CI / Windows Release Package (push) Has been skipped
2026-05-12 17:52:55 +10:00
Aiden
dfd49fd0e3 Multipass shaders 2026-05-12 17:08:35 +10:00
Aiden
1429b2e660 Update shader
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 3m0s
CI / Windows Release Package (push) Has been skipped
2026-05-12 16:52:15 +10:00
Aiden
02b221f481 restructure 2026-05-12 15:47:59 +10:00
Aiden
6a33bd02ab Websocket split
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 3m0s
CI / Windows Release Package (push) Has been skipped
2026-05-12 15:36:02 +10:00
Aiden
da7e1a93f6 Websockets
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m58s
CI / Windows Release Package (push) Has been skipped
2026-05-12 15:32:01 +10:00
Aiden
334693f28c Render udpates 2026-05-12 15:26:02 +10:00
Aiden
c5fd8e72b4 non-blocking http
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m58s
CI / Windows Release Package (push) Has been skipped
2026-05-12 15:05:54 +10:00
Aiden
95b4a54326 Seperation 2026-05-12 14:57:18 +10:00
Aiden
d07ea1f63a Render changes
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m59s
CI / Windows Release Package (push) Has been skipped
2026-05-12 14:36:36 +10:00
Aiden
1ddcf5d621 More http post end points filled
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 3m1s
CI / Windows Release Package (push) Has been skipped
2026-05-12 14:23:53 +10:00
Aiden
38d729b346 CI update
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m56s
CI / Windows Release Package (push) Has been skipped
2026-05-12 13:55:10 +10:00
Aiden
4b62627479 removed render thread touching
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Windows Release Package (push) Has been cancelled
CI / Native Windows Build And Tests (push) Has been cancelled
2026-05-12 13:52:04 +10:00
Aiden
430cf0733d end point adjsutments
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Windows Release Package (push) Has been cancelled
CI / Native Windows Build And Tests (push) Has been cancelled
2026-05-12 13:50:32 +10:00
Aiden
b44504500a Ui serving
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m55s
CI / Windows Release Package (push) Successful in 3m21s
2026-05-12 13:25:34 +10:00
Aiden
bc690e2a87 Clean up pass
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m55s
CI / Windows Release Package (push) Successful in 3m14s
2026-05-12 13:14:52 +10:00
Aiden
9938a6cc26 http
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m54s
CI / Windows Release Package (push) Successful in 3m2s
2026-05-12 12:38:54 +10:00
Aiden
79f7ac6c86 Json telemetry
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m52s
CI / Windows Release Package (push) Successful in 3m19s
2026-05-12 12:13:21 +10:00
Aiden
44b198b14d logging
All checks were successful
CI / React UI Build (push) Successful in 38s
CI / Native Windows Build And Tests (push) Successful in 3m12s
CI / Windows Release Package (push) Successful in 3m7s
2026-05-12 11:58:29 +10:00
Aiden
511b67c9bc New rules based order
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m53s
CI / Windows Release Package (push) Successful in 3m18s
2026-05-12 02:35:15 +10:00
Aiden
c0d7e84495 Shader ownership change
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m52s
CI / Windows Release Package (push) Successful in 2m59s
2026-05-12 02:15:03 +10:00
Aiden
4ea829af85 Shader test past
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m52s
CI / Windows Release Package (push) Has been cancelled
2026-05-12 02:08:48 +10:00
Aiden
e0ca548ef5 V2 working
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m54s
CI / Windows Release Package (push) Successful in 3m14s
2026-05-12 01:59:02 +10:00
Aiden
2531d871e8 Doc cleanup
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m49s
CI / Windows Release Package (push) Successful in 3m8s
2026-05-12 01:37:20 +10:00
Aiden
709d3d3fa4 Test works
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m53s
CI / Windows Release Package (push) Successful in 3m1s
2026-05-12 01:30:30 +10:00
Aiden
ea31d0ca13 Clean 2026-05-12 01:21:42 +10:00
Aiden
f1f4e3421b Frame timing
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m53s
CI / Windows Release Package (push) Successful in 3m6s
2026-05-12 01:08:32 +10:00
Aiden
ac729dc2b9 Stage 1 rewrite 2026-05-12 00:52:33 +10:00
130 changed files with 16962 additions and 5038 deletions

View File

@@ -7,6 +7,9 @@ on:
- master
- develop
pull_request:
schedule:
# Nightly build at 14:00 UTC, roughly midnight in Australia/Sydney.
- cron: "0 14 * * *"
workflow_dispatch:
jobs:
@@ -82,6 +85,7 @@ jobs:
package-windows:
name: Windows Release Package
runs-on: windows-2022
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs:
- native-windows
- ui-ubuntu

41
.vscode/launch.json vendored
View File

@@ -9,7 +9,12 @@
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug",
"environment": [],
"environment": [
{
"name": "VST_DISABLE_INPUT_CAPTURE",
"value": "1"
}
],
"console": "internalConsole",
"symbolSearchPath": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug",
"requireExactSource": true,
@@ -61,6 +66,40 @@
"moduleLoad": true
},
"preLaunchTask": "Build LoopThroughWithOpenGLCompositing Debug x64"
},
{
"name": "Debug DeckLinkRenderCadenceProbe",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug\\DeckLinkRenderCadenceProbe.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug",
"environment": [],
"console": "externalTerminal",
"symbolSearchPath": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug",
"requireExactSource": true,
"logging": {
"moduleLoad": true
},
"preLaunchTask": "Build DeckLinkRenderCadenceProbe Debug x64"
},
{
"name": "Debug RenderCadenceCompositor",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug\\RenderCadenceCompositor.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug",
"environment": [],
"console": "externalTerminal",
"symbolSearchPath": "${workspaceFolder}\\build\\vs2022-x64-debug\\Debug",
"requireExactSource": true,
"logging": {
"moduleLoad": true
},
"preLaunchTask": "Build RenderCadenceCompositor Debug x64"
}
]
}

32
.vscode/tasks.json vendored
View File

@@ -36,6 +36,38 @@
"group": "build",
"problemMatcher": "$msCompile"
},
{
"label": "Build DeckLinkRenderCadenceProbe Debug x64",
"type": "process",
"command": "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\Common7\\IDE\\CommonExtensions\\Microsoft\\CMake\\CMake\\bin\\cmake.exe",
"args": [
"--build",
"${workspaceFolder}\\build\\vs2022-x64-debug",
"--config",
"Debug",
"--target",
"DeckLinkRenderCadenceProbe",
"--parallel"
],
"group": "build",
"problemMatcher": "$msCompile"
},
{
"label": "Build RenderCadenceCompositor Debug x64",
"type": "process",
"command": "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\Common7\\IDE\\CommonExtensions\\Microsoft\\CMake\\CMake\\bin\\cmake.exe",
"args": [
"--build",
"${workspaceFolder}\\build\\vs2022-x64-debug",
"--config",
"Debug",
"--target",
"RenderCadenceCompositor",
"--parallel"
],
"group": "build",
"problemMatcher": "$msCompile"
},
{
"label": "Clean LoopThroughWithOpenGLCompositing Debug x64",
"type": "process",

View File

@@ -170,6 +170,8 @@ set(APP_SOURCES
"${APP_DIR}/videoio/VideoIOTypes.h"
"${APP_DIR}/videoio/OutputProductionController.cpp"
"${APP_DIR}/videoio/OutputProductionController.h"
"${APP_DIR}/videoio/RenderCadenceController.cpp"
"${APP_DIR}/videoio/RenderCadenceController.h"
"${APP_DIR}/videoio/RenderOutputQueue.cpp"
"${APP_DIR}/videoio/RenderOutputQueue.h"
"${APP_DIR}/videoio/SystemOutputFramePool.cpp"
@@ -227,6 +229,192 @@ if(MSVC)
target_compile_options(LoopThroughWithOpenGLCompositing PRIVATE /W3)
endif()
set(PROBE_APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/apps/DeckLinkRenderCadenceProbe")
add_executable(DeckLinkRenderCadenceProbe
"${APP_DIR}/videoio/decklink/DeckLinkAPI_i.c"
"${APP_DIR}/videoio/decklink/DeckLinkDisplayMode.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkDisplayMode.h"
"${APP_DIR}/videoio/decklink/DeckLinkFrameTransfer.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkFrameTransfer.h"
"${APP_DIR}/videoio/decklink/DeckLinkSession.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkSession.h"
"${APP_DIR}/videoio/decklink/DeckLinkVideoIOFormat.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkVideoIOFormat.h"
"${APP_DIR}/gl/renderer/GLExtensions.cpp"
"${APP_DIR}/gl/renderer/GLExtensions.h"
"${APP_DIR}/videoio/VideoIOFormat.cpp"
"${APP_DIR}/videoio/VideoIOFormat.h"
"${APP_DIR}/videoio/VideoIOTypes.h"
"${APP_DIR}/videoio/VideoPlayoutPolicy.h"
"${APP_DIR}/videoio/VideoPlayoutScheduler.cpp"
"${APP_DIR}/videoio/VideoPlayoutScheduler.h"
"${PROBE_APP_DIR}/DeckLinkRenderCadenceProbe.cpp"
)
target_include_directories(DeckLinkRenderCadenceProbe PRIVATE
"${APP_DIR}"
"${APP_DIR}/gl/renderer"
"${APP_DIR}/videoio"
"${APP_DIR}/videoio/decklink"
)
target_link_libraries(DeckLinkRenderCadenceProbe PRIVATE
opengl32
Ole32
)
target_compile_definitions(DeckLinkRenderCadenceProbe PRIVATE
_UNICODE
UNICODE
)
if(MSVC)
target_compile_options(DeckLinkRenderCadenceProbe PRIVATE /W3)
endif()
set(RENDER_CADENCE_APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/apps/RenderCadenceCompositor")
set(RENDER_CADENCE_APP_SOURCES
"${APP_DIR}/videoio/decklink/DeckLinkAPI_i.c"
"${APP_DIR}/videoio/decklink/DeckLinkDisplayMode.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkDisplayMode.h"
"${APP_DIR}/videoio/decklink/DeckLinkFrameTransfer.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkFrameTransfer.h"
"${APP_DIR}/videoio/decklink/DeckLinkSession.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkSession.h"
"${APP_DIR}/videoio/decklink/DeckLinkVideoIOFormat.cpp"
"${APP_DIR}/videoio/decklink/DeckLinkVideoIOFormat.h"
"${APP_DIR}/gl/renderer/GLExtensions.cpp"
"${APP_DIR}/gl/renderer/GLExtensions.h"
"${APP_DIR}/gl/shader/Std140Buffer.h"
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${APP_DIR}/runtime/support/RuntimeJson.h"
"${APP_DIR}/runtime/support/RuntimeParameterUtils.cpp"
"${APP_DIR}/runtime/support/RuntimeParameterUtils.h"
"${APP_DIR}/shader/ShaderCompiler.cpp"
"${APP_DIR}/shader/ShaderCompiler.h"
"${APP_DIR}/shader/ShaderPackageRegistry.cpp"
"${APP_DIR}/shader/ShaderPackageRegistry.h"
"${APP_DIR}/shader/ShaderTypes.h"
"${APP_DIR}/videoio/VideoIOFormat.cpp"
"${APP_DIR}/videoio/VideoIOFormat.h"
"${APP_DIR}/videoio/VideoIOTypes.h"
"${APP_DIR}/videoio/VideoPlayoutPolicy.h"
"${APP_DIR}/videoio/VideoPlayoutScheduler.cpp"
"${APP_DIR}/videoio/VideoPlayoutScheduler.h"
"${RENDER_CADENCE_APP_DIR}/RenderCadenceCompositor.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfig.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfig.h"
"${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.h"
"${RENDER_CADENCE_APP_DIR}/app/RenderCadenceApp.h"
"${RENDER_CADENCE_APP_DIR}/app/RuntimeLayerController.cpp"
"${RENDER_CADENCE_APP_DIR}/app/RuntimeLayerControllerBuild.cpp"
"${RENDER_CADENCE_APP_DIR}/app/RuntimeLayerControllerControls.cpp"
"${RENDER_CADENCE_APP_DIR}/app/RuntimeLayerController.h"
"${RENDER_CADENCE_APP_DIR}/control/ControlActionResult.h"
"${RENDER_CADENCE_APP_DIR}/control/RuntimeControlCommand.cpp"
"${RENDER_CADENCE_APP_DIR}/control/RuntimeControlCommand.h"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServer.cpp"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServer.h"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServerRoutes.cpp"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServerWebSocket.cpp"
"${RENDER_CADENCE_APP_DIR}/control/RuntimeStateJson.h"
"${RENDER_CADENCE_APP_DIR}/frames/InputFrameMailbox.cpp"
"${RENDER_CADENCE_APP_DIR}/frames/InputFrameMailbox.h"
"${RENDER_CADENCE_APP_DIR}/frames/SystemFrameExchange.cpp"
"${RENDER_CADENCE_APP_DIR}/frames/SystemFrameExchange.h"
"${RENDER_CADENCE_APP_DIR}/frames/SystemFrameTypes.h"
"${RENDER_CADENCE_APP_DIR}/json/JsonWriter.cpp"
"${RENDER_CADENCE_APP_DIR}/json/JsonWriter.h"
"${RENDER_CADENCE_APP_DIR}/logging/Logger.cpp"
"${RENDER_CADENCE_APP_DIR}/logging/Logger.h"
"${RENDER_CADENCE_APP_DIR}/platform/HiddenGlWindow.cpp"
"${RENDER_CADENCE_APP_DIR}/platform/HiddenGlWindow.h"
"${RENDER_CADENCE_APP_DIR}/render/InputFrameTexture.cpp"
"${RENDER_CADENCE_APP_DIR}/render/InputFrameTexture.h"
"${RENDER_CADENCE_APP_DIR}/render/readback/Bgra8ReadbackPipeline.cpp"
"${RENDER_CADENCE_APP_DIR}/render/readback/Bgra8ReadbackPipeline.h"
"${RENDER_CADENCE_APP_DIR}/render/readback/PboReadbackRing.cpp"
"${RENDER_CADENCE_APP_DIR}/render/readback/PboReadbackRing.h"
"${RENDER_CADENCE_APP_DIR}/render/RenderCadenceClock.cpp"
"${RENDER_CADENCE_APP_DIR}/render/RenderCadenceClock.h"
"${RENDER_CADENCE_APP_DIR}/render/RenderThread.cpp"
"${RENDER_CADENCE_APP_DIR}/render/RenderThread.h"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderRenderer.cpp"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderRenderer.h"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderParams.cpp"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderParams.h"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeRenderScene.cpp"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeRenderScene.h"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeRenderSceneRender.cpp"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderPrepareWorker.cpp"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderPrepareWorker.h"
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderProgram.h"
"${RENDER_CADENCE_APP_DIR}/render/SimpleMotionRenderer.cpp"
"${RENDER_CADENCE_APP_DIR}/render/SimpleMotionRenderer.h"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeLayerModel.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeLayerModel.h"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeShaderBridge.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeShaderBridge.h"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeSlangShaderCompiler.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeSlangShaderCompiler.h"
"${RENDER_CADENCE_APP_DIR}/runtime/SupportedShaderCatalog.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/SupportedShaderCatalog.h"
"${RENDER_CADENCE_APP_DIR}/telemetry/CadenceTelemetryJson.h"
"${RENDER_CADENCE_APP_DIR}/telemetry/CadenceTelemetry.h"
"${RENDER_CADENCE_APP_DIR}/telemetry/TelemetryHealthMonitor.h"
"${RENDER_CADENCE_APP_DIR}/video/DeckLinkInput.cpp"
"${RENDER_CADENCE_APP_DIR}/video/DeckLinkInput.h"
"${RENDER_CADENCE_APP_DIR}/video/DeckLinkInputThread.h"
"${RENDER_CADENCE_APP_DIR}/video/DeckLinkOutput.cpp"
"${RENDER_CADENCE_APP_DIR}/video/DeckLinkOutput.h"
"${RENDER_CADENCE_APP_DIR}/video/DeckLinkOutputThread.h"
)
add_executable(RenderCadenceCompositor ${RENDER_CADENCE_APP_SOURCES})
target_include_directories(RenderCadenceCompositor PRIVATE
"${APP_DIR}"
"${APP_DIR}/gl/renderer"
"${APP_DIR}/gl/shader"
"${APP_DIR}/platform"
"${APP_DIR}/runtime/support"
"${APP_DIR}/shader"
"${APP_DIR}/videoio"
"${APP_DIR}/videoio/decklink"
"${RENDER_CADENCE_APP_DIR}"
"${RENDER_CADENCE_APP_DIR}/app"
"${RENDER_CADENCE_APP_DIR}/control"
"${RENDER_CADENCE_APP_DIR}/control/http"
"${RENDER_CADENCE_APP_DIR}/frames"
"${RENDER_CADENCE_APP_DIR}/json"
"${RENDER_CADENCE_APP_DIR}/logging"
"${RENDER_CADENCE_APP_DIR}/platform"
"${RENDER_CADENCE_APP_DIR}/render"
"${RENDER_CADENCE_APP_DIR}/render/readback"
"${RENDER_CADENCE_APP_DIR}/render/runtime"
"${RENDER_CADENCE_APP_DIR}/runtime"
"${RENDER_CADENCE_APP_DIR}/telemetry"
"${RENDER_CADENCE_APP_DIR}/video"
)
target_link_libraries(RenderCadenceCompositor PRIVATE
opengl32
Ole32
Ws2_32
)
target_compile_definitions(RenderCadenceCompositor PRIVATE
_UNICODE
UNICODE
)
if(MSVC)
target_compile_options(RenderCadenceCompositor PRIVATE /W3)
endif()
add_executable(RuntimeJsonTests
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeJsonTests.cpp"
@@ -580,6 +768,270 @@ endif()
add_test(NAME RenderOutputQueueTests COMMAND RenderOutputQueueTests)
add_executable(RenderCadenceControllerTests
"${APP_DIR}/videoio/RenderCadenceController.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceControllerTests.cpp"
)
target_include_directories(RenderCadenceControllerTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/videoio"
)
if(MSVC)
target_compile_options(RenderCadenceControllerTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceControllerTests COMMAND RenderCadenceControllerTests)
add_executable(RenderCadenceCompositorFrameExchangeTests
"${APP_DIR}/videoio/VideoIOFormat.cpp"
"${RENDER_CADENCE_APP_DIR}/frames/SystemFrameExchange.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorFrameExchangeTests.cpp"
)
target_include_directories(RenderCadenceCompositorFrameExchangeTests PRIVATE
"${APP_DIR}/videoio"
"${RENDER_CADENCE_APP_DIR}/frames"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorFrameExchangeTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorFrameExchangeTests COMMAND RenderCadenceCompositorFrameExchangeTests)
add_executable(RenderCadenceCompositorInputFrameMailboxTests
"${APP_DIR}/videoio/VideoIOFormat.cpp"
"${RENDER_CADENCE_APP_DIR}/frames/InputFrameMailbox.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorInputFrameMailboxTests.cpp"
)
target_include_directories(RenderCadenceCompositorInputFrameMailboxTests PRIVATE
"${APP_DIR}/videoio"
"${RENDER_CADENCE_APP_DIR}/frames"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorInputFrameMailboxTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorInputFrameMailboxTests COMMAND RenderCadenceCompositorInputFrameMailboxTests)
add_executable(RenderCadenceCompositorClockTests
"${RENDER_CADENCE_APP_DIR}/render/RenderCadenceClock.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorClockTests.cpp"
)
target_include_directories(RenderCadenceCompositorClockTests PRIVATE
"${RENDER_CADENCE_APP_DIR}/render"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorClockTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorClockTests COMMAND RenderCadenceCompositorClockTests)
add_executable(RenderCadenceCompositorTelemetryTests
"${RENDER_CADENCE_APP_DIR}/json/JsonWriter.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorTelemetryTests.cpp"
)
target_include_directories(RenderCadenceCompositorTelemetryTests PRIVATE
"${RENDER_CADENCE_APP_DIR}/json"
"${RENDER_CADENCE_APP_DIR}/telemetry"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorTelemetryTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorTelemetryTests COMMAND RenderCadenceCompositorTelemetryTests)
add_executable(RenderCadenceCompositorRuntimeShaderParamsTests
"${RENDER_CADENCE_APP_DIR}/render/runtime/RuntimeShaderParams.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorRuntimeShaderParamsTests.cpp"
)
target_include_directories(RenderCadenceCompositorRuntimeShaderParamsTests PRIVATE
"${APP_DIR}/gl/shader"
"${APP_DIR}/shader"
"${RENDER_CADENCE_APP_DIR}/render"
"${RENDER_CADENCE_APP_DIR}/render/runtime"
"${RENDER_CADENCE_APP_DIR}/runtime"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorRuntimeShaderParamsTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorRuntimeShaderParamsTests COMMAND RenderCadenceCompositorRuntimeShaderParamsTests)
add_executable(RenderCadenceCompositorRuntimeLayerModelTests
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeLayerModel.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/SupportedShaderCatalog.cpp"
"${APP_DIR}/shader/ShaderPackageRegistry.cpp"
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${APP_DIR}/runtime/support/RuntimeParameterUtils.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorRuntimeLayerModelTests.cpp"
)
target_include_directories(RenderCadenceCompositorRuntimeLayerModelTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/runtime/support"
"${APP_DIR}/shader"
"${RENDER_CADENCE_APP_DIR}/runtime"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorRuntimeLayerModelTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorRuntimeLayerModelTests COMMAND RenderCadenceCompositorRuntimeLayerModelTests)
add_executable(RenderCadenceCompositorSupportedShaderCatalogTests
"${APP_DIR}/shader/ShaderPackageRegistry.cpp"
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/SupportedShaderCatalog.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp"
)
target_include_directories(RenderCadenceCompositorSupportedShaderCatalogTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/runtime/support"
"${APP_DIR}/shader"
"${RENDER_CADENCE_APP_DIR}/runtime"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorSupportedShaderCatalogTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorSupportedShaderCatalogTests COMMAND RenderCadenceCompositorSupportedShaderCatalogTests)
add_executable(RenderCadenceCompositorLoggerTests
"${RENDER_CADENCE_APP_DIR}/logging/Logger.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorLoggerTests.cpp"
)
target_include_directories(RenderCadenceCompositorLoggerTests PRIVATE
"${RENDER_CADENCE_APP_DIR}/logging"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorLoggerTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorLoggerTests COMMAND RenderCadenceCompositorLoggerTests)
add_executable(RenderCadenceCompositorJsonWriterTests
"${RENDER_CADENCE_APP_DIR}/json/JsonWriter.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorJsonWriterTests.cpp"
)
target_include_directories(RenderCadenceCompositorJsonWriterTests PRIVATE
"${RENDER_CADENCE_APP_DIR}/json"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorJsonWriterTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorJsonWriterTests COMMAND RenderCadenceCompositorJsonWriterTests)
add_executable(RenderCadenceCompositorRuntimeStateJsonTests
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${APP_DIR}/runtime/support/RuntimeParameterUtils.cpp"
"${APP_DIR}/shader/ShaderPackageRegistry.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfig.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.cpp"
"${RENDER_CADENCE_APP_DIR}/json/JsonWriter.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/RuntimeLayerModel.cpp"
"${RENDER_CADENCE_APP_DIR}/runtime/SupportedShaderCatalog.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorRuntimeStateJsonTests.cpp"
)
target_include_directories(RenderCadenceCompositorRuntimeStateJsonTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/runtime/support"
"${APP_DIR}/shader"
"${APP_DIR}/videoio"
"${APP_DIR}/videoio/decklink"
"${RENDER_CADENCE_APP_DIR}/app"
"${RENDER_CADENCE_APP_DIR}/control"
"${RENDER_CADENCE_APP_DIR}/json"
"${RENDER_CADENCE_APP_DIR}/logging"
"${RENDER_CADENCE_APP_DIR}/runtime"
"${RENDER_CADENCE_APP_DIR}/telemetry"
"${RENDER_CADENCE_APP_DIR}/video"
)
if(MSVC)
target_compile_options(RenderCadenceCompositorRuntimeStateJsonTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorRuntimeStateJsonTests COMMAND RenderCadenceCompositorRuntimeStateJsonTests)
add_executable(RenderCadenceCompositorHttpControlServerTests
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${RENDER_CADENCE_APP_DIR}/control/RuntimeControlCommand.cpp"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServer.cpp"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServerRoutes.cpp"
"${RENDER_CADENCE_APP_DIR}/control/http/HttpControlServerWebSocket.cpp"
"${RENDER_CADENCE_APP_DIR}/json/JsonWriter.cpp"
"${RENDER_CADENCE_APP_DIR}/logging/Logger.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorHttpControlServerTests.cpp"
)
target_include_directories(RenderCadenceCompositorHttpControlServerTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/runtime/support"
"${RENDER_CADENCE_APP_DIR}/control"
"${RENDER_CADENCE_APP_DIR}/control/http"
"${RENDER_CADENCE_APP_DIR}/json"
"${RENDER_CADENCE_APP_DIR}/logging"
)
target_link_libraries(RenderCadenceCompositorHttpControlServerTests PRIVATE
Ws2_32
)
if(MSVC)
target_compile_options(RenderCadenceCompositorHttpControlServerTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorHttpControlServerTests COMMAND RenderCadenceCompositorHttpControlServerTests)
add_executable(RenderCadenceCompositorAppConfigProviderTests
"${APP_DIR}/runtime/support/RuntimeJson.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfig.cpp"
"${RENDER_CADENCE_APP_DIR}/app/AppConfigProvider.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RenderCadenceCompositorAppConfigProviderTests.cpp"
)
target_include_directories(RenderCadenceCompositorAppConfigProviderTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/runtime/support"
"${RENDER_CADENCE_APP_DIR}/app"
"${RENDER_CADENCE_APP_DIR}/control"
"${RENDER_CADENCE_APP_DIR}/logging"
"${RENDER_CADENCE_APP_DIR}/telemetry"
"${RENDER_CADENCE_APP_DIR}/video"
"${APP_DIR}/videoio"
"${APP_DIR}/videoio/decklink"
)
target_link_libraries(RenderCadenceCompositorAppConfigProviderTests PRIVATE
Ws2_32
)
if(MSVC)
target_compile_options(RenderCadenceCompositorAppConfigProviderTests PRIVATE /W3)
endif()
add_test(NAME RenderCadenceCompositorAppConfigProviderTests COMMAND RenderCadenceCompositorAppConfigProviderTests)
add_executable(SystemOutputFramePoolTests
"${APP_DIR}/videoio/SystemOutputFramePool.cpp"
"${APP_DIR}/videoio/VideoIOFormat.cpp"

View File

@@ -0,0 +1,920 @@
#include "DeckLinkSession.h"
#include "GLExtensions.h"
#include "VideoIOFormat.h"
#include "VideoPlayoutPolicy.h"
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace
{
constexpr unsigned kDefaultWidth = 1920;
constexpr unsigned kDefaultHeight = 1080;
constexpr std::size_t kSystemFrameSlots = 12;
constexpr std::size_t kPboDepth = 6;
constexpr std::size_t kWarmupFrames = 4;
constexpr std::size_t kDeckLinkTargetBufferedFrames = 4;
enum class ProbeSlotState
{
Free,
Rendering,
Completed,
Scheduled
};
struct ProbeFrame
{
void* bytes = nullptr;
long rowBytes = 0;
unsigned width = 0;
unsigned height = 0;
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
std::size_t index = 0;
uint64_t generation = 0;
uint64_t frameIndex = 0;
};
struct ProbeMetrics
{
uint64_t renderedFrames = 0;
uint64_t completedFrames = 0;
uint64_t scheduledFrames = 0;
uint64_t completedDrops = 0;
uint64_t acquireMisses = 0;
uint64_t scheduleUnderruns = 0;
uint64_t pboQueueMisses = 0;
std::size_t freeCount = 0;
std::size_t renderingCount = 0;
std::size_t completedCount = 0;
std::size_t scheduledCount = 0;
};
class LatestFrameStore
{
public:
LatestFrameStore(unsigned width, unsigned height, std::size_t capacity) :
mWidth(width),
mHeight(height),
mRowBytes(VideoIORowBytes(VideoIOPixelFormat::Bgra8, width))
{
mSlots.resize(capacity);
const std::size_t byteCount = static_cast<std::size_t>(mRowBytes) * static_cast<std::size_t>(mHeight);
for (Slot& slot : mSlots)
{
slot.bytes.resize(byteCount);
slot.generation = 1;
}
}
bool AcquireForRender(ProbeFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!AcquireFreeLocked(frame))
{
if (!DropOldestCompletedLocked() || !AcquireFreeLocked(frame))
{
++mMetrics.acquireMisses;
return false;
}
}
return true;
}
bool PublishCompleted(const ProbeFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!IsValidLocked(frame))
return false;
Slot& slot = mSlots[frame.index];
if (slot.state != ProbeSlotState::Rendering)
return false;
slot.state = ProbeSlotState::Completed;
slot.frameIndex = frame.frameIndex;
mCompletedIndices.push_back(frame.index);
++mMetrics.completedFrames;
mCondition.notify_all();
return true;
}
bool ConsumeCompleted(ProbeFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
while (!mCompletedIndices.empty())
{
const std::size_t index = mCompletedIndices.front();
mCompletedIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != ProbeSlotState::Completed)
continue;
mSlots[index].state = ProbeSlotState::Scheduled;
FillFrameLocked(index, frame);
++mMetrics.scheduledFrames;
return true;
}
++mMetrics.scheduleUnderruns;
return false;
}
bool ReleaseByBytes(void* bytes)
{
if (bytes == nullptr)
return false;
std::lock_guard<std::mutex> lock(mMutex);
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
if (mSlots[index].bytes.data() != bytes)
continue;
mSlots[index].state = ProbeSlotState::Free;
++mSlots[index].generation;
RemoveCompletedIndexLocked(index);
mCondition.notify_all();
return true;
}
return false;
}
bool WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(mMutex);
return mCondition.wait_for(lock, timeout, [&]() {
return CompletedCountLocked() >= targetDepth;
});
}
ProbeMetrics Metrics() const
{
std::lock_guard<std::mutex> lock(mMutex);
ProbeMetrics metrics = mMetrics;
for (const Slot& slot : mSlots)
{
switch (slot.state)
{
case ProbeSlotState::Free:
++metrics.freeCount;
break;
case ProbeSlotState::Rendering:
++metrics.renderingCount;
break;
case ProbeSlotState::Completed:
++metrics.completedCount;
break;
case ProbeSlotState::Scheduled:
++metrics.scheduledCount;
break;
}
}
return metrics;
}
void CountRenderedFrame()
{
std::lock_guard<std::mutex> lock(mMutex);
++mMetrics.renderedFrames;
}
void CountPboQueueMiss()
{
std::lock_guard<std::mutex> lock(mMutex);
++mMetrics.pboQueueMisses;
}
private:
struct Slot
{
std::vector<unsigned char> bytes;
ProbeSlotState state = ProbeSlotState::Free;
uint64_t generation = 1;
uint64_t frameIndex = 0;
};
bool AcquireFreeLocked(ProbeFrame& frame)
{
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
if (mSlots[index].state != ProbeSlotState::Free)
continue;
mSlots[index].state = ProbeSlotState::Rendering;
++mSlots[index].generation;
FillFrameLocked(index, frame);
return true;
}
return false;
}
bool DropOldestCompletedLocked()
{
while (!mCompletedIndices.empty())
{
const std::size_t index = mCompletedIndices.front();
mCompletedIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != ProbeSlotState::Completed)
continue;
mSlots[index].state = ProbeSlotState::Free;
++mSlots[index].generation;
++mMetrics.completedDrops;
return true;
}
return false;
}
void FillFrameLocked(std::size_t index, ProbeFrame& frame) const
{
const Slot& slot = mSlots[index];
frame.bytes = const_cast<unsigned char*>(slot.bytes.data());
frame.rowBytes = static_cast<long>(mRowBytes);
frame.width = mWidth;
frame.height = mHeight;
frame.pixelFormat = VideoIOPixelFormat::Bgra8;
frame.index = index;
frame.generation = slot.generation;
frame.frameIndex = slot.frameIndex;
}
bool IsValidLocked(const ProbeFrame& frame) const
{
return frame.index < mSlots.size() && mSlots[frame.index].generation == frame.generation;
}
void RemoveCompletedIndexLocked(std::size_t index)
{
mCompletedIndices.erase(std::remove(mCompletedIndices.begin(), mCompletedIndices.end(), index), mCompletedIndices.end());
}
std::size_t CompletedCountLocked() const
{
std::size_t count = 0;
for (const Slot& slot : mSlots)
{
if (slot.state == ProbeSlotState::Completed)
++count;
}
return count;
}
unsigned mWidth = 0;
unsigned mHeight = 0;
unsigned mRowBytes = 0;
std::vector<Slot> mSlots;
std::deque<std::size_t> mCompletedIndices;
mutable std::mutex mMutex;
std::condition_variable mCondition;
ProbeMetrics mMetrics;
};
LRESULT CALLBACK ProbeWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
return DefWindowProc(hwnd, message, wParam, lParam);
}
class HiddenOpenGLContext
{
public:
~HiddenOpenGLContext()
{
Destroy();
}
bool Create(unsigned width, unsigned height, std::string& error)
{
mInstance = GetModuleHandle(nullptr);
WNDCLASSA wc = {};
wc.style = CS_OWNDC;
wc.lpfnWndProc = ProbeWindowProc;
wc.hInstance = mInstance;
wc.lpszClassName = "DeckLinkRenderCadenceProbeWindow";
RegisterClassA(&wc);
mWindow = CreateWindowA(
wc.lpszClassName,
"DeckLink Render Cadence Probe",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
static_cast<int>(width),
static_cast<int>(height),
nullptr,
nullptr,
mInstance,
nullptr);
if (!mWindow)
{
error = "CreateWindowA failed.";
return false;
}
mDc = GetDC(mWindow);
if (!mDc)
{
error = "GetDC failed.";
return false;
}
PIXELFORMATDESCRIPTOR pfd = {};
pfd.nSize = sizeof(pfd);
pfd.nVersion = 1;
pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER;
pfd.iPixelType = PFD_TYPE_RGBA;
pfd.cColorBits = 32;
pfd.cDepthBits = 0;
pfd.iLayerType = PFD_MAIN_PLANE;
const int pixelFormat = ChoosePixelFormat(mDc, &pfd);
if (pixelFormat == 0 || !SetPixelFormat(mDc, pixelFormat, &pfd))
{
error = "Could not choose/set a pixel format.";
return false;
}
mGlrc = wglCreateContext(mDc);
if (!mGlrc)
{
error = "wglCreateContext failed.";
return false;
}
return true;
}
bool MakeCurrent()
{
return mDc && mGlrc && wglMakeCurrent(mDc, mGlrc);
}
void ClearCurrent()
{
wglMakeCurrent(nullptr, nullptr);
}
void Destroy()
{
ClearCurrent();
if (mGlrc)
{
wglDeleteContext(mGlrc);
mGlrc = nullptr;
}
if (mWindow && mDc)
{
ReleaseDC(mWindow, mDc);
mDc = nullptr;
}
if (mWindow)
{
DestroyWindow(mWindow);
mWindow = nullptr;
}
}
private:
HINSTANCE mInstance = nullptr;
HWND mWindow = nullptr;
HDC mDc = nullptr;
HGLRC mGlrc = nullptr;
};
class RenderCadenceProbe
{
public:
RenderCadenceProbe(LatestFrameStore& frameStore, unsigned width, unsigned height, double frameDurationMs) :
mFrameStore(frameStore),
mWidth(width),
mHeight(height),
mFrameDuration(std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double, std::milli>(frameDurationMs)))
{
if (mFrameDuration <= Clock::duration::zero())
mFrameDuration = std::chrono::milliseconds(16);
}
bool Start(std::string& error)
{
mStopping = false;
mThread = std::thread([this]() { ThreadMain(); });
std::unique_lock<std::mutex> lock(mStartupMutex);
if (!mStartupCondition.wait_for(lock, std::chrono::seconds(3), [this]() { return mStarted || !mStartupError.empty(); }))
{
error = "Timed out starting render thread.";
return false;
}
if (!mStartupError.empty())
{
error = mStartupError;
return false;
}
return true;
}
void Stop()
{
mStopping = true;
if (mThread.joinable())
mThread.join();
}
private:
struct PboSlot
{
GLuint pbo = 0;
GLsync fence = nullptr;
bool inFlight = false;
uint64_t frameIndex = 0;
};
using Clock = std::chrono::steady_clock;
void ThreadMain()
{
std::string error;
HiddenOpenGLContext context;
if (!context.Create(mWidth, mHeight, error) || !context.MakeCurrent())
{
SignalStartupFailure(error.empty() ? "OpenGL context creation failed." : error);
return;
}
if (!ResolveGLExtensions())
{
SignalStartupFailure("OpenGL extension resolution failed.");
return;
}
if (!CreateRenderTargets())
{
SignalStartupFailure("OpenGL render target creation failed.");
return;
}
CreatePbos();
SignalStarted();
auto nextRenderTime = Clock::now();
while (!mStopping)
{
ConsumeCompletedPbos();
const auto now = Clock::now();
if (now < nextRenderTime)
{
std::this_thread::sleep_for((std::min)(std::chrono::milliseconds(1), std::chrono::duration_cast<std::chrono::milliseconds>(nextRenderTime - now)));
continue;
}
RenderPattern(mFrameIndex);
if (!QueueReadback(mFrameIndex))
mFrameStore.CountPboQueueMiss();
mFrameStore.CountRenderedFrame();
++mFrameIndex;
nextRenderTime += mFrameDuration;
if (Clock::now() - nextRenderTime > mFrameDuration * 4)
nextRenderTime = Clock::now() + mFrameDuration;
}
FlushPbos();
DestroyPbos();
DestroyRenderTargets();
context.ClearCurrent();
}
bool CreateRenderTargets()
{
glGenFramebuffers(1, &mFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);
glGenTextures(1, &mTexture);
glBindTexture(GL_TEXTURE_2D, mTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, static_cast<GLsizei>(mWidth), static_cast<GLsizei>(mHeight), 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mTexture, 0);
const bool complete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
glBindTexture(GL_TEXTURE_2D, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return complete;
}
void DestroyRenderTargets()
{
if (mFramebuffer != 0)
glDeleteFramebuffers(1, &mFramebuffer);
if (mTexture != 0)
glDeleteTextures(1, &mTexture);
mFramebuffer = 0;
mTexture = 0;
}
void CreatePbos()
{
mPbos.resize(kPboDepth);
const std::size_t byteCount = static_cast<std::size_t>(VideoIORowBytes(VideoIOPixelFormat::Bgra8, mWidth)) * mHeight;
for (PboSlot& slot : mPbos)
{
glGenBuffers(1, &slot.pbo);
glBindBuffer(GL_PIXEL_PACK_BUFFER, slot.pbo);
glBufferData(GL_PIXEL_PACK_BUFFER, static_cast<GLsizeiptr>(byteCount), nullptr, GL_STREAM_READ);
}
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
}
void DestroyPbos()
{
for (PboSlot& slot : mPbos)
{
if (slot.fence)
glDeleteSync(slot.fence);
if (slot.pbo != 0)
glDeleteBuffers(1, &slot.pbo);
slot = {};
}
mPbos.clear();
}
void FlushPbos()
{
for (std::size_t i = 0; i < mPbos.size() * 2; ++i)
ConsumeCompletedPbos();
}
void RenderPattern(uint64_t frameIndex)
{
const float t = static_cast<float>(frameIndex) / 60.0f;
const float red = 0.1f + 0.4f * (0.5f + 0.5f * std::sin(t));
const float green = 0.1f + 0.4f * (0.5f + 0.5f * std::sin(t * 0.73f + 1.0f));
const float blue = 0.15f + 0.3f * (0.5f + 0.5f * std::sin(t * 0.41f + 2.0f));
glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);
glViewport(0, 0, static_cast<GLsizei>(mWidth), static_cast<GLsizei>(mHeight));
glDisable(GL_SCISSOR_TEST);
glClearColor(red, green, blue, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
const int boxWidth = static_cast<int>(mWidth / 6);
const int boxHeight = static_cast<int>(mHeight / 5);
const float phase = 0.5f + 0.5f * std::sin(t * 1.7f);
const int x = static_cast<int>(phase * static_cast<float>(mWidth - boxWidth));
const int y = static_cast<int>((0.5f + 0.5f * std::sin(t * 1.1f + 0.8f)) * static_cast<float>(mHeight - boxHeight));
glEnable(GL_SCISSOR_TEST);
glScissor(x, y, boxWidth, boxHeight);
glClearColor(1.0f - red, 0.85f, 0.15f + blue, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glDisable(GL_SCISSOR_TEST);
}
bool QueueReadback(uint64_t frameIndex)
{
if (mPbos.empty())
return false;
PboSlot& slot = mPbos[mWriteIndex];
if (slot.inFlight)
return false;
const std::size_t byteCount = static_cast<std::size_t>(VideoIORowBytes(VideoIOPixelFormat::Bgra8, mWidth)) * mHeight;
glBindFramebuffer(GL_READ_FRAMEBUFFER, mFramebuffer);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
glBindBuffer(GL_PIXEL_PACK_BUFFER, slot.pbo);
glBufferData(GL_PIXEL_PACK_BUFFER, static_cast<GLsizeiptr>(byteCount), nullptr, GL_STREAM_READ);
glReadPixels(0, 0, static_cast<GLsizei>(mWidth), static_cast<GLsizei>(mHeight), GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, nullptr);
slot.fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
slot.inFlight = slot.fence != nullptr;
slot.frameIndex = frameIndex;
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
mWriteIndex = (mWriteIndex + 1) % mPbos.size();
return slot.inFlight;
}
void ConsumeCompletedPbos()
{
for (std::size_t checked = 0; checked < mPbos.size(); ++checked)
{
PboSlot& slot = mPbos[mReadIndex];
if (!slot.inFlight || slot.fence == nullptr)
{
mReadIndex = (mReadIndex + 1) % mPbos.size();
continue;
}
const GLenum waitResult = glClientWaitSync(slot.fence, 0, 0);
if (waitResult != GL_ALREADY_SIGNALED && waitResult != GL_CONDITION_SATISFIED)
return;
ProbeFrame frame;
if (mFrameStore.AcquireForRender(frame))
{
glBindBuffer(GL_PIXEL_PACK_BUFFER, slot.pbo);
void* mapped = glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY);
if (mapped)
{
const std::size_t byteCount = static_cast<std::size_t>(frame.rowBytes) * frame.height;
std::memcpy(frame.bytes, mapped, byteCount);
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
frame.frameIndex = slot.frameIndex;
mFrameStore.PublishCompleted(frame);
}
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
}
glDeleteSync(slot.fence);
slot.fence = nullptr;
slot.inFlight = false;
mReadIndex = (mReadIndex + 1) % mPbos.size();
}
}
void SignalStarted()
{
std::lock_guard<std::mutex> lock(mStartupMutex);
mStarted = true;
mStartupCondition.notify_all();
}
void SignalStartupFailure(const std::string& error)
{
std::lock_guard<std::mutex> lock(mStartupMutex);
mStartupError = error;
mStartupCondition.notify_all();
}
LatestFrameStore& mFrameStore;
unsigned mWidth = 0;
unsigned mHeight = 0;
Clock::duration mFrameDuration;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::mutex mStartupMutex;
std::condition_variable mStartupCondition;
bool mStarted = false;
std::string mStartupError;
GLuint mFramebuffer = 0;
GLuint mTexture = 0;
std::vector<PboSlot> mPbos;
std::size_t mWriteIndex = 0;
std::size_t mReadIndex = 0;
uint64_t mFrameIndex = 0;
};
class DeckLinkProbePlayout
{
public:
DeckLinkProbePlayout(DeckLinkSession& session, LatestFrameStore& frameStore) :
mSession(session),
mFrameStore(frameStore)
{
}
bool Start()
{
mStopping = false;
mThread = std::thread([this]() { ThreadMain(); });
return true;
}
void Stop()
{
mStopping = true;
if (mThread.joinable())
mThread.join();
}
void ThreadMain()
{
while (!mStopping)
{
const ProbeMetrics metrics = mFrameStore.Metrics();
if (metrics.scheduledCount >= kDeckLinkTargetBufferedFrames)
{
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
ProbeFrame frame;
if (!mFrameStore.ConsumeCompleted(frame))
{
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
VideoIOOutputFrame outputFrame;
outputFrame.bytes = frame.bytes;
outputFrame.nativeBuffer = frame.bytes;
outputFrame.rowBytes = frame.rowBytes;
outputFrame.width = frame.width;
outputFrame.height = frame.height;
outputFrame.pixelFormat = frame.pixelFormat;
if (!mSession.ScheduleOutputFrame(outputFrame))
{
mFrameStore.ReleaseByBytes(frame.bytes);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
}
private:
DeckLinkSession& mSession;
LatestFrameStore& mFrameStore;
std::thread mThread;
std::atomic<bool> mStopping{ false };
};
std::string CompletionResultToString(VideoIOCompletionResult result)
{
switch (result)
{
case VideoIOCompletionResult::Completed:
return "completed";
case VideoIOCompletionResult::DisplayedLate:
return "late";
case VideoIOCompletionResult::Dropped:
return "dropped";
case VideoIOCompletionResult::Flushed:
return "flushed";
case VideoIOCompletionResult::Unknown:
default:
return "unknown";
}
}
void PrintUsage()
{
std::cout << "DeckLinkRenderCadenceProbe\n"
<< " Renders a simple OpenGL BGRA8 motion pattern on one GL thread,\n"
<< " copies completed PBO readbacks into latest-N system memory slots,\n"
<< " warms up rendered frames, then feeds DeckLink scheduled playback.\n\n"
<< "Press Enter to stop.\n";
}
class ComInitGuard
{
public:
~ComInitGuard()
{
if (mInitialized)
CoUninitialize();
}
bool Initialize()
{
const HRESULT result = CoInitialize(nullptr);
mInitialized = SUCCEEDED(result);
mResult = result;
return mInitialized;
}
HRESULT Result() const { return mResult; }
private:
bool mInitialized = false;
HRESULT mResult = S_OK;
};
}
int main()
{
PrintUsage();
ComInitGuard com;
if (!com.Initialize())
{
std::cerr << "COM initialization failed: 0x" << std::hex << com.Result() << std::dec << "\n";
return 1;
}
LatestFrameStore frameStore(kDefaultWidth, kDefaultHeight, kSystemFrameSlots);
DeckLinkSession deckLink;
std::atomic<uint64_t> completions{ 0 };
std::atomic<uint64_t> late{ 0 };
std::atomic<uint64_t> dropped{ 0 };
VideoFormatSelection formats;
std::string error;
if (!deckLink.DiscoverDevicesAndModes(formats, error))
{
std::cerr << "DeckLink discovery failed: " << error << "\n";
return 1;
}
if (!deckLink.SelectPreferredFormats(formats, false, error))
{
std::cerr << "DeckLink format selection failed: " << error << "\n";
return 1;
}
if (!deckLink.ConfigureOutput(
[&](const VideoIOCompletion& completion) {
frameStore.ReleaseByBytes(completion.outputFrameBuffer);
++completions;
if (completion.result == VideoIOCompletionResult::DisplayedLate)
++late;
else if (completion.result == VideoIOCompletionResult::Dropped)
++dropped;
},
formats.output,
false,
error))
{
std::cerr << "DeckLink output configuration failed: " << error << "\n";
return 1;
}
if (!deckLink.PrepareOutputSchedule())
{
std::cerr << "DeckLink schedule preparation failed.\n";
return 1;
}
const VideoIOState& state = deckLink.State();
if (state.outputFrameSize.width != kDefaultWidth || state.outputFrameSize.height != kDefaultHeight)
{
std::cerr << "This probe currently expects 1920x1080 output. Selected mode is "
<< state.outputFrameSize.width << "x" << state.outputFrameSize.height << ".\n";
return 1;
}
RenderCadenceProbe renderer(frameStore, state.outputFrameSize.width, state.outputFrameSize.height, state.frameBudgetMilliseconds);
if (!renderer.Start(error))
{
std::cerr << "Render thread start failed: " << error << "\n";
return 1;
}
std::cout << "Warming up " << kWarmupFrames << " rendered frames at cadence...\n";
if (!frameStore.WaitForCompletedDepth(kWarmupFrames, std::chrono::seconds(3)))
{
std::cerr << "Timed out waiting for rendered warmup frames.\n";
renderer.Stop();
return 1;
}
DeckLinkProbePlayout playout(deckLink, frameStore);
playout.Start();
const auto prerollDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < prerollDeadline)
{
if (frameStore.Metrics().scheduledCount >= kDeckLinkTargetBufferedFrames)
break;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
if (!deckLink.StartScheduledPlayback())
{
std::cerr << "DeckLink scheduled playback failed to start.\n";
playout.Stop();
renderer.Stop();
return 1;
}
std::atomic<bool> metricsStopping{ false };
std::thread metricsThread([&]() {
uint64_t lastRendered = 0;
uint64_t lastScheduled = 0;
auto lastTime = std::chrono::steady_clock::now();
while (!metricsStopping)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
const auto now = std::chrono::steady_clock::now();
const double seconds = std::chrono::duration_cast<std::chrono::duration<double>>(now - lastTime).count();
const ProbeMetrics metrics = frameStore.Metrics();
const double renderFps = seconds > 0.0 ? static_cast<double>(metrics.renderedFrames - lastRendered) / seconds : 0.0;
const double scheduleFps = seconds > 0.0 ? static_cast<double>(metrics.scheduledFrames - lastScheduled) / seconds : 0.0;
lastRendered = metrics.renderedFrames;
lastScheduled = metrics.scheduledFrames;
lastTime = now;
std::cout << std::fixed << std::setprecision(1)
<< "renderFps=" << renderFps
<< " scheduleFps=" << scheduleFps
<< " free=" << metrics.freeCount
<< " completed=" << metrics.completedCount
<< " scheduled=" << metrics.scheduledCount
<< " drops=" << metrics.completedDrops
<< " pboMiss=" << metrics.pboQueueMisses
<< " completions=" << completions.load()
<< " late=" << late.load()
<< " dropped=" << dropped.load()
<< " decklinkBuffered=" << deckLink.State().actualDeckLinkBufferedFrames
<< "\n";
}
});
std::string line;
std::getline(std::cin, line);
metricsStopping = true;
if (metricsThread.joinable())
metricsThread.join();
playout.Stop();
deckLink.Stop();
renderer.Stop();
deckLink.ReleaseResources();
return 0;
}

View File

@@ -0,0 +1,113 @@
# DeckLink Render Cadence Probe
This is a deliberately small architecture probe for the Phase 7.7 playout model.
It is not the main app and does not use the main runtime, shader stack, preview path, input upload path, or render engine.
## What It Tests
The probe validates the clean playout spine:
```text
single OpenGL render thread
owns its own hidden GL context
renders a simple moving BGRA8 pattern at output cadence
queues GPU readback through a PBO ring
copies completed readbacks into latest-N system-memory slots
system-memory frame store
owns free / rendering / completed / scheduled slots
drops old completed unscheduled frames when render cadence needs space
protects scheduled frames until DeckLink completion
DeckLink playout thread
consumes completed system-memory frames
keeps a small scheduled buffer filled
does not render
```
Startup warms up rendered frames before starting DeckLink scheduled playback.
## How To Build
```powershell
cmake --build --preset build-debug --target DeckLinkRenderCadenceProbe -- /m:1
```
The executable is:
```text
build\vs2022-x64-debug\Debug\DeckLinkRenderCadenceProbe.exe
```
## How To Run
Run it from a terminal so you can see the telemetry:
```powershell
build\vs2022-x64-debug\Debug\DeckLinkRenderCadenceProbe.exe
```
Press Enter to stop.
The first version assumes `1080p59.94` / `1920x1080` output and BGRA8 system-memory frames.
## What To Watch
The probe prints one line per second:
- `renderFps`: cadence render throughput
- `scheduleFps`: DeckLink scheduling throughput
- `free`: free system-memory slots
- `completed`: rendered, unscheduled slots
- `scheduled`: slots currently owned by DeckLink
- `drops`: old completed unscheduled frames recycled by the latest-N cache
- `pboMiss`: PBO ring was full when trying to queue readback
- `late`: DeckLink displayed-late completions
- `dropped`: DeckLink dropped completions
- `decklinkBuffered`: actual DeckLink buffered-frame count when available
For a healthy architecture proof, expect:
- `renderFps` close to the selected output cadence
- `scheduleFps` close to the selected output cadence after warmup
- `scheduled` hovering near the target buffer depth
- `late` and `dropped` not increasing continuously
- visible motion that is smooth on the DeckLink output
## Interpretation
If this probe is smooth at 59.94/60, the broad architecture is viable and the main app's remaining stutters are likely caused by integration details such as input upload, shared render-thread work, preview/screenshot work, or runtime/render-state coupling.
If this probe is not smooth, the problem is lower level: DeckLink scheduling, OpenGL readback, Windows scheduling, or hardware/driver behavior.
## Initial Result
Date: 2026-05-12
User-visible result:
- output looked smooth
Representative telemetry:
```text
renderFps=59.9 scheduleFps=59.9 free=7 completed=1 scheduled=4 drops=0 pboMiss=0 completions=119 late=0 dropped=0 decklinkBuffered=4
renderFps=59.9 scheduleFps=59.9 free=7 completed=1 scheduled=4 drops=0 pboMiss=0 completions=179 late=0 dropped=0 decklinkBuffered=4
renderFps=59.8 scheduleFps=59.8 free=7 completed=1 scheduled=4 drops=0 pboMiss=0 completions=239 late=0 dropped=0 decklinkBuffered=4
renderFps=60.8 scheduleFps=59.8 free=7 completed=1 scheduled=4 drops=0 pboMiss=0 completions=299 late=0 dropped=0 decklinkBuffered=4
renderFps=59.9 scheduleFps=59.9 free=7 completed=1 scheduled=4 drops=0 pboMiss=0 completions=360 late=0 dropped=0 decklinkBuffered=4
renderFps=59.8 scheduleFps=60.8 free=8 completed=0 scheduled=4 drops=0 pboMiss=0 completions=420 late=0 dropped=0 decklinkBuffered=4
```
Read:
- the clean architecture can sustain the selected output cadence on the test machine
- BGRA8 PBO readback is viable when isolated from the main app's other render-thread work
- latest-N system-memory buffering stayed stable
- DeckLink actual buffered depth stayed at 4
- there were no late frames, dropped frames, completed-frame drops, or PBO misses in the sampled output
Implication:
The main app's remaining stutters are likely integration/ownership issues rather than a fundamental DeckLink/OpenGL/BGRA8 readback limit. The highest-value suspects are input upload before output render, shared render-thread queue contention, preview/screenshot work, and runtime/render-state work on the output path.

View File

@@ -51,6 +51,11 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt
deckLink.set("externalKeyingRequested", JsonValue(telemetrySnapshot.videoIO.externalKeyingRequested));
deckLink.set("externalKeyingActive", JsonValue(telemetrySnapshot.videoIO.externalKeyingActive));
deckLink.set("statusMessage", JsonValue(telemetrySnapshot.videoIO.statusMessage));
deckLink.set("actualBufferedFramesAvailable", JsonValue(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFramesAvailable));
deckLink.set("actualBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFrames)));
deckLink.set("targetBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.targetDeckLinkBufferedFrames)));
deckLink.set("scheduleCallMs", JsonValue(telemetrySnapshot.backendPlayout.deckLinkScheduleCallMilliseconds));
deckLink.set("scheduleFailures", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.deckLinkScheduleFailureCount)));
root.set("decklink", deckLink);
JsonValue videoIO = JsonValue::MakeObject();
@@ -129,11 +134,22 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt
recovery.set("completedFrameIndex", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.completedFrameIndex)));
recovery.set("scheduledFrameIndex", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledFrameIndex)));
recovery.set("scheduledLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames)));
recovery.set("syntheticScheduledLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames)));
recovery.set("measuredLagFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.measuredLagFrames)));
recovery.set("catchUpFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.catchUpFrames)));
recovery.set("lateStreak", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.lateStreak)));
recovery.set("dropStreak", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.dropStreak)));
JsonValue deckLinkPlayout = JsonValue::MakeObject();
deckLinkPlayout.set("actualBufferedFramesAvailable", JsonValue(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFramesAvailable));
deckLinkPlayout.set("actualBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.actualDeckLinkBufferedFrames)));
deckLinkPlayout.set("targetBufferedFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.targetDeckLinkBufferedFrames)));
deckLinkPlayout.set("scheduleCallMs", JsonValue(telemetrySnapshot.backendPlayout.deckLinkScheduleCallMilliseconds));
deckLinkPlayout.set("scheduleFailures", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.deckLinkScheduleFailureCount)));
JsonValue scheduler = JsonValue::MakeObject();
scheduler.set("syntheticLeadFrames", JsonValue(static_cast<double>(telemetrySnapshot.backendPlayout.scheduledLeadFrames)));
JsonValue backendPlayout = JsonValue::MakeObject();
backendPlayout.set("lifecycleState", JsonValue(telemetrySnapshot.backendPlayout.lifecycleState));
backendPlayout.set("degraded", JsonValue(telemetrySnapshot.backendPlayout.degraded));
@@ -144,6 +160,8 @@ JsonValue RuntimeStatePresenter::BuildRuntimeStateValue(const RuntimeStore& runt
backendPlayout.set("readyQueue", readyQueue);
backendPlayout.set("systemMemory", systemMemory);
backendPlayout.set("outputRender", outputRender);
backendPlayout.set("decklink", deckLinkPlayout);
backendPlayout.set("scheduler", scheduler);
backendPlayout.set("recovery", recovery);
root.set("backendPlayout", backendPlayout);

View File

@@ -347,6 +347,32 @@ bool HealthTelemetry::TryRecordSystemMemoryPlayoutStats(std::size_t freeFrameCou
return true;
}
void HealthTelemetry::RecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames,
std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount)
{
std::lock_guard<std::mutex> lock(mMutex);
mBackendPlayout.actualDeckLinkBufferedFramesAvailable = actualBufferedFramesAvailable;
mBackendPlayout.actualDeckLinkBufferedFrames = actualBufferedFramesAvailable ? actualBufferedFrames : 0;
mBackendPlayout.targetDeckLinkBufferedFrames = targetBufferedFrames;
mBackendPlayout.deckLinkScheduleCallMilliseconds = std::max(scheduleCallMilliseconds, 0.0);
mBackendPlayout.deckLinkScheduleFailureCount = scheduleFailureCount;
}
bool HealthTelemetry::TryRecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames,
std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount)
{
std::unique_lock<std::mutex> lock(mMutex, std::try_to_lock);
if (!lock.owns_lock())
return false;
mBackendPlayout.actualDeckLinkBufferedFramesAvailable = actualBufferedFramesAvailable;
mBackendPlayout.actualDeckLinkBufferedFrames = actualBufferedFramesAvailable ? actualBufferedFrames : 0;
mBackendPlayout.targetDeckLinkBufferedFrames = targetBufferedFrames;
mBackendPlayout.deckLinkScheduleCallMilliseconds = std::max(scheduleCallMilliseconds, 0.0);
mBackendPlayout.deckLinkScheduleFailureCount = scheduleFailureCount;
return true;
}
void HealthTelemetry::RecordOutputRenderPipelineTiming(
double drawMilliseconds,
double fenceWaitMilliseconds,

View File

@@ -126,6 +126,11 @@ public:
uint64_t completedFrameIndex = 0;
uint64_t scheduledFrameIndex = 0;
uint64_t scheduledLeadFrames = 0;
bool actualDeckLinkBufferedFramesAvailable = false;
uint64_t actualDeckLinkBufferedFrames = 0;
std::size_t targetDeckLinkBufferedFrames = 0;
double deckLinkScheduleCallMilliseconds = 0.0;
uint64_t deckLinkScheduleFailureCount = 0;
uint64_t measuredLagFrames = 0;
uint64_t catchUpFrames = 0;
uint64_t lateStreak = 0;
@@ -213,6 +218,11 @@ public:
std::size_t scheduledFrameCount, uint64_t underrunCount, uint64_t repeatCount, uint64_t dropCount,
double frameAgeAtScheduleMilliseconds, double frameAgeAtCompletionMilliseconds);
void RecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames,
std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount);
bool TryRecordDeckLinkBufferTelemetry(bool actualBufferedFramesAvailable, uint64_t actualBufferedFrames,
std::size_t targetBufferedFrames, double scheduleCallMilliseconds, uint64_t scheduleFailureCount);
void RecordOutputRenderPipelineTiming(
double drawMilliseconds,
double fenceWaitMilliseconds,

View File

@@ -0,0 +1,102 @@
#include "RenderCadenceController.h"
#include <algorithm>
#include <cmath>
void RenderCadenceController::Configure(Duration targetFrameDuration, TimePoint firstRenderTime, const RenderCadencePolicy& policy)
{
mTargetFrameDuration = IsPositive(targetFrameDuration) ? targetFrameDuration : std::chrono::milliseconds(1);
mPolicy = policy;
if (mPolicy.skipThresholdFrames < 1.0)
mPolicy.skipThresholdFrames = 1.0;
Reset(firstRenderTime);
}
void RenderCadenceController::Reset(TimePoint firstRenderTime)
{
mNextRenderTime = firstRenderTime;
mNextFrameIndex = 0;
mMetrics = RenderCadenceMetrics();
}
RenderCadenceDecision RenderCadenceController::Tick(TimePoint now)
{
RenderCadenceDecision decision;
decision.frameIndex = mNextFrameIndex;
decision.renderTargetTime = mNextRenderTime;
decision.nextRenderTime = mNextRenderTime;
if (now < mNextRenderTime)
{
decision.action = RenderCadenceAction::Wait;
decision.waitDuration = mNextRenderTime - now;
decision.reason = "waiting-for-next-render-tick";
return decision;
}
const Duration lateness = now - mNextRenderTime;
const uint64_t skippedTicks = SkippedTicksForLateness(lateness);
if (skippedTicks > 0)
{
decision.skippedTicks = skippedTicks;
decision.frameIndex = mNextFrameIndex + skippedTicks;
decision.renderTargetTime = mNextRenderTime + (mTargetFrameDuration * skippedTicks);
decision.reason = "late-skip-render-ticks";
mMetrics.skippedTickCount += skippedTicks;
}
else
{
decision.reason = IsPositive(lateness) ? "late-render-now" : "on-time-render";
}
decision.action = RenderCadenceAction::Render;
decision.lateness = now > decision.renderTargetTime
? now - decision.renderTargetTime
: Duration::zero();
mNextFrameIndex = decision.frameIndex + 1;
mNextRenderTime = decision.renderTargetTime + mTargetFrameDuration;
decision.nextRenderTime = mNextRenderTime;
++mMetrics.renderedFrameCount;
mMetrics.nextFrameIndex = mNextFrameIndex;
mMetrics.lastLateness = decision.lateness;
if (IsPositive(decision.lateness))
{
++mMetrics.lateFrameCount;
mMetrics.maxLateness = (std::max)(mMetrics.maxLateness, decision.lateness);
}
return decision;
}
uint64_t RenderCadenceController::SkippedTicksForLateness(Duration lateness) const
{
if (!mPolicy.skipLateTicks || !IsPositive(lateness) || !IsPositive(mTargetFrameDuration))
return 0;
const double lateFrames = static_cast<double>(lateness.count()) / static_cast<double>(mTargetFrameDuration.count());
if (lateFrames < mPolicy.skipThresholdFrames)
return 0;
const uint64_t elapsedTicks = static_cast<uint64_t>(std::floor(lateFrames));
if (elapsedTicks == 0)
return 0;
return (std::min)(elapsedTicks, mPolicy.maxSkippedTicksPerDecision);
}
bool RenderCadenceController::IsPositive(Duration duration)
{
return duration > Duration::zero();
}
const char* RenderCadenceActionName(RenderCadenceAction action)
{
switch (action)
{
case RenderCadenceAction::Render:
return "Render";
case RenderCadenceAction::Wait:
default:
return "Wait";
}
}

View File

@@ -0,0 +1,68 @@
#pragma once
#include <chrono>
#include <cstdint>
enum class RenderCadenceAction
{
Wait,
Render
};
struct RenderCadencePolicy
{
bool skipLateTicks = true;
uint64_t maxSkippedTicksPerDecision = 4;
double skipThresholdFrames = 2.0;
};
struct RenderCadenceDecision
{
RenderCadenceAction action = RenderCadenceAction::Wait;
uint64_t frameIndex = 0;
uint64_t skippedTicks = 0;
std::chrono::steady_clock::time_point renderTargetTime;
std::chrono::steady_clock::time_point nextRenderTime;
std::chrono::steady_clock::duration waitDuration = std::chrono::steady_clock::duration::zero();
std::chrono::steady_clock::duration lateness = std::chrono::steady_clock::duration::zero();
const char* reason = "waiting-for-next-render-tick";
};
struct RenderCadenceMetrics
{
uint64_t nextFrameIndex = 0;
uint64_t renderedFrameCount = 0;
uint64_t skippedTickCount = 0;
uint64_t lateFrameCount = 0;
std::chrono::steady_clock::duration lastLateness = std::chrono::steady_clock::duration::zero();
std::chrono::steady_clock::duration maxLateness = std::chrono::steady_clock::duration::zero();
};
class RenderCadenceController
{
public:
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;
using Duration = Clock::duration;
void Configure(Duration targetFrameDuration, TimePoint firstRenderTime, const RenderCadencePolicy& policy = RenderCadencePolicy());
void Reset(TimePoint firstRenderTime);
RenderCadenceDecision Tick(TimePoint now);
Duration TargetFrameDuration() const { return mTargetFrameDuration; }
TimePoint NextRenderTime() const { return mNextRenderTime; }
uint64_t NextFrameIndex() const { return mNextFrameIndex; }
const RenderCadenceMetrics& Metrics() const { return mMetrics; }
private:
uint64_t SkippedTicksForLateness(Duration lateness) const;
static bool IsPositive(Duration duration);
Duration mTargetFrameDuration = std::chrono::milliseconds(16);
TimePoint mNextRenderTime;
uint64_t mNextFrameIndex = 0;
RenderCadencePolicy mPolicy;
RenderCadenceMetrics mMetrics;
};
const char* RenderCadenceActionName(RenderCadenceAction action);

View File

@@ -47,6 +47,18 @@ bool RenderOutputQueue::TryPop(RenderOutputFrame& frame)
return true;
}
bool RenderOutputQueue::DropOldestFrame()
{
std::lock_guard<std::mutex> lock(mMutex);
if (mReadyFrames.empty())
return false;
ReleaseFrame(mReadyFrames.front());
mReadyFrames.pop_front();
++mDroppedCount;
return true;
}
void RenderOutputQueue::Clear()
{
std::lock_guard<std::mutex> lock(mMutex);

View File

@@ -34,6 +34,7 @@ public:
void Configure(const VideoPlayoutPolicy& policy);
bool Push(RenderOutputFrame frame);
bool TryPop(RenderOutputFrame& frame);
bool DropOldestFrame();
void Clear();
RenderOutputQueueMetrics GetMetrics() const;

View File

@@ -51,7 +51,7 @@ bool SystemOutputFramePool::AcquireFreeSlot(OutputFrameSlot& slot)
if (mSlots[index].state != OutputFrameSlotState::Free)
continue;
mSlots[index].state = OutputFrameSlotState::Acquired;
mSlots[index].state = OutputFrameSlotState::Rendering;
++mSlots[index].generation;
FillOutputSlotLocked(index, slot);
return true;
@@ -62,16 +62,26 @@ bool SystemOutputFramePool::AcquireFreeSlot(OutputFrameSlot& slot)
return false;
}
bool SystemOutputFramePool::AcquireRenderingSlot(OutputFrameSlot& slot)
{
return AcquireFreeSlot(slot);
}
bool SystemOutputFramePool::PublishReadySlot(const OutputFrameSlot& slot)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!TransitionSlotLocked(slot, OutputFrameSlotState::Acquired, OutputFrameSlotState::Ready))
if (!TransitionSlotLocked(slot, OutputFrameSlotState::Rendering, OutputFrameSlotState::Completed))
return false;
mReadySlots.push_back(slot.index);
return true;
}
bool SystemOutputFramePool::PublishCompletedSlot(const OutputFrameSlot& slot)
{
return PublishReadySlot(slot);
}
bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot)
{
std::lock_guard<std::mutex> lock(mMutex);
@@ -79,10 +89,9 @@ bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot)
{
const std::size_t index = mReadySlots.front();
mReadySlots.pop_front();
if (index >= mSlots.size() || mSlots[index].state != OutputFrameSlotState::Ready)
if (index >= mSlots.size() || mSlots[index].state != OutputFrameSlotState::Completed)
continue;
mSlots[index].state = OutputFrameSlotState::Consumed;
FillOutputSlotLocked(index, slot);
return true;
}
@@ -92,16 +101,18 @@ bool SystemOutputFramePool::ConsumeReadySlot(OutputFrameSlot& slot)
return false;
}
bool SystemOutputFramePool::ConsumeCompletedSlot(OutputFrameSlot& slot)
{
return ConsumeReadySlot(slot);
}
bool SystemOutputFramePool::MarkScheduled(const OutputFrameSlot& slot)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!IsValidSlotLocked(slot))
return false;
if (mSlots[slot.index].state != OutputFrameSlotState::Ready &&
mSlots[slot.index].state != OutputFrameSlotState::Consumed)
{
if (mSlots[slot.index].state != OutputFrameSlotState::Completed)
return false;
}
RemoveReadyIndexLocked(slot.index);
mSlots[slot.index].state = OutputFrameSlotState::Scheduled;
@@ -118,11 +129,8 @@ bool SystemOutputFramePool::MarkScheduledByBuffer(void* bytes)
{
if (mSlots[index].bytes.empty() || mSlots[index].bytes.data() != bytes)
continue;
if (mSlots[index].state != OutputFrameSlotState::Ready &&
mSlots[index].state != OutputFrameSlotState::Consumed)
{
if (mSlots[index].state != OutputFrameSlotState::Completed)
return false;
}
RemoveReadyIndexLocked(index);
mSlots[index].state = OutputFrameSlotState::Scheduled;
@@ -187,13 +195,12 @@ SystemOutputFramePoolMetrics SystemOutputFramePool::GetMetrics() const
case OutputFrameSlotState::Free:
++metrics.freeCount;
break;
case OutputFrameSlotState::Acquired:
case OutputFrameSlotState::Rendering:
++metrics.renderingCount;
++metrics.acquiredCount;
break;
case OutputFrameSlotState::Ready:
break;
case OutputFrameSlotState::Consumed:
++metrics.consumedCount;
case OutputFrameSlotState::Completed:
++metrics.completedCount;
break;
case OutputFrameSlotState::Scheduled:
++metrics.scheduledCount;

View File

@@ -11,9 +11,8 @@
enum class OutputFrameSlotState
{
Free,
Acquired,
Ready,
Consumed,
Rendering,
Completed,
Scheduled
};
@@ -37,10 +36,12 @@ struct SystemOutputFramePoolMetrics
{
std::size_t capacity = 0;
std::size_t freeCount = 0;
std::size_t renderingCount = 0;
std::size_t completedCount = 0;
std::size_t scheduledCount = 0;
std::size_t acquiredCount = 0;
std::size_t readyCount = 0;
std::size_t consumedCount = 0;
std::size_t scheduledCount = 0;
uint64_t acquireMissCount = 0;
uint64_t readyUnderrunCount = 0;
};
@@ -55,8 +56,11 @@ public:
SystemOutputFramePoolConfig Config() const;
bool AcquireFreeSlot(OutputFrameSlot& slot);
bool AcquireRenderingSlot(OutputFrameSlot& slot);
bool PublishReadySlot(const OutputFrameSlot& slot);
bool PublishCompletedSlot(const OutputFrameSlot& slot);
bool ConsumeReadySlot(OutputFrameSlot& slot);
bool ConsumeCompletedSlot(OutputFrameSlot& slot);
bool MarkScheduled(const OutputFrameSlot& slot);
bool MarkScheduledByBuffer(void* bytes);
bool ReleaseSlot(const OutputFrameSlot& slot);

View File

@@ -8,6 +8,7 @@
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <windows.h>
@@ -19,7 +20,8 @@ VideoBackend::VideoBackend(RenderEngine& renderEngine, HealthTelemetry& healthTe
mOutputProductionController(mPlayoutPolicy),
mReadyOutputQueue(mPlayoutPolicy),
mVideoIODevice(std::make_unique<DeckLinkSession>()),
mBridge(std::make_unique<OpenGLVideoIOBridge>(renderEngine))
mBridge(std::make_unique<OpenGLVideoIOBridge>(renderEngine)),
mInputCaptureDisabled(IsEnvironmentFlagEnabled("VST_DISABLE_INPUT_CAPTURE"))
{
}
@@ -69,6 +71,12 @@ bool VideoBackend::ConfigureInput(const VideoFormat& inputVideoMode, std::string
{
if (mLifecycle.State() != VideoBackendLifecycleState::Configuring)
ApplyLifecycleTransition(VideoBackendLifecycleState::Configuring, "Configuring video backend input.");
if (mInputCaptureDisabled)
{
MutableState().hasInputSource = false;
MutableState().statusMessage = "DeckLink input capture disabled by VST_DISABLE_INPUT_CAPTURE for output timing isolation.";
return true;
}
if (!mVideoIODevice->ConfigureInput(
[this](const VideoIOFrame& frame) { HandleInputFrame(frame); },
inputVideoMode,
@@ -110,19 +118,42 @@ bool VideoBackend::ConfigureOutput(const VideoFormat& outputVideoMode, bool exte
bool VideoBackend::Start()
{
ApplyLifecycleTransition(VideoBackendLifecycleState::Prerolling, "Video backend preroll starting.");
if (!mVideoIODevice->PrepareOutputSchedule())
{
ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend output schedule preparation failed." : StatusMessage());
return false;
}
StartOutputCompletionWorker();
const bool started = mVideoIODevice->Start();
if (started)
{
StartOutputProducerWorker();
ApplyLifecycleTransition(VideoBackendLifecycleState::Running, "Video backend started.");
}
else
if (!WarmupOutputPreroll())
{
StopOutputProducerWorker();
StopOutputCompletionWorker();
ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend start failed." : StatusMessage());
ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend preroll warmup failed." : StatusMessage());
return false;
}
return started;
if (!mInputCaptureDisabled && !mVideoIODevice->StartInputStreams())
{
StopOutputProducerWorker();
StopOutputCompletionWorker();
ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend input stream start failed." : StatusMessage());
return false;
}
if (!mVideoIODevice->StartScheduledPlayback())
{
StopOutputProducerWorker();
mVideoIODevice->Stop();
StopOutputCompletionWorker();
ApplyLifecycleFailure(StatusMessage().empty() ? "Video backend scheduled playback start failed." : StatusMessage());
return false;
}
ApplyLifecycleTransition(VideoBackendLifecycleState::Running, "Video backend started.");
return true;
}
bool VideoBackend::Stop()
@@ -175,6 +206,8 @@ bool VideoBackend::HasInputDevice() const
bool VideoBackend::HasInputSource() const
{
if (mInputCaptureDisabled)
return false;
return mVideoIODevice->HasInputSource();
}
@@ -288,6 +321,9 @@ void VideoBackend::ReportNoInputDeviceSignalStatus()
void VideoBackend::HandleInputFrame(const VideoIOFrame& frame)
{
if (mInputCaptureDisabled)
return;
const VideoIOState& state = mVideoIODevice->State();
mHealthTelemetry.TryReportSignalStatus(!frame.hasNoInputSource, state.inputFrameSize.width, state.inputFrameSize.height, state.inputDisplayModeName);
PublishInputSignalChanged(frame, state);
@@ -359,6 +395,12 @@ void VideoBackend::StartOutputProducerWorker()
if (mOutputProducerWorkerRunning)
return;
const double frameBudgetMilliseconds = State().frameBudgetMilliseconds;
const auto frameDuration = frameBudgetMilliseconds > 0.0
? std::chrono::duration_cast<RenderCadenceController::Duration>(
std::chrono::duration<double, std::milli>(frameBudgetMilliseconds))
: std::chrono::milliseconds(16);
mRenderCadenceController.Configure(frameDuration, std::chrono::steady_clock::now());
mLastOutputProductionCompletion = VideoIOCompletion();
mLastOutputProductionTime = std::chrono::steady_clock::time_point();
mOutputProducerWorkerStopping = false;
@@ -387,6 +429,39 @@ void VideoBackend::NotifyOutputProducer()
mOutputProducerCondition.notify_one();
}
bool VideoBackend::WarmupOutputPreroll()
{
const VideoPlayoutPolicy policy = NormalizeVideoPlayoutPolicy(mPlayoutPolicy);
const std::size_t targetPrerollFrames = static_cast<std::size_t>(policy.targetPrerollFrames);
if (targetPrerollFrames == 0)
return true;
const double frameBudgetMilliseconds = State().frameBudgetMilliseconds > 0.0 ? State().frameBudgetMilliseconds : 16.0;
const auto estimatedCadenceTime = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::duration<double, std::milli>(frameBudgetMilliseconds * static_cast<double>(targetPrerollFrames + 2)));
const auto timeout = (std::max)(std::chrono::milliseconds(1000), estimatedCadenceTime + std::chrono::milliseconds(500));
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline)
{
ScheduleReadyOutputFramesToTarget();
const SystemOutputFramePoolMetrics metrics = mSystemOutputFramePool.GetMetrics();
RecordSystemMemoryPlayoutStats();
if (metrics.scheduledCount >= targetPrerollFrames)
return true;
NotifyOutputProducer();
const auto waitDuration = (std::min)(OutputProducerWakeInterval(), std::chrono::milliseconds(5));
std::unique_lock<std::mutex> lock(mOutputProducerMutex);
mOutputProducerCondition.wait_for(lock, waitDuration);
if (mOutputProducerWorkerStopping)
return false;
}
SetStatusMessage("Timed out warming up DeckLink preroll from rendered system-memory frames.");
return false;
}
void VideoBackend::OutputCompletionWorkerMain()
{
for (;;)
@@ -433,11 +508,16 @@ void VideoBackend::OutputProducerWorkerMain()
const RenderOutputQueueMetrics metrics = mReadyOutputQueue.GetMetrics();
RecordReadyQueueDepthSample(metrics);
const OutputProductionDecision decision = mOutputProductionController.Decide(BuildOutputProductionPressure(metrics));
if (decision.action != OutputProductionAction::Produce || decision.requestedFrames == 0)
const auto now = std::chrono::steady_clock::now();
RenderCadenceDecision cadenceDecision = mRenderCadenceController.Tick(now);
if (cadenceDecision.action == RenderCadenceAction::Wait)
{
const auto waitDuration = (std::min)(
std::chrono::duration_cast<std::chrono::milliseconds>(cadenceDecision.waitDuration),
OutputProducerWakeInterval());
std::unique_lock<std::mutex> lock(mOutputProducerMutex);
mOutputProducerCondition.wait_for(lock, OutputProducerWakeInterval());
mOutputProducerCondition.wait_for(lock, waitDuration);
if (mOutputProducerWorkerStopping)
{
mOutputProducerWorkerRunning = false;
@@ -454,16 +534,7 @@ void VideoBackend::OutputProducerWorkerMain()
completion = mLastOutputProductionCompletion;
}
const bool belowTargetDepth = metrics.depth < decision.targetReadyFrames;
const auto now = std::chrono::steady_clock::now();
if (!belowTargetDepth &&
mLastOutputProductionTime != std::chrono::steady_clock::time_point() &&
now - mLastOutputProductionTime < OutputProducerWakeInterval())
{
continue;
}
const std::size_t producedFrames = ProduceReadyOutputFrames(completion, decision.requestedFrames);
const std::size_t producedFrames = ProduceReadyOutputFrames(completion, 1);
if (producedFrames > 0)
{
mLastOutputProductionTime = std::chrono::steady_clock::now();
@@ -513,7 +584,6 @@ void VideoBackend::ProcessOutputFrameCompletion(const VideoIOCompletion& complet
}
NotifyOutputProducer();
NotifyOutputProducer();
RecordBackendPlayoutHealth(completion.result, recoveryDecision);
RecordSystemMemoryPlayoutStats();
}
@@ -601,10 +671,6 @@ std::size_t VideoBackend::ProduceReadyOutputFrames(const VideoIOCompletion& comp
std::size_t producedFrames = 0;
while (producedFrames < maxFrames)
{
const OutputProductionDecision decision = mOutputProductionController.Decide(BuildOutputProductionPressure(metrics));
if (decision.action != OutputProductionAction::Produce)
break;
if (!RenderReadyOutputFrame(mVideoIODevice->State(), completion))
break;
++producedFrames;
@@ -635,7 +701,10 @@ bool VideoBackend::RenderReadyOutputFrame(const VideoIOState& state, const Video
VideoIOOutputFrame outputFrame;
const auto acquireStart = std::chrono::steady_clock::now();
if (!mSystemOutputFramePool.AcquireFreeSlot(outputSlot))
{
if (!mReadyOutputQueue.DropOldestFrame() || !mSystemOutputFramePool.AcquireFreeSlot(outputSlot))
return false;
}
outputFrame = outputSlot.frame;
const auto acquireEnd = std::chrono::steady_clock::now();
@@ -702,10 +771,12 @@ bool VideoBackend::ScheduleReadyOutputFrame()
if (!ScheduleOutputFrame(readyFrame.frame))
{
RecordDeckLinkBufferTelemetry();
mSystemOutputFramePool.ReleaseSlotByBuffer(readyFrame.frame.bytes);
return false;
}
RecordDeckLinkBufferTelemetry();
PublishOutputFrameScheduled(readyFrame.frame);
RecordSystemMemoryPlayoutStats();
return true;
@@ -726,10 +797,12 @@ bool VideoBackend::ScheduleBlackUnderrunFrame()
if (!ScheduleOutputFrame(outputFrame))
{
RecordDeckLinkBufferTelemetry();
ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: black fallback frame scheduling failed.");
return false;
}
RecordDeckLinkBufferTelemetry();
ApplyLifecycleTransition(VideoBackendLifecycleState::Degraded, "Output underrun: scheduled black fallback frame.");
PublishOutputFrameScheduled(outputFrame);
return true;
@@ -787,10 +860,25 @@ void VideoBackend::RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& m
++mReadyQueueZeroDepthCount;
}
void VideoBackend::RecordDeckLinkBufferTelemetry()
{
if (!mVideoIODevice)
return;
const VideoIOState& state = mVideoIODevice->State();
mHealthTelemetry.TryRecordDeckLinkBufferTelemetry(
state.actualDeckLinkBufferedFramesAvailable,
state.actualDeckLinkBufferedFrames,
static_cast<std::size_t>(mPlayoutPolicy.targetPrerollFrames),
state.deckLinkScheduleCallMilliseconds,
state.deckLinkScheduleFailureCount);
}
void VideoBackend::RecordSystemMemoryPlayoutStats()
{
const SystemOutputFramePoolMetrics poolMetrics = mSystemOutputFramePool.GetMetrics();
const RenderOutputQueueMetrics queueMetrics = mReadyOutputQueue.GetMetrics();
RecordDeckLinkBufferTelemetry();
mHealthTelemetry.TryRecordSystemMemoryPlayoutStats(
poolMetrics.freeCount,
poolMetrics.readyCount,
@@ -990,3 +1078,18 @@ std::string VideoBackend::PixelFormatName(VideoIOPixelFormat pixelFormat)
{
return std::string(VideoIOPixelFormatName(pixelFormat));
}
bool VideoBackend::IsEnvironmentFlagEnabled(const char* name)
{
if (name == nullptr || name[0] == '\0')
return false;
char* value = nullptr;
std::size_t valueSize = 0;
if (_dupenv_s(&value, &valueSize, name) != 0 || value == nullptr)
return false;
const std::string flag(value);
std::free(value);
return flag == "1" || flag == "true" || flag == "TRUE" || flag == "yes" || flag == "on";
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include "OutputProductionController.h"
#include "RenderCadenceController.h"
#include "RenderOutputQueue.h"
#include "SystemOutputFramePool.h"
#include "VideoBackendLifecycle.h"
@@ -76,6 +77,7 @@ private:
void StopOutputProducerWorker();
void OutputProducerWorkerMain();
void NotifyOutputProducer();
bool WarmupOutputPreroll();
std::chrono::milliseconds OutputProducerWakeInterval() const;
void ProcessOutputFrameCompletion(const VideoIOCompletion& completion);
std::size_t ProduceReadyOutputFrames(const VideoIOCompletion& completion, std::size_t maxFrames);
@@ -86,6 +88,7 @@ private:
bool ScheduleBlackUnderrunFrame();
void RecordFramePacing(VideoIOCompletionResult completionResult);
void RecordReadyQueueDepthSample(const RenderOutputQueueMetrics& metrics);
void RecordDeckLinkBufferTelemetry();
void RecordSystemMemoryPlayoutStats();
void RecordOutputRenderDuration(double renderMilliseconds, double acquireMilliseconds, double renderRequestMilliseconds, double endAccessMilliseconds);
bool ApplyLifecycleTransition(VideoBackendLifecycleState state, const std::string& message);
@@ -98,12 +101,14 @@ private:
void PublishTimingSample(const std::string& subsystem, const std::string& metric, double value, const std::string& unit);
static std::string CompletionResultName(VideoIOCompletionResult result);
static std::string PixelFormatName(VideoIOPixelFormat pixelFormat);
static bool IsEnvironmentFlagEnabled(const char* name);
HealthTelemetry& mHealthTelemetry;
RuntimeEventDispatcher& mRuntimeEventDispatcher;
VideoBackendLifecycle mLifecycle;
VideoPlayoutPolicy mPlayoutPolicy;
OutputProductionController mOutputProductionController;
RenderCadenceController mRenderCadenceController;
RenderOutputQueue mReadyOutputQueue;
SystemOutputFramePool mSystemOutputFramePool;
std::unique_ptr<VideoIODevice> mVideoIODevice;
@@ -124,6 +129,7 @@ private:
bool mOutputCompletionWorkerStopping = false;
bool mOutputProducerWorkerRunning = false;
bool mOutputProducerWorkerStopping = false;
bool mInputCaptureDisabled = false;
uint64_t mNextReadyOutputFrameIndex = 0;
uint64_t mInputFrameIndex = 0;
uint64_t mOutputFrameScheduleIndex = 0;

View File

@@ -50,6 +50,16 @@ struct VideoIOState
bool keyerInterfaceAvailable = false;
bool externalKeyingActive = false;
double frameBudgetMilliseconds = 0.0;
bool actualDeckLinkBufferedFramesAvailable = false;
uint64_t actualDeckLinkBufferedFrames = 0;
double deckLinkScheduleCallMilliseconds = 0.0;
uint64_t deckLinkScheduleFailureCount = 0;
bool deckLinkScheduleLeadAvailable = false;
int64_t deckLinkPlaybackStreamTime = 0;
uint64_t deckLinkPlaybackFrameIndex = 0;
uint64_t deckLinkNextScheduleFrameIndex = 0;
int64_t deckLinkScheduleLeadFrames = 0;
uint64_t deckLinkScheduleRealignmentCount = 0;
};
struct VideoIOFrame
@@ -112,6 +122,9 @@ public:
virtual bool SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error) = 0;
virtual bool ConfigureInput(InputFrameCallback callback, const VideoFormat& inputVideoMode, std::string& error) = 0;
virtual bool ConfigureOutput(OutputFrameCallback callback, const VideoFormat& outputVideoMode, bool externalKeyingEnabled, std::string& error) = 0;
virtual bool PrepareOutputSchedule() = 0;
virtual bool StartInputStreams() = 0;
virtual bool StartScheduledPlayback() = 0;
virtual bool Start() = 0;
virtual bool Stop() = 0;
virtual const VideoIOState& State() const = 0;

View File

@@ -32,6 +32,17 @@ VideoIOScheduleTime VideoPlayoutScheduler::NextScheduleTime()
return time;
}
void VideoPlayoutScheduler::AlignNextScheduleTimeToPlayback(int64_t streamTime, uint64_t leadFrames)
{
if (mFrameDuration <= 0 || streamTime < 0)
return;
const uint64_t playbackFrameIndex = static_cast<uint64_t>(streamTime / mFrameDuration);
const uint64_t minimumScheduleIndex = playbackFrameIndex + leadFrames;
if (minimumScheduleIndex > mScheduledFrameIndex)
mScheduledFrameIndex = minimumScheduleIndex;
}
VideoPlayoutRecoveryDecision VideoPlayoutScheduler::AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth)
{
++mCompletedFrameIndex;

View File

@@ -12,10 +12,12 @@ public:
void Configure(int64_t frameDuration, int64_t timeScale, const VideoPlayoutPolicy& policy);
void Reset();
VideoIOScheduleTime NextScheduleTime();
void AlignNextScheduleTimeToPlayback(int64_t streamTime, uint64_t leadFrames);
VideoPlayoutRecoveryDecision AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth = 0);
double FrameBudgetMilliseconds() const;
uint64_t ScheduledFrameIndex() const { return mScheduledFrameIndex; }
uint64_t CompletedFrameIndex() const { return mCompletedFrameIndex; }
int64_t FrameDuration() const { return mFrameDuration; }
uint64_t LateStreak() const { return mLateStreak; }
uint64_t DropStreak() const { return mDropStreak; }
int64_t TimeScale() const { return mTimeScale; }

View File

@@ -2,6 +2,7 @@
#include <atlbase.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <new>
@@ -11,6 +12,9 @@
namespace
{
constexpr int64_t kMinimumHealthyScheduleLeadFrames = 4;
constexpr int64_t kProactiveScheduleLeadFloorFrames = 1;
class SystemMemoryDeckLinkVideoBuffer : public IDeckLinkVideoBuffer
{
public:
@@ -525,9 +529,90 @@ bool DeckLinkSession::PopulateOutputFrame(IDeckLinkMutableVideoFrame* outputVide
bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame)
{
if (outputVideoFrame == nullptr || output == nullptr)
{
++mState.deckLinkScheduleFailureCount;
return false;
}
if (mScheduleRealignmentPending)
{
RealignScheduleCursorToPlayback();
mScheduleRealignmentPending = false;
}
UpdateScheduleLeadTelemetry();
MaybeRealignScheduleCursorForLowLead();
const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime();
return outputVideoFrame != nullptr &&
output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale) == S_OK;
const auto scheduleStart = std::chrono::steady_clock::now();
const HRESULT result = output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale);
const auto scheduleEnd = std::chrono::steady_clock::now();
mState.deckLinkScheduleCallMilliseconds = std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(scheduleEnd - scheduleStart).count();
if (result != S_OK)
++mState.deckLinkScheduleFailureCount;
RefreshBufferedVideoFrameCount();
return result == S_OK;
}
void DeckLinkSession::UpdateScheduleLeadTelemetry()
{
if (output == nullptr)
{
mState.deckLinkScheduleLeadAvailable = false;
return;
}
BMDTimeValue streamTime = 0;
double playbackSpeed = 0.0;
if (output->GetScheduledStreamTime(mScheduler.TimeScale(), &streamTime, &playbackSpeed) != S_OK || playbackSpeed <= 0.0)
{
mState.deckLinkScheduleLeadAvailable = false;
return;
}
const uint64_t playbackFrameIndex = streamTime >= 0 && mScheduler.FrameDuration() > 0
? static_cast<uint64_t>(streamTime / mScheduler.FrameDuration())
: 0;
const uint64_t nextScheduleFrameIndex = mScheduler.ScheduledFrameIndex();
mState.deckLinkScheduleLeadAvailable = true;
mState.deckLinkPlaybackStreamTime = streamTime;
mState.deckLinkPlaybackFrameIndex = playbackFrameIndex;
mState.deckLinkNextScheduleFrameIndex = nextScheduleFrameIndex;
mState.deckLinkScheduleLeadFrames = static_cast<int64_t>(nextScheduleFrameIndex) - static_cast<int64_t>(playbackFrameIndex);
}
void DeckLinkSession::MaybeRealignScheduleCursorForLowLead()
{
if (!mState.deckLinkScheduleLeadAvailable)
return;
if (mState.deckLinkScheduleLeadFrames >= kMinimumHealthyScheduleLeadFrames)
{
mProactiveScheduleRealignmentArmed = true;
return;
}
if (!mProactiveScheduleRealignmentArmed || mState.deckLinkScheduleLeadFrames > kProactiveScheduleLeadFloorFrames)
return;
RealignScheduleCursorToPlayback();
mProactiveScheduleRealignmentArmed = false;
}
void DeckLinkSession::RealignScheduleCursorToPlayback()
{
if (output == nullptr)
return;
BMDTimeValue streamTime = 0;
double playbackSpeed = 0.0;
if (output->GetScheduledStreamTime(mScheduler.TimeScale(), &streamTime, &playbackSpeed) != S_OK || playbackSpeed <= 0.0)
return;
const VideoPlayoutPolicy policy = NormalizeVideoPlayoutPolicy(mPlayoutPolicy);
mScheduler.AlignNextScheduleTimeToPlayback(streamTime, policy.targetPrerollFrames);
++mState.deckLinkScheduleRealignmentCount;
UpdateScheduleLeadTelemetry();
}
bool DeckLinkSession::ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame)
@@ -592,6 +677,26 @@ bool DeckLinkSession::ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideo
return ScheduleFrame(outputVideoFrame);
}
void DeckLinkSession::RefreshBufferedVideoFrameCount()
{
if (output == nullptr)
{
mState.actualDeckLinkBufferedFramesAvailable = false;
return;
}
unsigned int bufferedFrameCount = 0;
if (output->GetBufferedVideoFrameCount(&bufferedFrameCount) == S_OK)
{
mState.actualDeckLinkBufferedFrames = bufferedFrameCount;
mState.actualDeckLinkBufferedFramesAvailable = true;
}
else
{
mState.actualDeckLinkBufferedFramesAvailable = false;
}
}
bool DeckLinkSession::BeginOutputFrame(VideoIOOutputFrame& frame)
{
CComPtr<IDeckLinkMutableVideoFrame> outputVideoFrame;
@@ -627,9 +732,45 @@ bool DeckLinkSession::ScheduleOutputFrame(const VideoIOOutputFrame& frame)
return scheduled;
}
bool DeckLinkSession::Start()
bool DeckLinkSession::PrepareOutputSchedule()
{
mScheduler.Reset();
RefreshBufferedVideoFrameCount();
return output != nullptr;
}
bool DeckLinkSession::StartInputStreams()
{
if (!input)
return true;
if (input->StartStreams() != S_OK)
{
MessageBoxA(NULL, "Could not start the DeckLink input stream.", "DeckLink start failed", MB_OK | MB_ICONERROR);
return false;
}
return true;
}
bool DeckLinkSession::StartScheduledPlayback()
{
if (!output)
{
MessageBoxA(NULL, "Cannot start playout because no DeckLink output device is available.", "DeckLink start failed", MB_OK | MB_ICONERROR);
return false;
}
if (output->StartScheduledPlayback(0, mScheduler.TimeScale(), 1.0) != S_OK)
{
MessageBoxA(NULL, "Could not start DeckLink scheduled playback.", "DeckLink start failed", MB_OK | MB_ICONERROR);
return false;
}
RefreshBufferedVideoFrameCount();
return true;
}
bool DeckLinkSession::Start()
{
if (!output)
{
MessageBoxA(NULL, "Cannot start playout because no DeckLink output device is available.", "DeckLink start failed", MB_OK | MB_ICONERROR);
@@ -643,6 +784,9 @@ bool DeckLinkSession::Start()
const VideoPlayoutPolicy policy = NormalizeVideoPlayoutPolicy(mPlayoutPolicy);
mPlayoutPolicy = policy;
if (!PrepareOutputSchedule())
return false;
for (unsigned i = 0; i < policy.targetPrerollFrames; i++)
{
CComPtr<IDeckLinkMutableVideoFrame> outputVideoFrame;
@@ -658,21 +802,7 @@ bool DeckLinkSession::Start()
}
}
if (input)
{
if (input->StartStreams() != S_OK)
{
MessageBoxA(NULL, "Could not start the DeckLink input stream.", "DeckLink start failed", MB_OK | MB_ICONERROR);
return false;
}
}
if (output->StartScheduledPlayback(0, mScheduler.TimeScale(), 1.0) != S_OK)
{
MessageBoxA(NULL, "Could not start DeckLink scheduled playback.", "DeckLink start failed", MB_OK | MB_ICONERROR);
return false;
}
return true;
return StartInputStreams() && StartScheduledPlayback();
}
bool DeckLinkSession::Stop()
@@ -736,6 +866,8 @@ void DeckLinkSession::HandleVideoInputFrame(IDeckLinkVideoInputFrame* inputFrame
void DeckLinkSession::HandlePlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame, BMDOutputFrameCompletionResult completionResult)
{
RefreshBufferedVideoFrameCount();
void* completedSystemBuffer = nullptr;
if (completedFrame != nullptr)
{
@@ -767,6 +899,18 @@ void DeckLinkSession::HandlePlayoutFrameCompleted(IDeckLinkVideoFrame* completed
VideoIOCompletion completion;
completion.result = TranslateCompletionResult(completionResult);
if (completion.result == VideoIOCompletionResult::DisplayedLate || completion.result == VideoIOCompletionResult::Dropped)
{
if (mScheduleRealignmentArmed)
{
mScheduleRealignmentPending = true;
mScheduleRealignmentArmed = false;
}
}
else if (completion.result == VideoIOCompletionResult::Completed)
{
mScheduleRealignmentArmed = true;
}
completion.outputFrameBuffer = completedSystemBuffer;
mOutputFrameCallback(completion);
}

View File

@@ -28,6 +28,9 @@ public:
bool SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error) override;
bool ConfigureInput(InputFrameCallback callback, const VideoFormat& inputVideoMode, std::string& error) override;
bool ConfigureOutput(OutputFrameCallback callback, const VideoFormat& outputVideoMode, bool externalKeyingEnabled, std::string& error) override;
bool PrepareOutputSchedule() override;
bool StartInputStreams() override;
bool StartScheduledPlayback() override;
bool Start() override;
bool Stop() override;
@@ -72,8 +75,12 @@ private:
bool AcquireNextOutputVideoFrame(CComPtr<IDeckLinkMutableVideoFrame>& outputVideoFrame);
bool PopulateOutputFrame(IDeckLinkMutableVideoFrame* outputVideoFrame, VideoIOOutputFrame& frame);
bool ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame);
void UpdateScheduleLeadTelemetry();
void MaybeRealignScheduleCursorForLowLead();
void RealignScheduleCursorToPlayback();
bool ScheduleSystemMemoryFrame(const VideoIOOutputFrame& frame);
bool ScheduleBlackFrame(IDeckLinkMutableVideoFrame* outputVideoFrame);
void RefreshBufferedVideoFrameCount();
static VideoIOCompletionResult TranslateCompletionResult(BMDOutputFrameCompletionResult completionResult);
CComPtr<CaptureDelegate> captureDelegate;
@@ -87,6 +94,9 @@ private:
VideoIOState mState;
VideoPlayoutPolicy mPlayoutPolicy;
VideoPlayoutScheduler mScheduler;
bool mScheduleRealignmentPending = false;
bool mScheduleRealignmentArmed = true;
bool mProactiveScheduleRealignmentArmed = true;
InputFrameCallback mInputFrameCallback;
OutputFrameCallback mOutputFrameCallback;
};

View File

@@ -0,0 +1,442 @@
# RenderCadenceCompositor
This app is the modular version of the working DeckLink render-cadence probe.
Its job is to prove the production-facing foundation before the current compositor's shader/runtime/control features are ported over.
Before adding features here, read the guardrails in [Render Cadence Golden Rules](../../docs/RENDER_CADENCE_GOLDEN_RULES.md).
## Architecture
```text
RenderThread
owns a hidden OpenGL context
polls the oldest ready input frame without waiting
uploads input frames into a render-owned GL texture
renders simple BGRA8 motion at selected cadence
queues async PBO readback
publishes completed frames into SystemFrameExchange
InputFrameMailbox
owns bounded FIFO CPU input slots
keeps a bounded three-ready-frame input buffer for render
trims frames beyond that bound to avoid runaway input latency
protects the one frame currently being uploaded by render
uses a single contiguous copy when capture row stride matches mailbox row stride
SystemFrameExchange
owns Free / Rendering / Completed / Scheduled slots
preserves completed output frames as a bounded FIFO reserve once they are waiting for playout
protects scheduled frames until DeckLink completion
DeckLinkOutputThread
consumes completed system-memory frames
schedules them into DeckLink up to target depth
never renders
```
Startup builds a small output preroll reserve before DeckLink scheduled playback starts. When DeckLink input is available, startup also waits briefly for three ready input frames before the render thread starts so the first render ticks are deliberate rather than lucky.
## Current Scope
Included now:
- output-only DeckLink
- optional DeckLink input edge with BGRA8 capture or raw UYVY8 capture decoded on the render thread
- non-blocking startup when DeckLink output is unavailable
- hidden render-thread-owned OpenGL context
- simple smooth-motion renderer
- BGRA8-only output
- non-blocking three-frame FIFO input mailbox for render
- fast contiguous mailbox copy path for matching input row strides
- bounded three-frame input warmup before render cadence starts
- render-thread-owned input texture upload
- async PBO readback
- bounded FIFO system-memory frame exchange
- bounded completed-frame output preroll reserve before DeckLink playback, with DeckLink scheduled depth still targeted at four
- conservative DeckLink schedule-lead telemetry and recovery
- 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
- 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
- manifest-driven stateless named-pass shader packages
- atomic render-plan swap after every pass program is prepared
- HTTP shader list populated from supported stateless full-frame shader packages
- default float, vec2, color, boolean, enum, and trigger parameters
- small JSON writer for future HTTP/WebSocket payloads
- JSON serialization for cadence telemetry snapshots
- background logging with `log`, `warning`, and `error` levels
- local HTTP control server matching the OpenAPI route surface
- HTTP layer controls for add, remove, reorder, bypass, shader change, parameter update, and parameter reset
- trigger parameters as latest-pulse controls with shader-visible count/time
- startup config provider for `config/runtime-host.json`
- quiet telemetry health monitor
- non-GL frame-exchange tests
- non-GL input-mailbox tests
Intentionally not included yet:
- additional input format conversion/scaling
- temporal/history/feedback shader storage
- texture/LUT asset upload
- text-parameter rasterization
- runtime state
- OSC control
- persistent control/state writes
- trigger event history for stacked repeated pulses
- preview
- screenshots
- persistence
Those features should be ported only after the cadence spine is stable.
## V1 Feature Parity Checklist
This tracks parity with `apps/LoopThroughWithOpenGLCompositing`.
- [x] Stable DeckLink output cadence
- [x] BGRA8 system-memory output path
- [x] Render thread owns its primary GL context
- [x] Output startup warmup before scheduled playback
- [x] Non-blocking startup when DeckLink output is unavailable
- [x] Runtime shader package discovery
- [x] Background Slang shader compile
- [x] Shared-context GL shader/program preparation
- [x] Render-thread program swap at a frame boundary
- [x] Stateless single-pass shader rendering
- [x] Stateless named-pass shader rendering
- [x] Atomic multipass render-plan commit
- [x] Shader add/remove control path
- [x] Previous-layer texture handoff for stacked shaders
- [x] Supported shader list in HTTP/UI state
- [x] Local HTTP server
- [x] WebSocket state updates for the UI
- [x] OpenAPI document serving
- [x] Static control UI serving
- [x] Startup config loading from `config/runtime-host.json`
- [x] Cadence telemetry JSON
- [x] Health logging for schedule/drop/starvation events
- [x] Runtime parameter updates from HTTP controls
- [x] Layer reorder/bypass/set-shader/update-parameter/reset-parameter HTTP controls
- [x] Trigger parameter pulse count/time for latest trigger events
- [x] Optional DeckLink input capture
- [x] UYVY8 input capture with render-thread GPU decode to shader input texture
- [x] Three-frame FIFO CPU input mailbox for render
- [x] Fast contiguous input mailbox copy when source/destination stride matches
- [x] Bounded three-frame input warmup before render cadence starts
- [x] Render-owned input texture upload
- [x] Runtime shaders receive input through `gVideoInput`
- [x] Live DeckLink input bound to `gVideoInput`
- [ ] Input format conversion/scaling
- [ ] Temporal history buffers
- [ ] Feedback buffers
- [ ] Texture asset loading and upload
- [ ] LUT asset loading and upload
- [ ] Text parameter rasterization
- [ ] Trigger history/event buffers for overlapping repeated trigger effects
- [ ] Full runtime state store/read model
- [ ] Persistent layer stack/config writes
- [ ] OSC ingress
- [ ] Preview output
- [ ] Screenshot capture
- [ ] External keying support
- [ ] Full V1 health/runtime presentation model
## Build
```powershell
cmake --build --preset build-debug --target RenderCadenceCompositor -- /m:1
```
The executable is:
```text
build\vs2022-x64-debug\Debug\RenderCadenceCompositor.exe
```
## Run
Run from VS Code with:
```text
Debug RenderCadenceCompositor
```
Or from a terminal:
```powershell
build\vs2022-x64-debug\Debug\RenderCadenceCompositor.exe
```
Press Enter to stop.
To test a different compatible shader package:
```powershell
build\vs2022-x64-debug\Debug\RenderCadenceCompositor.exe --shader solid-color
```
Use `--no-shader` to keep the simple motion fallback only.
## Startup Config
On startup the app loads `config/runtime-host.json` through `AppConfigProvider`, then applies explicit CLI overrides.
Currently consumed fields:
- `serverPort`
- `shaderLibrary`
- `oscBindAddress`
- `oscPort`
- `oscSmoothing`
- `inputVideoFormat`
- `inputFrameRate`
- `outputVideoFormat`
- `outputFrameRate`
- `autoReload`
- `maxTemporalHistoryFrames`
- `previewFps`
- `enableExternalKeying`
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.
Supported CLI overrides:
- `--shader <shader-id>`
- `--no-shader`
- `--port <port>`
## Expected Telemetry
Startup, shutdown, shader-build, and render-thread event messages are written through the app logger. Telemetry is intentionally separate and remains a compact once-per-second cadence line.
The logger writes to the console, `OutputDebugStringA`, and `logs/render-cadence-compositor.log` by default. Render-thread log calls use the non-blocking path so diagnostics do not become cadence blockers.
## HTTP Control Server
The app starts a local HTTP control server on `http://127.0.0.1:8080` by default, searching nearby ports if that one is busy.
Current endpoints:
- `GET /` and UI asset paths: serve the bundled control UI from `ui/dist`
- `GET /api/state`: returns OpenAPI-shaped display data with cadence telemetry, supported shaders, output status, and a read-only current runtime layer
- `GET /ws`: upgrades to a WebSocket and streams state snapshots when they change
- `GET /docs/openapi.yaml` and `GET /openapi.yaml`: serves the OpenAPI document
- `GET /docs`: serves Swagger UI
- `POST /api/layers/add`, `/remove`, `/reorder`, `/set-bypass`, `/set-shader`, `/update-parameter`, and `/reset-parameters` use the shared runtime control-command path
- other OpenAPI POST routes are present but return `{ "ok": false, "error": "Endpoint is not implemented in RenderCadenceCompositor yet." }`
The HTTP server runs on its own thread. It serves static UI/docs files, samples/copies telemetry through callbacks, and translates POST bodies into runtime control commands. Command execution is app-owned, so future OSC ingress can create the same commands without depending on HTTP route code. Control commands may update the display layer model, start background shader builds, or publish an already-built render-layer snapshot, but they do not call render work or DeckLink scheduling directly.
## Optional DeckLink Output
DeckLink output is an optional edge service in this app.
Startup order is:
1. start render thread
2. build a bounded completed-frame output preroll reserve at normal render cadence
3. try to attach DeckLink output
4. start telemetry and HTTP either way
If DeckLink discovery or output setup fails, the app logs a warning and continues running without starting the output scheduler or scheduled playback. This keeps render cadence, runtime shader testing, HTTP state, and logging available on machines without DeckLink hardware or drivers.
`/api/state` reports the output status in `videoIO.statusMessage`.
## Optional DeckLink Input
DeckLink input is an optional edge service in this app.
Startup order is:
1. create `InputFrameMailbox`
2. try to attach DeckLink input for the configured input mode
3. prefer BGRA8 capture, otherwise accept raw UYVY8 capture and configure the mailbox for UYVY8 bytes
4. start `DeckLinkInputThread`
5. wait briefly for three ready input warmup frames before starting render cadence
6. leave input absent if discovery, setup, format support, or stream startup fails
`DeckLinkInput` and `DeckLinkInputThread` are deliberately narrow. They capture BGRA8 frames directly or raw UYVY8 frames into `InputFrameMailbox`; they do not call GL, render, preview, screenshot, shader, or output scheduling code. UYVY8-to-RGBA decode happens later inside the render-thread-owned input texture upload path, so the DeckLink callback stays a capture/copy edge only. The render upload path consumes the oldest ready input frame from a bounded three-ready-frame queue, so the input behaves like a small jitter buffer instead of a latest-frame preview mailbox. The mailbox trims older frames beyond that bound to avoid runaway latency, uses one contiguous copy when the capture row stride matches the configured mailbox row stride, and falls back to row-by-row copy only for padded or mismatched frames. Unsupported input modes or formats outside BGRA8/UYVY8 are reported explicitly and treated as an unavailable edge rather than silently converted.
Input warmup is startup-only and bounded. It may delay render-thread startup for a short window, but it does not add waits to the steady-state render cadence loop.
The app samples telemetry once per second.
Normal cadence samples are available through `GET /api/state` and are not printed to the console. The telemetry monitor only logs health events:
- warning when DeckLink late/dropped-frame counters increase, including schedule lead and recovery count
- warning when schedule failures increase
- error when the app/DeckLink output buffer is starved
Render cadence telemetry:
- `clockOverruns`: render cadence overruns where missed time was detected
- `clockSkippedFrames`: selected-cadence frame intervals skipped instead of catch-up rendering
- `clockOveruns` / `clockSkipped`: compatibility aliases for quick polling scripts
Input telemetry:
- `inputFramesReceived`: frames accepted into `InputFrameMailbox`
- `inputFramesDropped`: ready input frames dropped or missed because the mailbox was full
- `renderFrameMs`: most recent render-thread draw duration, excluding completed-readback copy and readback queue work
- `renderFrameBudgetUsedPercent`: most recent render draw time as a percentage of the selected frame budget
- `renderFrameMaxMs`: maximum observed render-thread draw duration for this process
- `readbackQueueMs`: time spent queueing the most recent async BGRA8 PBO readback
- `completedReadbackCopyMs`: time spent mapping/copying the most recent completed readback into system-memory frame storage
- `completedDrops`: oldest completed unscheduled system-memory frames dropped because the bounded completed reserve overflowed; this is an app-side reserve drop, not a DeckLink dropped-frame report
- `acquireMisses`: times render/readback could not acquire a writable system-memory frame slot; completed frames waiting for playout are preserved instead of being displaced
- `deckLinkScheduleLeadAvailable`: whether DeckLink schedule-lead telemetry was available for the latest sample
- `deckLinkScheduleLeadFrames`: estimated distance between the DeckLink playback cursor and the next scheduled stream time
- `deckLinkPlaybackFrameIndex`: latest sampled DeckLink playback frame index
- `deckLinkNextScheduleFrameIndex`: next stream frame index the app intends to schedule
- `deckLinkPlaybackStreamTime`: latest sampled DeckLink playback stream time in DeckLink time units
- `deckLinkScheduleRealignments`: conservative schedule-cursor recoveries triggered by late/drop pressure or dangerously low lead
- `inputConsumeMisses`: render ticks where no ready input frame was available to upload
- `inputUploadMisses`: input texture upload attempts that reused the previous GL input texture
- `inputReadyFrames`: ready input frames currently queued in `InputFrameMailbox`
- `inputReadingFrames`: input frames currently protected while render uploads them
- `inputLatestAgeMs`: age of the newest submitted input frame
- `inputUploadMs`: render-thread GL upload/decode submission time for the latest uploaded input frame
- `inputFormatSupported`: whether the latest frame reaching the render upload path was BGRA8 or UYVY8 compatible
- `inputSignalPresent`: whether any input frame has reached the mailbox
- `inputCaptureFps`: DeckLink input callback capture rate
- `inputConvertMs`: input-edge CPU conversion time; expected to remain `0` for BGRA8 and raw UYVY8 capture because UYVY8 decode is render-thread GPU work
- `inputSubmitMs`: time spent copying/submitting the latest captured input frame to `InputFrameMailbox`
- `inputCaptureFormat`: selected DeckLink input format (`BGRA8`, `UYVY8`, or `none`)
- `inputNoSignalFrames`: DeckLink callbacks reporting no input source
- `inputUnsupportedFrames`: input frames rejected before mailbox submission
- `inputSubmitMisses`: input frames that could not be submitted to the mailbox
Runtime shaders continue rendering when input is missing. If no mailbox frame has been uploaded yet, shader samplers use the runtime fallback source texture; once DeckLink input is flowing, shaders such as CRT and trigger-ripple sample the current input through `gVideoInput`.
Healthy first-run signs:
- visible DeckLink output is smooth
- `renderFps` is close to the selected cadence
- `scheduleFps` is close to the selected cadence after warmup
- `scheduled` stays near 4
- `decklinkBuffered` stays near 4 when available
- `deckLinkScheduleLeadFrames` remains positive and stable when available
- `deckLinkScheduleRealignments` does not increase continuously
- `late` and `dropped` do not increase continuously
- `scheduleFailures` does not increase
- `shaderCommitted` becomes `1` after the background Happy Accident compile completes
- `shaderFailures` remains `0`
`completedPollMisses` means the DeckLink scheduling thread woke up before a completed frame was available. It is not a DeckLink playout underrun by itself. Treat it as healthy polling noise when `scheduled`, `decklinkBuffered`, `late`, `dropped`, and `scheduleFailures` remain stable.
## Runtime Slang Shader Test
On startup the app begins compiling the selected shader package on a background thread owned by the app orchestration layer. The default is `shaders/happy-accident`.
The render thread keeps drawing the simple motion renderer while Slang compiles. It does not choose packages, launch Slang, or track build lifecycle. Once a completed shader artifact is published, the render-thread-owned runtime scene queues changed layers to a shared-context GL prepare worker. That worker compiles/links runtime shader programs off the cadence thread. The render thread only swaps in an already-prepared GL program at a frame boundary. If either the Slang build or GL preparation fails, the app keeps rendering the current renderer or simple motion fallback.
Current runtime shader support is deliberately limited to stateless full-frame packages:
- one or more named passes
- one sampled source input per pass
- named intermediate outputs routed by the pass manifest
- final visible output must be named `layerOutput`
- no temporal history
- no feedback storage
- no texture/LUT assets yet
- no text parameters yet
- manifest defaults initialize parameters
- HTTP controls can update runtime parameter values without rebuilding GL programs when the shader program is unchanged
- trigger parameters are treated as latest-pulse controls: each press increments the trigger count and records the current runtime time
- repeated trigger history is not stored yet, so effects such as `trigger-ripple` restart from the latest trigger rather than accumulating overlapping ripples
- the first layer receives a small fallback source texture until DeckLink input is available
- the first layer receives the latest DeckLink input texture through both `gVideoInput` and `gLayerInput` when input frames are available
- stacked layers receive the original input through `gVideoInput` and the previous ready layer output through `gLayerInput`
Shader source semantics:
- `gVideoInput` means the latest decoded shader-visible video input for every layer.
- `gLayerInput` means the previous layer output.
- the first layer may receive `gLayerInput = gVideoInput`.
- later layers receive `gVideoInput = original input` and `gLayerInput = previous layer`.
- named intermediate pass inputs inside a multipass layer are still routed through the selected pass-source slot; layer stacking should use `gLayerInput`.
The `/api/state` shader list uses the same support rules as runtime shader compilation and reports only packages this app can run today. Unsupported manifest feature sets such as temporal, feedback, texture-backed, font-backed, or text-parameter shaders are hidden from the control UI for now.
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.
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.
Successful handoff signs:
- telemetry shows `shaderCommitted=1`
- output changes from the simple motion pattern to the Happy Accident shader
- render/schedule cadence remains near 60 fps during and after the handoff
- DeckLink buffer remains stable
## Baseline Result
Date: 2026-05-12
User-visible result:
- output was smooth
- DeckLink held a 4-frame buffer
Representative telemetry:
```text
renderFps=59.9 scheduleFps=59.9 free=8 completed=0 scheduled=4 completedPollMisses=30 scheduleFailures=0 completions=720 late=0 dropped=0 decklinkBuffered=4 scheduleCallMs=1.2
renderFps=59.8 scheduleFps=59.8 free=7 completed=1 scheduled=4 completedPollMisses=36 scheduleFailures=0 completions=1080 late=0 dropped=0 decklinkBuffered=4 scheduleCallMs=4.7
renderFps=59.9 scheduleFps=59.9 free=7 completed=1 scheduled=4 completedPollMisses=86 scheduleFailures=0 completions=1381 late=0 dropped=0 decklinkBuffered=4 scheduleCallMs=2.1
```
Read:
- render cadence and DeckLink schedule cadence both held roughly 60 fps
- app scheduled depth stayed at 4
- actual DeckLink buffered depth stayed at 4
- DeckLink schedule lead remained positive during healthy playback
- no late frames, dropped frames, or schedule failures were observed
- completed poll misses were benign because playout remained fully fed
## Tests
```powershell
cmake --build --preset build-debug --target RenderCadenceCompositorFrameExchangeTests -- /m:1
ctest --test-dir build\vs2022-x64-debug -C Debug -R RenderCadenceCompositorFrameExchangeTests --output-on-failure
```
## Relationship To The Probe
`apps/DeckLinkRenderCadenceProbe` proved the timing model in one compact file.
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
- `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 readback and completed-frame publication
- `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/`: app-owned shader layer readiness model, runtime Slang build bridge, and completed artifact handoff
- `control/`: control action results and runtime-state JSON presentation
- `control/http/`: local HTTP API, static UI serving, OpenAPI serving, and WebSocket updates
- `json/`: compact JSON serialization helpers
- `video/`: DeckLink output wrapper and scheduling thread
- `telemetry/`: cadence telemetry
- `telemetry/TelemetryHealthMonitor`: quiet health event logging from telemetry samples
- `app/`: startup/shutdown orchestration
- `app/AppConfigProvider`: startup config loading and CLI overrides
## Next Porting Steps
Only after this app matches the probe's smooth output:
1. replace `SimpleMotionRenderer` with a render-scene interface
2. port shader package rendering
3. port runtime snapshots/live state
4. add control services
5. add preview/screenshot from system-memory frames
6. add scaling and additional input format support after the BGRA8/raw-UYVY8 input edge is stable

View File

@@ -0,0 +1,228 @@
#include "app/AppConfig.h"
#include "app/AppConfigProvider.h"
#include "app/RenderCadenceApp.h"
#include "frames/InputFrameMailbox.h"
#include "frames/SystemFrameExchange.h"
#include "logging/Logger.h"
#include "render/RenderThread.h"
#include "video/DeckLinkInput.h"
#include "video/DeckLinkInputThread.h"
#include "DeckLinkDisplayMode.h"
#include "VideoIOFormat.h"
#include <windows.h>
#include <chrono>
#include <iostream>
#include <sstream>
#include <string>
#include <thread>
namespace
{
constexpr std::size_t kDeckLinkTargetBufferedFrames = 4;
constexpr std::size_t kReadbackDepth = 6;
constexpr std::size_t kWritableOutputReserveFrames = kReadbackDepth + 2;
class ComInitGuard
{
public:
~ComInitGuard()
{
if (mInitialized)
CoUninitialize();
}
bool Initialize()
{
const HRESULT result = CoInitialize(nullptr);
mInitialized = SUCCEEDED(result);
mResult = result;
return mInitialized;
}
HRESULT Result() const { return mResult; }
private:
bool mInitialized = false;
HRESULT mResult = S_OK;
};
bool WaitForInputWarmup(InputFrameMailbox& mailbox, std::size_t targetReadyFrames, std::chrono::milliseconds timeout)
{
const auto start = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - start < timeout)
{
const InputFrameMailboxMetrics metrics = mailbox.Metrics();
if (metrics.readyCount >= targetReadyFrames)
return true;
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return false;
}
}
int main(int argc, char** argv)
{
RenderCadenceCompositor::AppConfigProvider configProvider;
std::string configError;
if (!configProvider.LoadDefault(configError))
{
RenderCadenceCompositor::Logger::Instance().Start(RenderCadenceCompositor::DefaultAppConfig().logging);
RenderCadenceCompositor::LogError("app", "Config load failed: " + configError);
RenderCadenceCompositor::Logger::Instance().Stop();
return 1;
}
configProvider.ApplyCommandLine(argc, argv);
RenderCadenceCompositor::AppConfig appConfig = configProvider.Config();
RenderCadenceCompositor::Logger::Instance().Start(appConfig.logging);
RenderCadenceCompositor::Log(
"app",
"RenderCadenceCompositor starting. Starts render cadence, system-memory exchange, DeckLink scheduled output, and telemetry. Press Enter to stop.");
RenderCadenceCompositor::Log("app", "Loaded config from " + configProvider.SourcePath().string());
ComInitGuard com;
if (!com.Initialize())
{
std::ostringstream message;
message << "COM initialization failed: 0x" << std::hex << com.Result();
RenderCadenceCompositor::LogError("app", message.str());
RenderCadenceCompositor::Logger::Instance().Stop();
return 1;
}
SystemFrameExchangeConfig frameExchangeConfig;
RenderCadenceCompositor::VideoFormatDimensions(
appConfig.outputVideoFormat,
frameExchangeConfig.width,
frameExchangeConfig.height);
frameExchangeConfig.pixelFormat = VideoIOPixelFormat::Bgra8;
frameExchangeConfig.rowBytes = VideoIORowBytes(frameExchangeConfig.pixelFormat, frameExchangeConfig.width);
frameExchangeConfig.capacity =
appConfig.warmupCompletedFrames +
kDeckLinkTargetBufferedFrames +
kWritableOutputReserveFrames;
frameExchangeConfig.maxCompletedFrames = appConfig.warmupCompletedFrames;
SystemFrameExchange frameExchange(frameExchangeConfig);
InputFrameMailboxConfig inputMailboxConfig;
RenderCadenceCompositor::VideoFormatDimensions(
appConfig.inputVideoFormat,
inputMailboxConfig.width,
inputMailboxConfig.height);
inputMailboxConfig.pixelFormat = VideoIOPixelFormat::Bgra8;
inputMailboxConfig.rowBytes = VideoIORowBytes(inputMailboxConfig.pixelFormat, inputMailboxConfig.width);
inputMailboxConfig.capacity = 4;
inputMailboxConfig.maxReadyFrames = 3;
InputFrameMailbox inputMailbox(inputMailboxConfig);
VideoFormat inputVideoMode;
VideoFormat outputVideoMode;
std::string inputVideoModeError;
const bool inputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.inputVideoFormat, appConfig.inputFrameRate, inputVideoMode);
const bool outputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.outputVideoFormat, appConfig.outputFrameRate, outputVideoMode);
if (!inputVideoModeResolved)
{
inputVideoModeError = "Unsupported DeckLink inputVideoFormat/inputFrameRate in config/runtime-host.json: " +
appConfig.inputVideoFormat + " / " + appConfig.inputFrameRate;
RenderCadenceCompositor::LogWarning("app", inputVideoModeError);
}
if (!outputVideoModeResolved)
{
RenderCadenceCompositor::LogWarning(
"app",
"Unsupported DeckLink outputVideoFormat/outputFrameRate in config/runtime-host.json; render cadence will use parsed frame-rate fallback: " +
appConfig.outputVideoFormat + " / " + appConfig.outputFrameRate);
}
else
{
appConfig.deckLink.outputVideoMode = outputVideoMode;
}
RenderCadenceCompositor::DeckLinkInput deckLinkInput(inputMailbox);
RenderCadenceCompositor::DeckLinkInputThread deckLinkInputThread(deckLinkInput);
bool deckLinkInputStarted = false;
if (inputVideoModeResolved)
{
RenderCadenceCompositor::DeckLinkInputConfig deckLinkInputConfig;
deckLinkInputConfig.videoFormat = inputVideoMode;
std::string deckLinkInputError;
if (deckLinkInput.Initialize(deckLinkInputConfig, deckLinkInputError))
{
inputMailboxConfig.pixelFormat = deckLinkInput.CapturePixelFormat();
inputMailboxConfig.rowBytes = VideoIORowBytes(inputMailboxConfig.pixelFormat, inputMailboxConfig.width);
inputMailbox.Configure(inputMailboxConfig);
}
if (deckLinkInput.IsInitialized() && deckLinkInputThread.Start(deckLinkInputError))
{
deckLinkInputStarted = true;
RenderCadenceCompositor::Log("app", "DeckLink input edge started for " + inputVideoMode.displayName + ".");
RenderCadenceCompositor::Log("app", "Waiting for DeckLink input warmup frames.");
constexpr std::size_t kInputStartupBufferedFrames = 3;
constexpr std::chrono::milliseconds kInputWarmupTimeout(1000);
if (WaitForInputWarmup(inputMailbox, kInputStartupBufferedFrames, kInputWarmupTimeout))
{
const InputFrameMailboxMetrics metrics = inputMailbox.Metrics();
RenderCadenceCompositor::Log(
"app",
"DeckLink input warmup complete. ready=" + std::to_string(metrics.readyCount) +
" submitted=" + std::to_string(metrics.submittedFrames) + ".");
}
else
{
const InputFrameMailboxMetrics metrics = inputMailbox.Metrics();
RenderCadenceCompositor::LogWarning(
"app",
"DeckLink input warmup timed out; starting render cadence with current input buffer. ready=" +
std::to_string(metrics.readyCount) + " submitted=" + std::to_string(metrics.submittedFrames) + ".");
}
}
else
{
RenderCadenceCompositor::LogWarning("app", "DeckLink input edge unavailable; runtime shaders will use fallback input until a real input edge is available. " + deckLinkInputError);
deckLinkInput.ReleaseResources();
}
}
else
{
RenderCadenceCompositor::LogWarning("app", "DeckLink input mode was not resolved; runtime shaders will use fallback input until a real input edge is available.");
}
RenderThread::Config renderConfig;
renderConfig.width = frameExchangeConfig.width;
renderConfig.height = frameExchangeConfig.height;
const double fallbackFrameDurationMilliseconds = RenderCadenceCompositor::FrameDurationMillisecondsFromRateString(appConfig.outputFrameRate);
renderConfig.frameDurationMilliseconds = outputVideoModeResolved
? RenderCadenceCompositor::FrameDurationMillisecondsFromDisplayMode(outputVideoMode.displayMode, fallbackFrameDurationMilliseconds)
: fallbackFrameDurationMilliseconds;
renderConfig.pboDepth = kReadbackDepth;
RenderThread renderThread(frameExchange, &inputMailbox, renderConfig);
RenderCadenceCompositor::RenderCadenceApp<RenderThread, SystemFrameExchange> app(renderThread, frameExchange, appConfig);
app.SetDeckLinkInputMetricsProvider([&deckLinkInput]() {
return deckLinkInput.Metrics();
});
std::string error;
if (!app.Start(error))
{
RenderCadenceCompositor::LogError("app", "RenderCadenceCompositor start failed: " + error);
if (deckLinkInputStarted)
deckLinkInputThread.Stop();
RenderCadenceCompositor::Logger::Instance().Stop();
return 1;
}
std::string line;
std::getline(std::cin, line);
app.Stop();
if (deckLinkInputStarted)
deckLinkInputThread.Stop();
RenderCadenceCompositor::Log("app", "RenderCadenceCompositor stopped.");
RenderCadenceCompositor::Logger::Instance().Stop();
return 0;
}

View File

@@ -0,0 +1,39 @@
#include "AppConfig.h"
namespace RenderCadenceCompositor
{
AppConfig DefaultAppConfig()
{
AppConfig config;
config.deckLink.externalKeyingEnabled = false;
config.deckLink.outputAlphaRequired = false;
config.outputThread.targetBufferedFrames = 4;
config.telemetry.interval = std::chrono::seconds(1);
config.logging.minimumLevel = LogLevel::Log;
config.logging.writeToConsole = true;
config.logging.writeToDebugOutput = true;
config.logging.writeToFile = true;
config.logging.filePath = "logs/render-cadence-compositor.log";
config.logging.maxQueuedMessages = 1024;
config.http.preferredPort = 8080;
config.http.portSearchCount = 20;
config.http.idleSleep = std::chrono::milliseconds(10);
config.shaderLibrary = "shaders";
config.oscBindAddress = "0.0.0.0";
config.oscPort = 9000;
config.oscSmoothing = 0.18;
config.inputVideoFormat = "1080p";
config.inputFrameRate = "59.94";
config.outputVideoFormat = "1080p";
config.outputFrameRate = "59.94";
config.autoReload = true;
config.maxTemporalHistoryFrames = 12;
config.previewFps = 30.0;
config.warmupCompletedFrames = 4;
config.warmupTimeout = std::chrono::seconds(3);
config.prerollTimeout = std::chrono::seconds(3);
config.prerollPoll = std::chrono::milliseconds(2);
config.runtimeShaderId = "happy-accident";
return config;
}
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include "../control/http/HttpControlServer.h"
#include "../logging/Logger.h"
#include "../telemetry/TelemetryHealthMonitor.h"
#include "../video/DeckLinkOutput.h"
#include "../video/DeckLinkOutputThread.h"
#include <chrono>
#include <cstddef>
#include <string>
namespace RenderCadenceCompositor
{
struct AppConfig
{
DeckLinkOutputConfig deckLink;
DeckLinkOutputThreadConfig outputThread;
TelemetryHealthMonitorConfig telemetry;
LoggerConfig logging;
HttpControlServerConfig http;
std::string shaderLibrary = "shaders";
std::string oscBindAddress = "0.0.0.0";
unsigned short oscPort = 9000;
double oscSmoothing = 0.18;
std::string inputVideoFormat = "1080p";
std::string inputFrameRate = "59.94";
std::string outputVideoFormat = "1080p";
std::string outputFrameRate = "59.94";
bool autoReload = true;
std::size_t maxTemporalHistoryFrames = 12;
double previewFps = 30.0;
std::size_t warmupCompletedFrames = 4;
std::chrono::milliseconds warmupTimeout = std::chrono::seconds(3);
std::chrono::milliseconds prerollTimeout = std::chrono::seconds(3);
std::chrono::milliseconds prerollPoll = std::chrono::milliseconds(2);
std::string runtimeShaderId = "happy-accident";
};
AppConfig DefaultAppConfig();
}

View File

@@ -0,0 +1,282 @@
#include "AppConfigProvider.h"
#include "RuntimeJson.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <vector>
#include <windows.h>
namespace RenderCadenceCompositor
{
namespace
{
std::filesystem::path ExecutableDirectory()
{
char path[MAX_PATH] = {};
const DWORD length = GetModuleFileNameA(nullptr, path, static_cast<DWORD>(sizeof(path)));
if (length == 0 || length >= sizeof(path))
return std::filesystem::current_path();
return std::filesystem::path(path).parent_path();
}
std::string ReadTextFile(const std::filesystem::path& path, std::string& error)
{
std::ifstream input(path, std::ios::binary);
if (!input)
{
error = "Could not open config file: " + path.string();
return std::string();
}
std::ostringstream buffer;
buffer << input.rdbuf();
return buffer.str();
}
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);
}
}
AppConfigProvider::AppConfigProvider() :
mConfig(DefaultAppConfig())
{
}
bool AppConfigProvider::LoadDefault(std::string& error)
{
const std::filesystem::path path = FindConfigFile();
if (path.empty())
{
error = "Could not locate config/runtime-host.json from current directory or executable directory.";
return false;
}
return Load(path, error);
}
bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& error)
{
mConfig = DefaultAppConfig();
mSourcePath = path;
mLoadedFromFile = false;
std::string fileError;
const std::string text = ReadTextFile(path, fileError);
if (!fileError.empty())
{
error = fileError;
return false;
}
JsonValue root;
std::string parseError;
if (!ParseJson(text, root, parseError) || !root.isObject())
{
error = parseError.empty() ? "Config root must be a JSON object." : parseError;
return false;
}
ApplyString(root, "shaderLibrary", mConfig.shaderLibrary);
ApplyPort(root, "serverPort", mConfig.http.preferredPort);
ApplyString(root, "oscBindAddress", mConfig.oscBindAddress);
ApplyPort(root, "oscPort", mConfig.oscPort);
ApplyDouble(root, "oscSmoothing", mConfig.oscSmoothing);
ApplyString(root, "inputVideoFormat", mConfig.inputVideoFormat);
ApplyString(root, "inputFrameRate", mConfig.inputFrameRate);
ApplyString(root, "outputVideoFormat", mConfig.outputVideoFormat);
ApplyString(root, "outputFrameRate", mConfig.outputFrameRate);
ApplyBool(root, "autoReload", mConfig.autoReload);
ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames);
ApplyDouble(root, "previewFps", mConfig.previewFps);
ApplyBool(root, "enableExternalKeying", mConfig.deckLink.externalKeyingEnabled);
mLoadedFromFile = true;
error.clear();
return true;
}
void AppConfigProvider::ApplyCommandLine(int argc, char** argv)
{
for (int index = 1; index < argc; ++index)
{
const std::string argument = argv[index];
if (argument == "--shader" && index + 1 < argc)
{
mConfig.runtimeShaderId = argv[++index];
continue;
}
if (argument == "--no-shader")
{
mConfig.runtimeShaderId.clear();
continue;
}
if (argument == "--port" && index + 1 < argc)
{
const int port = std::atoi(argv[++index]);
if (port >= 1 && port <= 65535)
mConfig.http.preferredPort = static_cast<unsigned short>(port);
continue;
}
}
}
double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate)
{
double rate = fallbackRate;
try
{
rate = std::stod(rateText);
}
catch (...)
{
rate = fallbackRate;
}
if (rate <= 0.0)
rate = fallbackRate;
return 1000.0 / rate;
}
double FrameDurationMillisecondsFromDisplayMode(BMDDisplayMode displayMode, double fallbackMilliseconds)
{
struct ModeRate
{
BMDDisplayMode mode;
int64_t frameDuration;
int64_t timeScale;
};
static const ModeRate rates[] =
{
{ bmdModeHD720p50, 1, 50 },
{ bmdModeHD720p5994, 1001, 60000 },
{ bmdModeHD720p60, 1, 60 },
{ bmdModeHD1080i50, 1, 25 },
{ bmdModeHD1080i5994, 1001, 30000 },
{ bmdModeHD1080i6000, 1, 30 },
{ bmdModeHD1080p2398, 1001, 24000 },
{ bmdModeHD1080p24, 1, 24 },
{ bmdModeHD1080p25, 1, 25 },
{ bmdModeHD1080p2997, 1001, 30000 },
{ bmdModeHD1080p30, 1, 30 },
{ bmdModeHD1080p50, 1, 50 },
{ bmdModeHD1080p5994, 1001, 60000 },
{ bmdModeHD1080p6000, 1, 60 },
{ bmdMode4K2160p2398, 1001, 24000 },
{ bmdMode4K2160p24, 1, 24 },
{ bmdMode4K2160p25, 1, 25 },
{ bmdMode4K2160p2997, 1001, 30000 },
{ bmdMode4K2160p30, 1, 30 },
{ bmdMode4K2160p50, 1, 50 },
{ bmdMode4K2160p5994, 1001, 60000 },
{ bmdMode4K2160p60, 1, 60 }
};
for (const ModeRate& rate : rates)
{
if (rate.mode == displayMode && rate.timeScale > 0)
return (static_cast<double>(rate.frameDuration) * 1000.0) / static_cast<double>(rate.timeScale);
}
return fallbackMilliseconds;
}
void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height)
{
std::string normalized = formatName;
std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
if (normalized == "720p")
{
width = 1280;
height = 720;
return;
}
if (normalized == "2160p" || normalized == "4k" || normalized == "uhd")
{
width = 3840;
height = 2160;
return;
}
width = 1920;
height = 1080;
}
std::filesystem::path FindConfigFile(const std::filesystem::path& relativePath)
{
return FindRepoPath(relativePath);
}
std::filesystem::path FindRepoPath(const std::filesystem::path& relativePath)
{
std::vector<std::filesystem::path> starts;
starts.push_back(std::filesystem::current_path());
starts.push_back(ExecutableDirectory());
for (std::filesystem::path start : starts)
{
for (;;)
{
const std::filesystem::path candidate = start / relativePath;
if (std::filesystem::exists(candidate))
return candidate;
const std::filesystem::path parent = start.parent_path();
if (parent.empty() || parent == start)
break;
start = parent;
}
}
return std::filesystem::path();
}
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include "AppConfig.h"
#include "DeckLinkDisplayMode.h"
#include <filesystem>
#include <string>
namespace RenderCadenceCompositor
{
class AppConfigProvider
{
public:
AppConfigProvider();
bool Load(const std::filesystem::path& path, std::string& error);
bool LoadDefault(std::string& error);
void ApplyCommandLine(int argc, char** argv);
const AppConfig& Config() const { return mConfig; }
const std::filesystem::path& SourcePath() const { return mSourcePath; }
bool LoadedFromFile() const { return mLoadedFromFile; }
private:
AppConfig mConfig;
std::filesystem::path mSourcePath;
bool mLoadedFromFile = false;
};
double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate = 59.94);
double FrameDurationMillisecondsFromDisplayMode(BMDDisplayMode displayMode, double fallbackMilliseconds);
void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height);
std::filesystem::path FindConfigFile(const std::filesystem::path& relativePath = "config/runtime-host.json");
std::filesystem::path FindRepoPath(const std::filesystem::path& relativePath);
}

View File

@@ -0,0 +1,291 @@
#pragma once
#include "AppConfig.h"
#include "AppConfigProvider.h"
#include "RuntimeLayerController.h"
#include "../logging/Logger.h"
#include "../control/RuntimeStateJson.h"
#include "../telemetry/TelemetryHealthMonitor.h"
#include "../video/DeckLinkInput.h"
#include "../video/DeckLinkOutput.h"
#include "../video/DeckLinkOutputThread.h"
#include <chrono>
#include <filesystem>
#include <functional>
#include <string>
#include <thread>
#include <type_traits>
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 = DefaultAppConfig()) :
mRenderThread(renderThread),
mFrameExchange(frameExchange),
mConfig(config),
mOutputThread(mOutput, mFrameExchange, mConfig.outputThread),
mTelemetryHealth(mConfig.telemetry),
mRuntimeLayers([this](const std::vector<RuntimeRenderLayerModel>& layers) {
mRenderThread.SubmitRuntimeRenderLayers(layers);
})
{
}
RenderCadenceApp(const RenderCadenceApp&) = delete;
RenderCadenceApp& operator=(const RenderCadenceApp&) = delete;
~RenderCadenceApp()
{
Stop();
}
bool Start(std::string& error)
{
mRuntimeLayers.Initialize(
mConfig.shaderLibrary,
static_cast<unsigned>(mConfig.maxTemporalHistoryFrames),
mConfig.runtimeShaderId);
Log("app", "Starting render thread.");
if (!detail::StartRenderThread(mRenderThread, error, 0))
{
LogError("app", "Render thread start failed: " + error);
Stop();
return false;
}
mRuntimeLayers.StartStartupBuild(mConfig.runtimeShaderId);
if (!BuildSettledOutputReserve(error))
{
LogError("app", error);
Stop();
return false;
}
StartOptionalVideoOutput();
mTelemetryHealth.Start(mFrameExchange, mOutput, mOutputThread, mRenderThread);
StartHttpServer();
Log("app", "RenderCadenceCompositor started.");
mStarted = true;
return true;
}
void Stop()
{
mHttpServer.Stop();
mTelemetryHealth.Stop();
mOutputThread.Stop();
mOutput.Stop();
mRuntimeLayers.Stop();
mRenderThread.Stop();
mOutput.ReleaseResources();
if (mStarted)
Log("app", "RenderCadenceCompositor shutdown complete.");
mStarted = false;
}
bool Started() const { return mStarted; }
const DeckLinkOutput& Output() const { return mOutput; }
void SetDeckLinkInputMetricsProvider(std::function<DeckLinkInputMetrics()> provider)
{
mDeckLinkInputMetricsProvider = std::move(provider);
}
private:
void StartOptionalVideoOutput()
{
std::string outputError;
Log("app", "Initializing optional DeckLink output.");
if (!mOutput.Initialize(
mConfig.deckLink,
[this](const VideoIOCompletion& completion) {
mFrameExchange.ReleaseScheduledByBytes(completion.outputFrameBuffer);
},
outputError))
{
DisableVideoOutput("DeckLink output unavailable: " + outputError);
return;
}
Log("app", "Starting DeckLink output thread.");
if (!mOutputThread.Start())
{
DisableVideoOutput("DeckLink output thread failed to start.");
return;
}
Log("app", "Waiting for DeckLink preroll frames.");
if (!WaitForPreroll())
{
DisableVideoOutput("Timed out waiting for DeckLink preroll frames.");
return;
}
Log("app", "Starting DeckLink scheduled playback.");
if (!mOutput.StartScheduledPlayback(outputError))
{
DisableVideoOutput("DeckLink scheduled playback failed: " + outputError);
return;
}
mVideoOutputEnabled = true;
mVideoOutputStatus = "DeckLink scheduled output running.";
Log("app", mVideoOutputStatus);
Log(
"app",
"DeckLink 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()
{
HttpControlServerCallbacks callbacks;
callbacks.getStateJson = [this]() {
return BuildStateJson();
};
callbacks.addLayer = [this](const std::string& body) {
return mRuntimeLayers.HandleAddLayer(body);
};
callbacks.removeLayer = [this](const std::string& body) {
return mRuntimeLayers.HandleRemoveLayer(body);
};
callbacks.executePost = [this](const std::string& path, const std::string& body) {
RuntimeControlCommand command;
std::string error;
if (!ParseRuntimeControlCommand(path, body, command, error))
return ControlActionResult{ false, error };
return mRuntimeLayers.HandleControlCommand(command);
};
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;
}
}
std::string BuildStateJson()
{
CadenceTelemetrySnapshot telemetry = mHttpTelemetry.Sample(mFrameExchange, mOutput, mOutputThread, mRenderThread);
ApplyDeckLinkInputMetrics(telemetry);
RuntimeLayerModelSnapshot layerSnapshot = mRuntimeLayers.Snapshot(telemetry);
return RuntimeStateToJson(RuntimeStateJsonInput{
mConfig,
telemetry,
mHttpServer.Port(),
mVideoOutputEnabled,
mVideoOutputStatus,
mRuntimeLayers.ShaderCatalog(),
layerSnapshot
});
}
void ApplyDeckLinkInputMetrics(CadenceTelemetrySnapshot& telemetry)
{
if (!mDeckLinkInputMetricsProvider)
return;
const DeckLinkInputMetrics inputMetrics = mDeckLinkInputMetricsProvider();
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;
DeckLinkOutput mOutput;
DeckLinkOutputThread<SystemFrameExchange> mOutputThread;
TelemetryHealthMonitor mTelemetryHealth;
CadenceTelemetry mHttpTelemetry;
HttpControlServer mHttpServer;
RuntimeLayerController mRuntimeLayers;
std::function<DeckLinkInputMetrics()> mDeckLinkInputMetricsProvider;
uint64_t mLastInputCapturedFrames = 0;
bool mStarted = false;
bool mVideoOutputEnabled = false;
std::string mVideoOutputStatus = "DeckLink output not started.";
};
}

View File

@@ -0,0 +1,92 @@
#include "RuntimeLayerController.h"
#include "../logging/Logger.h"
namespace RenderCadenceCompositor
{
RuntimeLayerController::RuntimeLayerController(RenderLayerPublisher publisher) :
mPublisher(std::move(publisher))
{
}
RuntimeLayerController::~RuntimeLayerController()
{
Stop();
}
void RuntimeLayerController::SetPublisher(RenderLayerPublisher publisher)
{
mPublisher = std::move(publisher);
}
void RuntimeLayerController::Initialize(const std::string& shaderLibrary, unsigned maxTemporalHistoryFrames, std::string& runtimeShaderId)
{
LoadSupportedShaderCatalog(shaderLibrary, maxTemporalHistoryFrames);
InitializeLayerModel(runtimeShaderId);
}
void RuntimeLayerController::StartStartupBuild(const std::string& runtimeShaderId)
{
if (runtimeShaderId.empty())
{
Log("runtime-shader", "Runtime shader build disabled.");
return;
}
Log("runtime-shader", "Starting background Slang build for shader '" + runtimeShaderId + "'.");
const std::string layerId = FirstRuntimeLayerId();
if (!layerId.empty())
StartLayerShaderBuild(layerId, runtimeShaderId);
}
void RuntimeLayerController::Stop()
{
StopAllRuntimeShaderBuilds();
}
RuntimeLayerModelSnapshot RuntimeLayerController::Snapshot(const CadenceTelemetrySnapshot& telemetry) const
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
RuntimeLayerModelSnapshot snapshot = mRuntimeLayerModel.Snapshot();
if (telemetry.shaderBuildFailures > 0)
{
snapshot.compileSucceeded = false;
snapshot.compileMessage = "Runtime shader GL commit failed; see logs for details.";
}
return snapshot;
}
void RuntimeLayerController::PublishRuntimeRenderLayers()
{
if (!mPublisher)
return;
std::vector<RuntimeRenderLayerModel> renderLayers;
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
renderLayers = mRuntimeLayerModel.Snapshot().renderLayers;
}
mPublisher(renderLayers);
}
bool RuntimeLayerController::MarkRuntimeBuildReady(const RuntimeShaderArtifact& artifact)
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
std::string error;
if (!mRuntimeLayerModel.MarkBuildReady(artifact, error))
{
LogWarning("runtime-shader", error);
return false;
}
return true;
}
void RuntimeLayerController::MarkRuntimeBuildFailedForLayer(const std::string& layerId, const std::string& message)
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
std::string error;
if (!mRuntimeLayerModel.MarkBuildFailed(layerId, message, error))
LogWarning("runtime-shader", error);
}
}

View File

@@ -0,0 +1,63 @@
#pragma once
#include "../control/ControlActionResult.h"
#include "../control/RuntimeControlCommand.h"
#include "../runtime/RuntimeLayerModel.h"
#include "../runtime/RuntimeShaderBridge.h"
#include "../runtime/SupportedShaderCatalog.h"
#include "../telemetry/CadenceTelemetry.h"
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace RenderCadenceCompositor
{
class RuntimeLayerController
{
public:
using RenderLayerPublisher = std::function<void(const std::vector<RuntimeRenderLayerModel>&)>;
explicit RuntimeLayerController(RenderLayerPublisher publisher = RenderLayerPublisher());
RuntimeLayerController(const RuntimeLayerController&) = delete;
RuntimeLayerController& operator=(const RuntimeLayerController&) = delete;
~RuntimeLayerController();
void SetPublisher(RenderLayerPublisher publisher);
void Initialize(const std::string& shaderLibrary, unsigned maxTemporalHistoryFrames, std::string& runtimeShaderId);
void StartStartupBuild(const std::string& runtimeShaderId);
void Stop();
ControlActionResult HandleAddLayer(const std::string& body);
ControlActionResult HandleRemoveLayer(const std::string& body);
ControlActionResult HandleControlCommand(const RuntimeControlCommand& command);
RuntimeLayerModelSnapshot Snapshot(const CadenceTelemetrySnapshot& telemetry) const;
const SupportedShaderCatalog& ShaderCatalog() const { return mShaderCatalog; }
private:
void LoadSupportedShaderCatalog(const std::string& shaderLibrary, unsigned maxTemporalHistoryFrames);
void InitializeLayerModel(std::string& runtimeShaderId);
void StartLayerShaderBuild(const std::string& layerId, const std::string& shaderId);
void RetireLayerShaderBuild(const std::string& layerId);
void CleanupRetiredShaderBuilds();
void StopAllRuntimeShaderBuilds();
void PublishRuntimeRenderLayers();
bool MarkRuntimeBuildReady(const RuntimeShaderArtifact& artifact);
void MarkRuntimeBuildFailedForLayer(const std::string& layerId, const std::string& message);
std::string FirstRuntimeLayerId() const;
static bool ExtractStringField(const std::string& body, const char* fieldName, std::string& value, std::string& error);
RenderLayerPublisher mPublisher;
SupportedShaderCatalog mShaderCatalog;
mutable std::mutex mRuntimeLayerMutex;
RuntimeLayerModel mRuntimeLayerModel;
std::mutex mShaderBuildMutex;
std::map<std::string, std::unique_ptr<RuntimeShaderBridge>> mShaderBuilds;
std::vector<std::unique_ptr<RuntimeShaderBridge>> mRetiredShaderBuilds;
};
}

View File

@@ -0,0 +1,122 @@
#include "RuntimeLayerController.h"
#include "AppConfigProvider.h"
#include "../logging/Logger.h"
#include <filesystem>
namespace RenderCadenceCompositor
{
void RuntimeLayerController::LoadSupportedShaderCatalog(const std::string& shaderLibrary, unsigned maxTemporalHistoryFrames)
{
const std::filesystem::path shaderRoot = FindRepoPath(shaderLibrary);
std::string error;
if (!mShaderCatalog.Load(shaderRoot, maxTemporalHistoryFrames, error))
{
LogWarning("runtime-shader", "Supported shader catalog is empty: " + error);
return;
}
Log("runtime-shader", "Supported shader catalog loaded with " + std::to_string(mShaderCatalog.Shaders().size()) + " shader(s).");
}
void RuntimeLayerController::InitializeLayerModel(std::string& runtimeShaderId)
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
std::string error;
if (!mRuntimeLayerModel.InitializeSingleLayer(mShaderCatalog, runtimeShaderId, error))
{
LogWarning("runtime-shader", error + " Runtime shader build disabled.");
runtimeShaderId.clear();
mRuntimeLayerModel.Clear();
}
}
void RuntimeLayerController::StartLayerShaderBuild(const std::string& layerId, const std::string& shaderId)
{
CleanupRetiredShaderBuilds();
RetireLayerShaderBuild(layerId);
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
std::string error;
mRuntimeLayerModel.MarkBuildStarted(layerId, "Runtime Slang build started for shader '" + shaderId + "'.", error);
}
auto bridge = std::make_unique<RuntimeShaderBridge>();
RuntimeShaderBridge* bridgePtr = bridge.get();
{
std::lock_guard<std::mutex> lock(mShaderBuildMutex);
mShaderBuilds[layerId] = std::move(bridge);
}
bridgePtr->Start(
layerId,
shaderId,
[this](const RuntimeShaderArtifact& artifact) {
if (MarkRuntimeBuildReady(artifact))
PublishRuntimeRenderLayers();
},
[this, layerId](const std::string& message) {
MarkRuntimeBuildFailedForLayer(layerId, message);
LogError("runtime-shader", "Runtime Slang build failed: " + message);
});
}
void RuntimeLayerController::RetireLayerShaderBuild(const std::string& layerId)
{
std::unique_ptr<RuntimeShaderBridge> bridge;
{
std::lock_guard<std::mutex> lock(mShaderBuildMutex);
auto bridgeIt = mShaderBuilds.find(layerId);
if (bridgeIt == mShaderBuilds.end())
return;
bridge = std::move(bridgeIt->second);
mShaderBuilds.erase(bridgeIt);
bridge->RequestStop();
mRetiredShaderBuilds.push_back(std::move(bridge));
}
}
void RuntimeLayerController::CleanupRetiredShaderBuilds()
{
std::vector<std::unique_ptr<RuntimeShaderBridge>> readyToStop;
{
std::lock_guard<std::mutex> lock(mShaderBuildMutex);
for (auto it = mRetiredShaderBuilds.begin(); it != mRetiredShaderBuilds.end();)
{
if ((*it)->CanStopWithoutWaiting())
{
readyToStop.push_back(std::move(*it));
it = mRetiredShaderBuilds.erase(it);
continue;
}
++it;
}
}
for (std::unique_ptr<RuntimeShaderBridge>& bridge : readyToStop)
bridge->Stop();
}
void RuntimeLayerController::StopAllRuntimeShaderBuilds()
{
std::map<std::string, std::unique_ptr<RuntimeShaderBridge>> builds;
std::vector<std::unique_ptr<RuntimeShaderBridge>> retiredBuilds;
{
std::lock_guard<std::mutex> lock(mShaderBuildMutex);
builds.swap(mShaderBuilds);
retiredBuilds.swap(mRetiredShaderBuilds);
}
for (auto& entry : builds)
entry.second->Stop();
for (auto& bridge : retiredBuilds)
bridge->Stop();
}
std::string RuntimeLayerController::FirstRuntimeLayerId() const
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
return mRuntimeLayerModel.FirstLayerId();
}
}

View File

@@ -0,0 +1,160 @@
#include "RuntimeLayerController.h"
#include "RuntimeJson.h"
#include "../logging/Logger.h"
namespace RenderCadenceCompositor
{
ControlActionResult RuntimeLayerController::HandleAddLayer(const std::string& body)
{
CleanupRetiredShaderBuilds();
std::string shaderId;
std::string error;
if (!ExtractStringField(body, "shaderId", shaderId, error))
return { false, error };
std::string layerId;
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.AddLayer(mShaderCatalog, shaderId, layerId, error))
return { false, error };
}
Log("runtime-shader", "Layer added: " + layerId + " shader=" + shaderId);
StartLayerShaderBuild(layerId, shaderId);
return { true, std::string() };
}
ControlActionResult RuntimeLayerController::HandleRemoveLayer(const std::string& body)
{
CleanupRetiredShaderBuilds();
std::string layerId;
std::string error;
if (!ExtractStringField(body, "layerId", layerId, error))
return { false, error };
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.RemoveLayer(layerId, error))
return { false, error };
}
Log("runtime-shader", "Layer removed: " + layerId);
RetireLayerShaderBuild(layerId);
PublishRuntimeRenderLayers();
return { true, std::string() };
}
ControlActionResult RuntimeLayerController::HandleControlCommand(const RuntimeControlCommand& command)
{
CleanupRetiredShaderBuilds();
std::string error;
switch (command.type)
{
case RuntimeControlCommandType::AddLayer:
{
std::string layerId;
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.AddLayer(mShaderCatalog, command.shaderId, layerId, error))
return { false, error };
}
Log("runtime-shader", "Layer added: " + layerId + " shader=" + command.shaderId);
StartLayerShaderBuild(layerId, command.shaderId);
return { true, std::string() };
}
case RuntimeControlCommandType::RemoveLayer:
{
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.RemoveLayer(command.layerId, error))
return { false, error };
}
Log("runtime-shader", "Layer removed: " + command.layerId);
RetireLayerShaderBuild(command.layerId);
PublishRuntimeRenderLayers();
return { true, std::string() };
}
case RuntimeControlCommandType::ReorderLayer:
{
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.ReorderLayer(command.layerId, command.targetIndex, error))
return { false, error };
}
PublishRuntimeRenderLayers();
return { true, std::string() };
}
case RuntimeControlCommandType::SetLayerBypass:
{
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.SetLayerBypass(command.layerId, command.bypass, error))
return { false, error };
}
PublishRuntimeRenderLayers();
return { true, std::string() };
}
case RuntimeControlCommandType::SetLayerShader:
{
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.SetLayerShader(mShaderCatalog, command.layerId, command.shaderId, error))
return { false, error };
}
Log("runtime-shader", "Layer shader changed: " + command.layerId + " shader=" + command.shaderId);
StartLayerShaderBuild(command.layerId, command.shaderId);
return { true, std::string() };
}
case RuntimeControlCommandType::UpdateLayerParameter:
{
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.UpdateParameter(command.layerId, command.parameterId, command.value, error))
return { false, error };
}
PublishRuntimeRenderLayers();
return { true, std::string() };
}
case RuntimeControlCommandType::ResetLayerParameters:
{
{
std::lock_guard<std::mutex> lock(mRuntimeLayerMutex);
if (!mRuntimeLayerModel.ResetParameters(command.layerId, error))
return { false, error };
}
PublishRuntimeRenderLayers();
return { true, std::string() };
}
case RuntimeControlCommandType::Unsupported:
break;
}
return { false, "Unsupported runtime control command." };
}
bool RuntimeLayerController::ExtractStringField(const std::string& body, const char* fieldName, std::string& value, std::string& error)
{
JsonValue root;
std::string parseError;
if (!ParseJson(body.empty() ? "{}" : body, root, parseError) || !root.isObject())
{
error = parseError.empty() ? "Request body must be a JSON object." : parseError;
return false;
}
const JsonValue* field = root.find(fieldName);
if (!field || !field->isString() || field->asString().empty())
{
error = std::string("Request field '") + fieldName + "' must be a non-empty string.";
return false;
}
value = field->asString();
error.clear();
return true;
}
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include <string>
namespace RenderCadenceCompositor
{
struct ControlActionResult
{
bool ok = false;
std::string error;
};
}

View File

@@ -0,0 +1,127 @@
#include "RuntimeControlCommand.h"
namespace RenderCadenceCompositor
{
namespace
{
const JsonValue* RequireObjectField(const JsonValue& root, const char* fieldName, std::string& error)
{
const JsonValue* field = root.find(fieldName);
if (!field)
error = std::string("Request field '") + fieldName + "' is required.";
return field;
}
bool RequireStringField(const JsonValue& root, const char* fieldName, std::string& value, std::string& error)
{
const JsonValue* field = RequireObjectField(root, fieldName, error);
if (!field)
return false;
if (!field->isString() || field->asString().empty())
{
error = std::string("Request field '") + fieldName + "' must be a non-empty string.";
return false;
}
value = field->asString();
return true;
}
bool RequireBoolField(const JsonValue& root, const char* fieldName, bool& value, std::string& error)
{
const JsonValue* field = RequireObjectField(root, fieldName, error);
if (!field)
return false;
if (!field->isBoolean())
{
error = std::string("Request field '") + fieldName + "' must be a boolean.";
return false;
}
value = field->asBoolean();
return true;
}
bool RequireIntegerField(const JsonValue& root, const char* fieldName, int& value, std::string& error)
{
const JsonValue* field = RequireObjectField(root, fieldName, error);
if (!field)
return false;
if (!field->isNumber())
{
error = std::string("Request field '") + fieldName + "' must be a number.";
return false;
}
value = static_cast<int>(field->asNumber());
return true;
}
}
bool ParseRuntimeControlCommand(
const std::string& path,
const std::string& body,
RuntimeControlCommand& command,
std::string& error)
{
command = RuntimeControlCommand();
JsonValue root;
std::string parseError;
if (!ParseJson(body.empty() ? "{}" : body, root, parseError) || !root.isObject())
{
error = parseError.empty() ? "Request body must be a JSON object." : parseError;
return false;
}
if (path == "/api/layers/add")
{
command.type = RuntimeControlCommandType::AddLayer;
return RequireStringField(root, "shaderId", command.shaderId, error);
}
if (path == "/api/layers/remove")
{
command.type = RuntimeControlCommandType::RemoveLayer;
return RequireStringField(root, "layerId", command.layerId, error);
}
if (path == "/api/layers/reorder")
{
command.type = RuntimeControlCommandType::ReorderLayer;
return RequireStringField(root, "layerId", command.layerId, error)
&& RequireIntegerField(root, "targetIndex", command.targetIndex, error);
}
if (path == "/api/layers/set-bypass")
{
command.type = RuntimeControlCommandType::SetLayerBypass;
return RequireStringField(root, "layerId", command.layerId, error)
&& RequireBoolField(root, "bypass", command.bypass, error);
}
if (path == "/api/layers/set-shader")
{
command.type = RuntimeControlCommandType::SetLayerShader;
return RequireStringField(root, "layerId", command.layerId, error)
&& RequireStringField(root, "shaderId", command.shaderId, error);
}
if (path == "/api/layers/update-parameter")
{
command.type = RuntimeControlCommandType::UpdateLayerParameter;
const JsonValue* value = nullptr;
if (!RequireStringField(root, "layerId", command.layerId, error)
|| !RequireStringField(root, "parameterId", command.parameterId, error))
{
return false;
}
value = RequireObjectField(root, "value", error);
if (!value)
return false;
command.value = *value;
return true;
}
if (path == "/api/layers/reset-parameters")
{
command.type = RuntimeControlCommandType::ResetLayerParameters;
return RequireStringField(root, "layerId", command.layerId, error);
}
command.type = RuntimeControlCommandType::Unsupported;
error = "Endpoint is not implemented in RenderCadenceCompositor yet.";
return false;
}
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include "RuntimeJson.h"
#include <string>
namespace RenderCadenceCompositor
{
enum class RuntimeControlCommandType
{
AddLayer,
RemoveLayer,
ReorderLayer,
SetLayerBypass,
SetLayerShader,
UpdateLayerParameter,
ResetLayerParameters,
Unsupported
};
struct RuntimeControlCommand
{
RuntimeControlCommandType type = RuntimeControlCommandType::Unsupported;
std::string layerId;
std::string shaderId;
std::string parameterId;
int targetIndex = 0;
bool bypass = false;
JsonValue value;
};
bool ParseRuntimeControlCommand(
const std::string& path,
const std::string& body,
RuntimeControlCommand& command,
std::string& error);
}

View File

@@ -0,0 +1,324 @@
#pragma once
#include "../app/AppConfig.h"
#include "../app/AppConfigProvider.h"
#include "../json/JsonWriter.h"
#include "../runtime/RuntimeLayerModel.h"
#include "../runtime/SupportedShaderCatalog.h"
#include "../telemetry/CadenceTelemetryJson.h"
#include <cstdint>
#include <string>
#include <vector>
namespace RenderCadenceCompositor
{
struct RuntimeStateJsonInput
{
const AppConfig& config;
const CadenceTelemetrySnapshot& telemetry;
unsigned short serverPort = 0;
bool videoOutputEnabled = false;
std::string videoOutputStatus;
const SupportedShaderCatalog& shaderCatalog;
const RuntimeLayerModelSnapshot& runtimeLayers;
};
inline void WriteVideoIoStatusJson(JsonWriter& writer, const RuntimeStateJsonInput& input)
{
writer.BeginObject();
writer.KeyString("backend", "decklink");
writer.KeyNull("modelName");
writer.KeyBool("supportsInternalKeying", false);
writer.KeyBool("supportsExternalKeying", false);
writer.KeyBool("keyerInterfaceAvailable", false);
writer.KeyBool("externalKeyingRequested", input.config.deckLink.externalKeyingEnabled);
writer.KeyBool("externalKeyingActive", input.videoOutputEnabled && input.config.deckLink.externalKeyingEnabled);
writer.KeyString("statusMessage", input.videoOutputStatus);
writer.EndObject();
}
inline void OutputDimensions(const RuntimeStateJsonInput& input, unsigned& width, unsigned& height)
{
VideoFormatDimensions(input.config.outputVideoFormat, width, height);
}
inline const char* ShaderParameterTypeName(ShaderParameterType type)
{
switch (type)
{
case ShaderParameterType::Float: return "float";
case ShaderParameterType::Vec2: return "vec2";
case ShaderParameterType::Color: return "color";
case ShaderParameterType::Boolean: return "bool";
case ShaderParameterType::Enum: return "enum";
case ShaderParameterType::Text: return "text";
case ShaderParameterType::Trigger: return "trigger";
}
return "unknown";
}
inline void WriteNumberArray(JsonWriter& writer, const std::vector<double>& values)
{
writer.BeginArray();
for (double value : values)
writer.Double(value);
writer.EndArray();
}
inline void WriteDefaultParameterValue(JsonWriter& writer, const ShaderParameterDefinition& parameter)
{
switch (parameter.type)
{
case ShaderParameterType::Boolean:
writer.Bool(parameter.defaultBoolean);
return;
case ShaderParameterType::Enum:
writer.String(parameter.defaultEnumValue);
return;
case ShaderParameterType::Text:
writer.String(parameter.defaultTextValue);
return;
case ShaderParameterType::Trigger:
writer.Double(0.0);
return;
case ShaderParameterType::Float:
writer.Double(parameter.defaultNumbers.empty() ? 0.0 : parameter.defaultNumbers.front());
return;
case ShaderParameterType::Vec2:
case ShaderParameterType::Color:
WriteNumberArray(writer, parameter.defaultNumbers);
return;
}
writer.Null();
}
inline void WriteParameterValue(JsonWriter& writer, const ShaderParameterDefinition& parameter, const ShaderParameterValue& value)
{
switch (parameter.type)
{
case ShaderParameterType::Boolean:
writer.Bool(value.booleanValue);
return;
case ShaderParameterType::Enum:
writer.String(value.enumValue);
return;
case ShaderParameterType::Text:
writer.String(value.textValue);
return;
case ShaderParameterType::Trigger:
case ShaderParameterType::Float:
writer.Double(value.numberValues.empty() ? 0.0 : value.numberValues.front());
return;
case ShaderParameterType::Vec2:
case ShaderParameterType::Color:
WriteNumberArray(writer, value.numberValues);
return;
}
writer.Null();
}
inline void WriteTemporalJson(JsonWriter& writer, const TemporalSettings& temporal)
{
writer.BeginObject();
writer.KeyBool("enabled", temporal.enabled);
writer.KeyString("historySource", "none");
writer.KeyUInt("requestedHistoryLength", temporal.requestedHistoryLength);
writer.KeyUInt("effectiveHistoryLength", temporal.effectiveHistoryLength);
writer.EndObject();
}
inline void WriteFeedbackJson(JsonWriter& writer, const FeedbackSettings& feedback)
{
writer.BeginObject();
writer.KeyBool("enabled", feedback.enabled);
writer.KeyString("writePass", feedback.writePassId);
writer.EndObject();
}
inline const char* RuntimeLayerBuildStateName(RuntimeLayerBuildState state)
{
switch (state)
{
case RuntimeLayerBuildState::Pending: return "pending";
case RuntimeLayerBuildState::Ready: return "ready";
case RuntimeLayerBuildState::Failed: return "failed";
}
return "unknown";
}
inline void WriteParameterDefinitionJson(JsonWriter& writer, const ShaderParameterDefinition& parameter, const ShaderParameterValue* value)
{
writer.BeginObject();
writer.KeyString("id", parameter.id);
writer.KeyString("label", parameter.label.empty() ? parameter.id : parameter.label);
writer.KeyString("description", parameter.description);
writer.KeyString("type", ShaderParameterTypeName(parameter.type));
writer.Key("defaultValue");
WriteDefaultParameterValue(writer, parameter);
writer.Key("value");
if (value)
WriteParameterValue(writer, parameter, *value);
else
WriteDefaultParameterValue(writer, parameter);
if (!parameter.minNumbers.empty())
{
writer.Key("min");
WriteNumberArray(writer, parameter.minNumbers);
}
if (!parameter.maxNumbers.empty())
{
writer.Key("max");
WriteNumberArray(writer, parameter.maxNumbers);
}
if (!parameter.stepNumbers.empty())
{
writer.Key("step");
WriteNumberArray(writer, parameter.stepNumbers);
}
if (parameter.type == ShaderParameterType::Enum)
{
writer.Key("options");
writer.BeginArray();
for (const ShaderParameterOption& option : parameter.enumOptions)
{
writer.BeginObject();
writer.KeyString("value", option.value);
writer.KeyString("label", option.label.empty() ? option.value : option.label);
writer.EndObject();
}
writer.EndArray();
}
if (parameter.type == ShaderParameterType::Text)
{
writer.KeyUInt("maxLength", parameter.maxLength);
if (!parameter.fontId.empty())
writer.KeyString("font", parameter.fontId);
}
writer.EndObject();
}
inline void WriteLayersJson(JsonWriter& writer, const RuntimeStateJsonInput& input)
{
writer.BeginArray();
for (const RuntimeLayerReadModel& layer : input.runtimeLayers.displayLayers)
{
const ShaderPackage* shaderPackage = input.shaderCatalog.FindPackage(layer.shaderId);
writer.BeginObject();
writer.KeyString("id", layer.id);
writer.KeyString("shaderId", layer.shaderId);
writer.KeyString("shaderName", layer.shaderName);
writer.KeyBool("bypass", layer.bypass);
writer.KeyString("buildState", RuntimeLayerBuildStateName(layer.buildState));
writer.KeyBool("renderReady", layer.renderReady);
writer.KeyString("message", layer.message);
writer.Key("temporal");
if (shaderPackage)
WriteTemporalJson(writer, shaderPackage->temporal);
else
WriteTemporalJson(writer, TemporalSettings());
writer.Key("feedback");
if (shaderPackage)
WriteFeedbackJson(writer, shaderPackage->feedback);
else
WriteFeedbackJson(writer, FeedbackSettings());
writer.Key("parameters");
writer.BeginArray();
for (const ShaderParameterDefinition& parameter : layer.parameterDefinitions)
{
const auto valueIt = layer.parameterValues.find(parameter.id);
WriteParameterDefinitionJson(writer, parameter, valueIt == layer.parameterValues.end() ? nullptr : &valueIt->second);
}
writer.EndArray();
writer.EndObject();
}
writer.EndArray();
}
inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
{
JsonWriter writer;
writer.BeginObject();
writer.Key("app");
writer.BeginObject();
writer.KeyUInt("serverPort", input.serverPort);
writer.KeyUInt("oscPort", input.config.oscPort);
writer.KeyString("oscBindAddress", input.config.oscBindAddress);
writer.KeyDouble("oscSmoothing", input.config.oscSmoothing);
writer.KeyBool("autoReload", input.config.autoReload);
writer.KeyUInt("maxTemporalHistoryFrames", static_cast<uint64_t>(input.config.maxTemporalHistoryFrames));
writer.KeyDouble("previewFps", input.config.previewFps);
writer.KeyBool("enableExternalKeying", input.config.deckLink.externalKeyingEnabled);
writer.KeyString("inputVideoFormat", input.config.inputVideoFormat);
writer.KeyString("inputFrameRate", input.config.inputFrameRate);
writer.KeyString("outputVideoFormat", input.config.outputVideoFormat);
writer.KeyString("outputFrameRate", input.config.outputFrameRate);
writer.EndObject();
writer.Key("runtime");
writer.BeginObject();
writer.KeyUInt("layerCount", static_cast<uint64_t>(input.runtimeLayers.displayLayers.size()));
writer.KeyBool("compileSucceeded", input.runtimeLayers.compileSucceeded);
writer.KeyString("compileMessage", input.runtimeLayers.compileMessage);
writer.EndObject();
writer.Key("video");
writer.BeginObject();
unsigned outputWidth = 0;
unsigned outputHeight = 0;
OutputDimensions(input, outputWidth, outputHeight);
writer.KeyBool("hasSignal", input.videoOutputEnabled);
writer.KeyUInt("width", outputWidth);
writer.KeyUInt("height", outputHeight);
writer.KeyString("modeName", input.config.outputVideoFormat + " output-only");
writer.EndObject();
writer.Key("decklink");
WriteVideoIoStatusJson(writer, input);
writer.Key("videoIO");
WriteVideoIoStatusJson(writer, input);
writer.Key("performance");
writer.BeginObject();
writer.KeyDouble("frameBudgetMs", FrameDurationMillisecondsFromRateString(input.config.outputFrameRate));
writer.KeyDouble("renderMs", input.telemetry.renderFrameMilliseconds);
writer.KeyNull("smoothedRenderMs");
writer.KeyDouble("budgetUsedPercent", input.telemetry.renderFrameBudgetUsedPercent);
writer.KeyNull("completionIntervalMs");
writer.KeyNull("smoothedCompletionIntervalMs");
writer.KeyNull("maxCompletionIntervalMs");
writer.KeyUInt("lateFrameCount", input.telemetry.displayedLate);
writer.KeyUInt("droppedFrameCount", input.telemetry.dropped);
writer.KeyNull("flushedFrameCount");
writer.Key("cadence");
WriteCadenceTelemetryJson(writer, input.telemetry);
writer.EndObject();
writer.KeyNull("backendPlayout");
writer.KeyNull("runtimeEvents");
writer.Key("shaders");
writer.BeginArray();
for (const SupportedShaderSummary& shader : input.shaderCatalog.Shaders())
{
writer.BeginObject();
writer.KeyString("id", shader.id);
writer.KeyString("name", shader.name);
writer.KeyString("description", shader.description);
writer.KeyString("category", shader.category);
writer.KeyBool("available", true);
writer.KeyNull("error");
writer.EndObject();
}
writer.EndArray();
writer.Key("stackPresets");
writer.BeginArray();
writer.EndArray();
writer.Key("layers");
WriteLayersJson(writer, input);
writer.EndObject();
return writer.StringValue();
}
}

View File

@@ -0,0 +1,293 @@
#include "HttpControlServer.h"
#include "../logging/Logger.h"
#include <ws2tcpip.h>
#include <algorithm>
#include <sstream>
namespace RenderCadenceCompositor
{
namespace
{
bool InitializeWinsock(std::string& error)
{
WSADATA wsaData = {};
const int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (result != 0)
{
error = "WSAStartup failed.";
return false;
}
return true;
}
}
UniqueSocket::UniqueSocket(SOCKET socket) :
mSocket(socket)
{
}
UniqueSocket::~UniqueSocket()
{
reset();
}
UniqueSocket::UniqueSocket(UniqueSocket&& other) noexcept :
mSocket(other.release())
{
}
UniqueSocket& UniqueSocket::operator=(UniqueSocket&& other) noexcept
{
if (this != &other)
reset(other.release());
return *this;
}
SOCKET UniqueSocket::release()
{
const SOCKET socket = mSocket;
mSocket = INVALID_SOCKET;
return socket;
}
void UniqueSocket::reset(SOCKET socket)
{
if (valid())
closesocket(mSocket);
mSocket = socket;
}
HttpControlServer::~HttpControlServer()
{
Stop();
}
bool HttpControlServer::Start(
const std::filesystem::path& uiRoot,
const std::filesystem::path& docsRoot,
HttpControlServerConfig config,
HttpControlServerCallbacks callbacks,
std::string& error)
{
Stop();
if (!InitializeWinsock(error))
return false;
mWinsockStarted = true;
mUiRoot = uiRoot;
mDocsRoot = docsRoot;
mConfig = config;
mCallbacks = std::move(callbacks);
mListenSocket.reset(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP));
if (!mListenSocket.valid())
{
error = "Could not create HTTP control server socket.";
Stop();
return false;
}
u_long nonBlocking = 1;
ioctlsocket(mListenSocket.get(), FIONBIO, &nonBlocking);
sockaddr_in address = {};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bool bound = false;
for (unsigned short offset = 0; offset < mConfig.portSearchCount; ++offset)
{
address.sin_port = htons(static_cast<u_short>(mConfig.preferredPort + offset));
if (bind(mListenSocket.get(), reinterpret_cast<sockaddr*>(&address), sizeof(address)) == 0)
{
mPort = static_cast<unsigned short>(mConfig.preferredPort + offset);
bound = true;
break;
}
}
if (!bound)
{
error = "Could not bind HTTP control server to loopback.";
Stop();
return false;
}
if (listen(mListenSocket.get(), SOMAXCONN) != 0)
{
error = "Could not listen on HTTP control server socket.";
Stop();
return false;
}
mRunning.store(true, std::memory_order_release);
mThread = std::thread([this]() { ThreadMain(); });
Log("http", "HTTP control server listening on http://127.0.0.1:" + std::to_string(mPort));
return true;
}
void HttpControlServer::Stop()
{
mRunning.store(false, std::memory_order_release);
mListenSocket.reset();
if (mThread.joinable())
mThread.join();
std::vector<std::thread> clientThreads;
{
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
clientThreads.swap(mClientThreads);
for (std::thread& thread : mFinishedClientThreads)
clientThreads.push_back(std::move(thread));
mFinishedClientThreads.clear();
}
for (std::thread& thread : clientThreads)
{
if (thread.joinable())
thread.join();
}
if (mWinsockStarted)
{
WSACleanup();
mWinsockStarted = false;
}
mPort = 0;
}
HttpControlServer::HttpResponse HttpControlServer::RouteRequestForTest(const HttpRequest& request) const
{
return RouteRequest(request);
}
void HttpControlServer::SetCallbacksForTest(HttpControlServerCallbacks callbacks)
{
mCallbacks = std::move(callbacks);
}
void HttpControlServer::SetRootsForTest(const std::filesystem::path& uiRoot, const std::filesystem::path& docsRoot)
{
mUiRoot = uiRoot;
mDocsRoot = docsRoot;
}
void HttpControlServer::ThreadMain()
{
while (mRunning.load(std::memory_order_acquire))
{
JoinFinishedClientThreads();
TryAcceptClient();
std::this_thread::sleep_for(mConfig.idleSleep);
}
}
bool HttpControlServer::TryAcceptClient()
{
sockaddr_in clientAddress = {};
int addressSize = sizeof(clientAddress);
UniqueSocket clientSocket(accept(mListenSocket.get(), reinterpret_cast<sockaddr*>(&clientAddress), &addressSize));
if (!clientSocket.valid())
return false;
return HandleClient(std::move(clientSocket));
}
bool HttpControlServer::HandleClient(UniqueSocket clientSocket)
{
char buffer[16384];
const int received = recv(clientSocket.get(), buffer, sizeof(buffer), 0);
if (received <= 0)
return false;
HttpRequest request;
if (!ParseHttpRequest(std::string(buffer, buffer + received), request))
return SendResponse(clientSocket.get(), TextResponse("400 Bad Request", "Bad Request"));
if (request.path == "/ws")
return HandleWebSocketClient(std::move(clientSocket), request);
return SendResponse(clientSocket.get(), RouteRequest(request));
}
bool HttpControlServer::SendResponse(SOCKET clientSocket, const HttpResponse& response) const
{
std::ostringstream stream;
stream << "HTTP/1.1 " << response.status << "\r\n"
<< "Content-Type: " << response.contentType << "\r\n"
<< "Content-Length: " << response.body.size() << "\r\n"
<< "Access-Control-Allow-Origin: *\r\n"
<< "Connection: close\r\n\r\n"
<< response.body;
const std::string payload = stream.str();
return send(clientSocket, payload.c_str(), static_cast<int>(payload.size()), 0) == static_cast<int>(payload.size());
}
HttpControlServer::HttpResponse HttpControlServer::RouteRequest(const HttpRequest& request) const
{
if (request.method == "GET")
return ServeGet(request);
if (request.method == "POST")
return ServePost(request);
if (request.method == "OPTIONS")
return TextResponse("204 No Content", std::string());
return TextResponse("404 Not Found", "Not Found");
}
bool HttpControlServer::ParseHttpRequest(const std::string& rawRequest, HttpRequest& request)
{
const std::size_t requestLineEnd = rawRequest.find("\r\n");
if (requestLineEnd == std::string::npos)
return false;
const std::string requestLine = rawRequest.substr(0, requestLineEnd);
const std::size_t methodEnd = requestLine.find(' ');
if (methodEnd == std::string::npos)
return false;
const std::size_t pathEnd = requestLine.find(' ', methodEnd + 1);
if (pathEnd == std::string::npos)
return false;
request.method = requestLine.substr(0, methodEnd);
request.path = requestLine.substr(methodEnd + 1, pathEnd - methodEnd - 1);
request.headers.clear();
const std::size_t queryStart = request.path.find('?');
if (queryStart != std::string::npos)
request.path = request.path.substr(0, queryStart);
const std::size_t headersStart = requestLineEnd + 2;
const std::size_t bodySeparator = rawRequest.find("\r\n\r\n", headersStart);
const std::size_t headersEnd = bodySeparator == std::string::npos ? rawRequest.size() : bodySeparator;
for (std::size_t lineStart = headersStart; lineStart < headersEnd;)
{
const std::size_t lineEnd = rawRequest.find("\r\n", lineStart);
const std::size_t currentLineEnd = lineEnd == std::string::npos ? headersEnd : (std::min)(lineEnd, headersEnd);
const std::string line = rawRequest.substr(lineStart, currentLineEnd - lineStart);
const std::size_t separator = line.find(':');
if (separator != std::string::npos)
{
const std::string key = ToLower(line.substr(0, separator));
std::string value = line.substr(separator + 1);
const std::size_t first = value.find_first_not_of(" \t");
const std::size_t last = value.find_last_not_of(" \t");
request.headers[key] = first == std::string::npos ? std::string() : value.substr(first, last - first + 1);
}
if (lineEnd == std::string::npos || lineEnd >= headersEnd)
break;
lineStart = lineEnd + 2;
}
request.body = bodySeparator == std::string::npos ? std::string() : rawRequest.substr(bodySeparator + 4);
return !request.method.empty() && !request.path.empty();
}
}

View File

@@ -0,0 +1,135 @@
#pragma once
#include "ControlActionResult.h"
#include <winsock2.h>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <functional>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace RenderCadenceCompositor
{
struct HttpControlServerConfig
{
unsigned short preferredPort = 8080;
unsigned short portSearchCount = 20;
std::chrono::milliseconds idleSleep = std::chrono::milliseconds(10);
};
struct HttpControlServerCallbacks
{
std::function<std::string()> getStateJson;
std::function<ControlActionResult(const std::string&)> addLayer;
std::function<ControlActionResult(const std::string&)> removeLayer;
std::function<ControlActionResult(const std::string&, const std::string&)> executePost;
};
class UniqueSocket
{
public:
explicit UniqueSocket(SOCKET socket = INVALID_SOCKET);
~UniqueSocket();
UniqueSocket(const UniqueSocket&) = delete;
UniqueSocket& operator=(const UniqueSocket&) = delete;
UniqueSocket(UniqueSocket&& other) noexcept;
UniqueSocket& operator=(UniqueSocket&& other) noexcept;
SOCKET get() const { return mSocket; }
bool valid() const { return mSocket != INVALID_SOCKET; }
SOCKET release();
void reset(SOCKET socket = INVALID_SOCKET);
private:
SOCKET mSocket = INVALID_SOCKET;
};
class HttpControlServer
{
public:
struct HttpRequest
{
std::string method;
std::string path;
std::map<std::string, std::string> headers;
std::string body;
};
struct HttpResponse
{
std::string status;
std::string contentType;
std::string body;
};
HttpControlServer() = default;
~HttpControlServer();
HttpControlServer(const HttpControlServer&) = delete;
HttpControlServer& operator=(const HttpControlServer&) = delete;
bool Start(
const std::filesystem::path& uiRoot,
const std::filesystem::path& docsRoot,
HttpControlServerConfig config,
HttpControlServerCallbacks callbacks,
std::string& error);
void Stop();
bool IsRunning() const { return mRunning.load(std::memory_order_acquire); }
unsigned short Port() const { return mPort; }
void SetCallbacksForTest(HttpControlServerCallbacks callbacks);
void SetRootsForTest(const std::filesystem::path& uiRoot, const std::filesystem::path& docsRoot);
HttpResponse RouteRequestForTest(const HttpRequest& request) const;
static bool ParseHttpRequest(const std::string& rawRequest, HttpRequest& request);
static std::string WebSocketAcceptKey(const std::string& clientKey);
private:
void ThreadMain();
bool TryAcceptClient();
bool HandleClient(UniqueSocket clientSocket);
bool HandleWebSocketClient(UniqueSocket clientSocket, const HttpRequest& request);
void WebSocketClientMain(UniqueSocket clientSocket);
void JoinFinishedClientThreads();
bool SendResponse(SOCKET clientSocket, const HttpResponse& response) const;
HttpResponse RouteRequest(const HttpRequest& request) const;
HttpResponse ServeGet(const HttpRequest& request) const;
HttpResponse ServePost(const HttpRequest& request) const;
HttpResponse ServeOpenApiSpec() const;
HttpResponse ServeSwaggerDocs() const;
HttpResponse ServeUiAsset(const std::string& relativePath) const;
std::string LoadTextFile(const std::filesystem::path& path) const;
static HttpResponse JsonResponse(const std::string& status, const std::string& body);
static HttpResponse TextResponse(const std::string& status, const std::string& body);
static HttpResponse HtmlResponse(const std::string& status, const std::string& body);
static std::string ActionResponse(bool ok, const std::string& error = std::string());
static bool SendWebSocketText(SOCKET clientSocket, const std::string& text);
static std::string GuessContentType(const std::filesystem::path& path);
static bool IsSafeRelativePath(const std::filesystem::path& path);
static std::string ToLower(std::string text);
std::filesystem::path mUiRoot;
std::filesystem::path mDocsRoot;
HttpControlServerConfig mConfig;
HttpControlServerCallbacks mCallbacks;
UniqueSocket mListenSocket;
std::thread mThread;
std::mutex mClientThreadsMutex;
std::vector<std::thread> mClientThreads;
std::vector<std::thread> mFinishedClientThreads;
std::atomic<bool> mRunning{ false };
unsigned short mPort = 0;
bool mWinsockStarted = false;
};
}

View File

@@ -0,0 +1,203 @@
#include "HttpControlServer.h"
#include "../json/JsonWriter.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <sstream>
namespace RenderCadenceCompositor
{
namespace
{
bool IsKnownPostEndpoint(const std::string& path)
{
return path == "/api/layers/add"
|| path == "/api/layers/remove"
|| path == "/api/layers/move"
|| path == "/api/layers/reorder"
|| path == "/api/layers/set-bypass"
|| path == "/api/layers/set-shader"
|| path == "/api/layers/update-parameter"
|| path == "/api/layers/reset-parameters"
|| path == "/api/stack-presets/save"
|| path == "/api/stack-presets/load"
|| path == "/api/reload"
|| path == "/api/screenshot";
}
}
HttpControlServer::HttpResponse HttpControlServer::ServeGet(const HttpRequest& request) const
{
if (request.path == "/api/state")
return JsonResponse("200 OK", mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}");
if (request.path == "/openapi.yaml" || request.path == "/docs/openapi.yaml")
return ServeOpenApiSpec();
if (request.path == "/docs" || request.path == "/docs/")
return ServeSwaggerDocs();
if (request.path == "/" || request.path == "/index.html")
return ServeUiAsset("index.html");
if (request.path.rfind("/assets/", 0) == 0)
return ServeUiAsset(request.path.substr(1));
if (request.path.size() > 1)
{
const HttpResponse asset = ServeUiAsset(request.path.substr(1));
if (asset.status != "404 Not Found")
return asset;
}
return ServeUiAsset("index.html");
}
HttpControlServer::HttpResponse HttpControlServer::ServePost(const HttpRequest& request) const
{
if (!IsKnownPostEndpoint(request.path))
return TextResponse("404 Not Found", "Not Found");
if (mCallbacks.executePost)
{
const ControlActionResult result = mCallbacks.executePost(request.path, request.body);
return JsonResponse(result.ok ? "200 OK" : "400 Bad Request", ActionResponse(result.ok, result.error));
}
if (request.path == "/api/layers/add" && mCallbacks.addLayer)
{
const ControlActionResult result = mCallbacks.addLayer(request.body);
return JsonResponse(result.ok ? "200 OK" : "400 Bad Request", ActionResponse(result.ok, result.error));
}
if (request.path == "/api/layers/remove" && mCallbacks.removeLayer)
{
const ControlActionResult result = mCallbacks.removeLayer(request.body);
return JsonResponse(result.ok ? "200 OK" : "400 Bad Request", ActionResponse(result.ok, result.error));
}
return {
"400 Bad Request",
"application/json",
ActionResponse(false, "Endpoint is not implemented in RenderCadenceCompositor yet.")
};
}
HttpControlServer::HttpResponse HttpControlServer::ServeOpenApiSpec() const
{
const std::filesystem::path path = mDocsRoot / "openapi.yaml";
const std::string body = LoadTextFile(path);
return body.empty()
? TextResponse("404 Not Found", "OpenAPI spec not found")
: HttpResponse{ "200 OK", GuessContentType(path), body };
}
HttpControlServer::HttpResponse HttpControlServer::ServeSwaggerDocs() const
{
std::ostringstream html;
html << "<!doctype html>\n"
<< "<html><head><meta charset=\"utf-8\"><title>Video Shader Toys API Docs</title>\n"
<< "<link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\"></head>\n"
<< "<body><div id=\"swagger-ui\"></div>\n"
<< "<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>\n"
<< "<script>SwaggerUIBundle({url:'/docs/openapi.yaml',dom_id:'#swagger-ui'});</script>\n"
<< "</body></html>\n";
return { "200 OK", "text/html", html.str() };
}
HttpControlServer::HttpResponse HttpControlServer::ServeUiAsset(const std::string& relativePath) const
{
if (mUiRoot.empty())
return TextResponse("404 Not Found", "UI root is not configured");
const std::filesystem::path sanitizedPath = std::filesystem::path(relativePath).lexically_normal();
if (!IsSafeRelativePath(sanitizedPath))
return TextResponse("404 Not Found", "Not Found");
const std::filesystem::path path = mUiRoot / sanitizedPath;
const std::string body = LoadTextFile(path);
if (body.empty())
return TextResponse("404 Not Found", "Not Found");
return { "200 OK", GuessContentType(path), body };
}
std::string HttpControlServer::LoadTextFile(const std::filesystem::path& path) const
{
std::ifstream input(path, std::ios::binary);
if (!input)
return std::string();
std::ostringstream buffer;
buffer << input.rdbuf();
return buffer.str();
}
HttpControlServer::HttpResponse HttpControlServer::JsonResponse(const std::string& status, const std::string& body)
{
return { status, "application/json", body };
}
HttpControlServer::HttpResponse HttpControlServer::TextResponse(const std::string& status, const std::string& body)
{
return { status, "text/plain", body };
}
HttpControlServer::HttpResponse HttpControlServer::HtmlResponse(const std::string& status, const std::string& body)
{
return { status, "text/html", body };
}
std::string HttpControlServer::ActionResponse(bool ok, const std::string& error)
{
JsonWriter writer;
writer.BeginObject();
writer.KeyBool("ok", ok);
if (!error.empty())
writer.KeyString("error", error);
writer.EndObject();
return writer.StringValue();
}
std::string HttpControlServer::GuessContentType(const std::filesystem::path& path)
{
const std::string extension = ToLower(path.extension().string());
if (extension == ".yaml" || extension == ".yml")
return "application/yaml";
if (extension == ".json")
return "application/json";
if (extension == ".js" || extension == ".mjs")
return "text/javascript";
if (extension == ".css")
return "text/css";
if (extension == ".html" || extension == ".htm")
return "text/html";
if (extension == ".svg")
return "image/svg+xml";
if (extension == ".png")
return "image/png";
if (extension == ".jpg" || extension == ".jpeg")
return "image/jpeg";
if (extension == ".ico")
return "image/x-icon";
if (extension == ".map")
return "application/json";
return "text/plain";
}
bool HttpControlServer::IsSafeRelativePath(const std::filesystem::path& path)
{
if (path.empty() || path.is_absolute())
return false;
for (const std::filesystem::path& part : path)
{
if (part == "..")
return false;
}
return true;
}
std::string HttpControlServer::ToLower(std::string text)
{
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
return text;
}
}

View File

@@ -0,0 +1,248 @@
#include "HttpControlServer.h"
#include <array>
#include <cstdint>
#include <sstream>
#include <utility>
#include <vector>
namespace RenderCadenceCompositor
{
namespace
{
std::array<uint8_t, 20> Sha1(const std::string& input)
{
auto leftRotate = [](uint32_t value, uint32_t bits) {
return (value << bits) | (value >> (32U - bits));
};
std::vector<uint8_t> data(input.begin(), input.end());
const uint64_t bitLength = static_cast<uint64_t>(data.size()) * 8ULL;
data.push_back(0x80);
while ((data.size() % 64) != 56)
data.push_back(0);
for (int shift = 56; shift >= 0; shift -= 8)
data.push_back(static_cast<uint8_t>((bitLength >> shift) & 0xff));
uint32_t h0 = 0x67452301;
uint32_t h1 = 0xefcdab89;
uint32_t h2 = 0x98badcfe;
uint32_t h3 = 0x10325476;
uint32_t h4 = 0xc3d2e1f0;
for (std::size_t offset = 0; offset < data.size(); offset += 64)
{
uint32_t words[80] = {};
for (std::size_t i = 0; i < 16; ++i)
{
const std::size_t index = offset + i * 4;
words[i] = (static_cast<uint32_t>(data[index]) << 24)
| (static_cast<uint32_t>(data[index + 1]) << 16)
| (static_cast<uint32_t>(data[index + 2]) << 8)
| static_cast<uint32_t>(data[index + 3]);
}
for (std::size_t i = 16; i < 80; ++i)
words[i] = leftRotate(words[i - 3] ^ words[i - 8] ^ words[i - 14] ^ words[i - 16], 1);
uint32_t a = h0;
uint32_t b = h1;
uint32_t c = h2;
uint32_t d = h3;
uint32_t e = h4;
for (std::size_t i = 0; i < 80; ++i)
{
uint32_t f = 0;
uint32_t k = 0;
if (i < 20)
{
f = (b & c) | ((~b) & d);
k = 0x5a827999;
}
else if (i < 40)
{
f = b ^ c ^ d;
k = 0x6ed9eba1;
}
else if (i < 60)
{
f = (b & c) | (b & d) | (c & d);
k = 0x8f1bbcdc;
}
else
{
f = b ^ c ^ d;
k = 0xca62c1d6;
}
const uint32_t temp = leftRotate(a, 5) + f + e + k + words[i];
e = d;
d = c;
c = leftRotate(b, 30);
b = a;
a = temp;
}
h0 += a;
h1 += b;
h2 += c;
h3 += d;
h4 += e;
}
std::array<uint8_t, 20> digest = {};
const uint32_t parts[] = { h0, h1, h2, h3, h4 };
for (std::size_t i = 0; i < 5; ++i)
{
digest[i * 4] = static_cast<uint8_t>((parts[i] >> 24) & 0xff);
digest[i * 4 + 1] = static_cast<uint8_t>((parts[i] >> 16) & 0xff);
digest[i * 4 + 2] = static_cast<uint8_t>((parts[i] >> 8) & 0xff);
digest[i * 4 + 3] = static_cast<uint8_t>(parts[i] & 0xff);
}
return digest;
}
std::string Base64Encode(const uint8_t* data, std::size_t size)
{
static constexpr char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string output;
output.reserve(((size + 2) / 3) * 4);
for (std::size_t i = 0; i < size; i += 3)
{
const uint32_t a = data[i];
const uint32_t b = i + 1 < size ? data[i + 1] : 0;
const uint32_t c = i + 2 < size ? data[i + 2] : 0;
const uint32_t triple = (a << 16) | (b << 8) | c;
output.push_back(kAlphabet[(triple >> 18) & 0x3f]);
output.push_back(kAlphabet[(triple >> 12) & 0x3f]);
output.push_back(i + 1 < size ? kAlphabet[(triple >> 6) & 0x3f] : '=');
output.push_back(i + 2 < size ? kAlphabet[triple & 0x3f] : '=');
}
return output;
}
}
bool HttpControlServer::HandleWebSocketClient(UniqueSocket clientSocket, const HttpRequest& request)
{
const auto keyIt = request.headers.find("sec-websocket-key");
if (keyIt == request.headers.end() || keyIt->second.empty())
return SendResponse(clientSocket.get(), TextResponse("400 Bad Request", "Missing WebSocket key"));
std::ostringstream stream;
stream << "HTTP/1.1 101 Switching Protocols\r\n"
<< "Upgrade: websocket\r\n"
<< "Connection: Upgrade\r\n"
<< "Sec-WebSocket-Accept: " << WebSocketAcceptKey(keyIt->second) << "\r\n\r\n";
const std::string response = stream.str();
if (send(clientSocket.get(), response.c_str(), static_cast<int>(response.size()), 0) != static_cast<int>(response.size()))
return false;
u_long nonBlocking = 1;
ioctlsocket(clientSocket.get(), FIONBIO, &nonBlocking);
std::thread thread([this, socket = std::move(clientSocket)]() mutable {
WebSocketClientMain(std::move(socket));
});
{
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
mClientThreads.push_back(std::move(thread));
}
return true;
}
void HttpControlServer::WebSocketClientMain(UniqueSocket clientSocket)
{
std::string previousState;
while (mRunning.load(std::memory_order_acquire))
{
const std::string state = mCallbacks.getStateJson ? mCallbacks.getStateJson() : "{}";
if (state != previousState)
{
if (!SendWebSocketText(clientSocket.get(), state))
break;
previousState = state;
}
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
const std::thread::id currentId = std::this_thread::get_id();
for (auto it = mClientThreads.begin(); it != mClientThreads.end(); ++it)
{
if (it->get_id() != currentId)
continue;
mFinishedClientThreads.push_back(std::move(*it));
mClientThreads.erase(it);
break;
}
}
void HttpControlServer::JoinFinishedClientThreads()
{
std::vector<std::thread> finished;
{
std::lock_guard<std::mutex> lock(mClientThreadsMutex);
finished.swap(mFinishedClientThreads);
}
for (std::thread& thread : finished)
{
if (thread.joinable())
thread.join();
}
}
bool HttpControlServer::SendWebSocketText(SOCKET clientSocket, const std::string& text)
{
if (clientSocket == INVALID_SOCKET)
return false;
std::vector<unsigned char> frame;
frame.reserve(text.size() + 16);
frame.push_back(0x81);
if (text.size() <= 125)
{
frame.push_back(static_cast<unsigned char>(text.size()));
}
else if (text.size() <= 0xffff)
{
frame.push_back(126);
frame.push_back(static_cast<unsigned char>((text.size() >> 8) & 0xff));
frame.push_back(static_cast<unsigned char>(text.size() & 0xff));
}
else
{
frame.push_back(127);
const uint64_t length = static_cast<uint64_t>(text.size());
for (int shift = 56; shift >= 0; shift -= 8)
frame.push_back(static_cast<unsigned char>((length >> shift) & 0xff));
}
frame.insert(frame.end(), text.begin(), text.end());
const char* data = reinterpret_cast<const char*>(frame.data());
int remaining = static_cast<int>(frame.size());
while (remaining > 0)
{
const int sent = send(clientSocket, data, remaining, 0);
if (sent <= 0)
{
const int error = WSAGetLastError();
if (error == WSAEWOULDBLOCK)
{
std::this_thread::sleep_for(std::chrono::milliseconds(2));
continue;
}
return false;
}
data += sent;
remaining -= sent;
}
return true;
}
std::string HttpControlServer::WebSocketAcceptKey(const std::string& clientKey)
{
static constexpr const char* kWebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const std::array<uint8_t, 20> digest = Sha1(clientKey + kWebSocketGuid);
return Base64Encode(digest.data(), digest.size());
}
}

View File

@@ -0,0 +1,250 @@
#include "InputFrameMailbox.h"
#include <algorithm>
#include <chrono>
#include <cstring>
namespace
{
InputFrameMailboxConfig NormalizeConfig(InputFrameMailboxConfig config)
{
if (config.rowBytes == 0)
config.rowBytes = VideoIORowBytes(config.pixelFormat, config.width);
return config;
}
}
InputFrameMailbox::InputFrameMailbox(const InputFrameMailboxConfig& config)
{
Configure(config);
}
void InputFrameMailbox::Configure(const InputFrameMailboxConfig& config)
{
std::lock_guard<std::mutex> lock(mMutex);
mConfig = NormalizeConfig(config);
mReadyIndices.clear();
mSlots.clear();
mSlots.resize(mConfig.capacity);
const std::size_t byteCount = FrameByteCount();
for (Slot& slot : mSlots)
{
slot.bytes.resize(byteCount);
slot.state = InputFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
}
mCounters = InputFrameMailboxMetrics();
}
InputFrameMailboxConfig InputFrameMailbox::Config() const
{
std::lock_guard<std::mutex> lock(mMutex);
return mConfig;
}
bool InputFrameMailbox::SubmitFrame(const void* bytes, unsigned rowBytes, uint64_t frameIndex)
{
if (bytes == nullptr || rowBytes == 0)
return false;
std::lock_guard<std::mutex> lock(mMutex);
if (mSlots.empty() || mConfig.width == 0 || mConfig.height == 0)
return false;
std::size_t slotIndex = mSlots.size();
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
if (mSlots[index].state == InputFrameSlotState::Free)
{
slotIndex = index;
break;
}
}
if (slotIndex == mSlots.size())
{
if (!DropOldestReadyLocked())
{
++mCounters.submitMisses;
return false;
}
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
if (mSlots[index].state == InputFrameSlotState::Free)
{
slotIndex = index;
break;
}
}
}
if (slotIndex == mSlots.size())
{
++mCounters.submitMisses;
return false;
}
Slot& slot = mSlots[slotIndex];
const std::size_t destinationRowBytes = mConfig.rowBytes;
const std::size_t sourceRowBytes = static_cast<std::size_t>(rowBytes);
const unsigned char* source = static_cast<const unsigned char*>(bytes);
if (sourceRowBytes == destinationRowBytes)
{
std::memcpy(slot.bytes.data(), source, destinationRowBytes * static_cast<std::size_t>(mConfig.height));
}
else
{
const std::size_t copyRowBytes = (std::min)(sourceRowBytes, destinationRowBytes);
for (unsigned y = 0; y < mConfig.height; ++y)
{
std::memcpy(
slot.bytes.data() + static_cast<std::size_t>(y) * destinationRowBytes,
source + static_cast<std::size_t>(y) * sourceRowBytes,
copyRowBytes);
}
}
slot.state = InputFrameSlotState::Ready;
slot.frameIndex = frameIndex;
++slot.generation;
mReadyIndices.push_back(slotIndex);
TrimReadyFramesLocked();
++mCounters.submittedFrames;
mCounters.latestFrameIndex = frameIndex;
mCounters.hasSubmittedFrame = true;
mLatestSubmitTime = std::chrono::steady_clock::now();
return true;
}
bool InputFrameMailbox::TryAcquireOldest(InputFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
while (!mReadyIndices.empty())
{
const std::size_t index = mReadyIndices.front();
mReadyIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != InputFrameSlotState::Ready)
continue;
mSlots[index].state = InputFrameSlotState::Reading;
FillFrameLocked(index, frame);
++mCounters.consumedFrames;
return true;
}
frame = InputFrame();
++mCounters.consumeMisses;
return false;
}
bool InputFrameMailbox::Release(const InputFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!IsValidLocked(frame))
return false;
Slot& slot = mSlots[frame.index];
if (slot.state != InputFrameSlotState::Reading)
return false;
slot.state = InputFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
return true;
}
void InputFrameMailbox::Clear()
{
std::lock_guard<std::mutex> lock(mMutex);
mReadyIndices.clear();
for (Slot& slot : mSlots)
{
slot.state = InputFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
}
}
InputFrameMailboxMetrics InputFrameMailbox::Metrics() const
{
std::lock_guard<std::mutex> lock(mMutex);
InputFrameMailboxMetrics metrics = mCounters;
metrics.capacity = mSlots.size();
if (metrics.hasSubmittedFrame)
{
metrics.latestFrameAgeMilliseconds = std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(
std::chrono::steady_clock::now() - mLatestSubmitTime).count();
}
for (const Slot& slot : mSlots)
{
switch (slot.state)
{
case InputFrameSlotState::Free:
++metrics.freeCount;
break;
case InputFrameSlotState::Ready:
++metrics.readyCount;
break;
case InputFrameSlotState::Reading:
++metrics.readingCount;
break;
}
}
return metrics;
}
bool InputFrameMailbox::IsValidLocked(const InputFrame& frame) const
{
return frame.index < mSlots.size() && mSlots[frame.index].generation == frame.generation;
}
void InputFrameMailbox::FillFrameLocked(std::size_t index, InputFrame& frame) const
{
const Slot& slot = mSlots[index];
frame.bytes = slot.bytes.empty() ? nullptr : slot.bytes.data();
frame.rowBytes = static_cast<long>(mConfig.rowBytes);
frame.width = mConfig.width;
frame.height = mConfig.height;
frame.pixelFormat = mConfig.pixelFormat;
frame.index = index;
frame.generation = slot.generation;
frame.frameIndex = slot.frameIndex;
}
bool InputFrameMailbox::DropOldestReadyLocked()
{
while (!mReadyIndices.empty())
{
const std::size_t index = mReadyIndices.front();
mReadyIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != InputFrameSlotState::Ready)
continue;
mSlots[index].state = InputFrameSlotState::Free;
mSlots[index].frameIndex = 0;
++mSlots[index].generation;
++mCounters.droppedReadyFrames;
return true;
}
return false;
}
void InputFrameMailbox::TrimReadyFramesLocked()
{
if (mConfig.maxReadyFrames == 0)
return;
while (mReadyIndices.size() > mConfig.maxReadyFrames)
DropOldestReadyLocked();
}
std::size_t InputFrameMailbox::FrameByteCount() const
{
return static_cast<std::size_t>(mConfig.rowBytes) * static_cast<std::size_t>(mConfig.height);
}

View File

@@ -0,0 +1,93 @@
#pragma once
#include "VideoIOFormat.h"
#include <cstddef>
#include <cstdint>
#include <chrono>
#include <deque>
#include <mutex>
#include <vector>
enum class InputFrameSlotState
{
Free,
Ready,
Reading
};
struct InputFrameMailboxConfig
{
unsigned width = 0;
unsigned height = 0;
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
unsigned rowBytes = 0;
std::size_t capacity = 0;
std::size_t maxReadyFrames = 0;
};
struct InputFrame
{
const void* bytes = nullptr;
long rowBytes = 0;
unsigned width = 0;
unsigned height = 0;
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
std::size_t index = 0;
uint64_t generation = 0;
uint64_t frameIndex = 0;
};
struct InputFrameMailboxMetrics
{
std::size_t capacity = 0;
std::size_t freeCount = 0;
std::size_t readyCount = 0;
std::size_t readingCount = 0;
uint64_t submittedFrames = 0;
uint64_t consumedFrames = 0;
uint64_t droppedReadyFrames = 0;
uint64_t submitMisses = 0;
uint64_t consumeMisses = 0;
uint64_t latestFrameIndex = 0;
bool hasSubmittedFrame = false;
double latestFrameAgeMilliseconds = 0.0;
};
class InputFrameMailbox
{
public:
InputFrameMailbox() = default;
explicit InputFrameMailbox(const InputFrameMailboxConfig& config);
void Configure(const InputFrameMailboxConfig& config);
InputFrameMailboxConfig Config() const;
bool SubmitFrame(const void* bytes, unsigned rowBytes, uint64_t frameIndex);
bool TryAcquireOldest(InputFrame& frame);
bool Release(const InputFrame& frame);
void Clear();
InputFrameMailboxMetrics Metrics() const;
private:
struct Slot
{
std::vector<unsigned char> bytes;
InputFrameSlotState state = InputFrameSlotState::Free;
uint64_t frameIndex = 0;
uint64_t generation = 0;
};
bool IsValidLocked(const InputFrame& frame) const;
void FillFrameLocked(std::size_t index, InputFrame& frame) const;
bool DropOldestReadyLocked();
void TrimReadyFramesLocked();
std::size_t FrameByteCount() const;
mutable std::mutex mMutex;
InputFrameMailboxConfig mConfig;
std::vector<Slot> mSlots;
std::deque<std::size_t> mReadyIndices;
InputFrameMailboxMetrics mCounters;
std::chrono::steady_clock::time_point mLatestSubmitTime;
};

View File

@@ -0,0 +1,299 @@
#include "SystemFrameExchange.h"
namespace
{
SystemFrameExchangeConfig NormalizeConfig(SystemFrameExchangeConfig config)
{
if (config.rowBytes == 0)
config.rowBytes = VideoIORowBytes(config.pixelFormat, config.width);
return config;
}
}
SystemFrameExchange::SystemFrameExchange(const SystemFrameExchangeConfig& config)
{
Configure(config);
}
void SystemFrameExchange::Configure(const SystemFrameExchangeConfig& config)
{
std::lock_guard<std::mutex> lock(mMutex);
mConfig = NormalizeConfig(config);
mCompletedIndices.clear();
mSlots.clear();
mSlots.resize(mConfig.capacity);
const std::size_t byteCount = FrameByteCount();
for (Slot& slot : mSlots)
{
slot.bytes.resize(byteCount);
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
}
mCounters = SystemFrameExchangeMetrics();
mCondition.notify_all();
}
SystemFrameExchangeConfig SystemFrameExchange::Config() const
{
std::lock_guard<std::mutex> lock(mMutex);
return mConfig;
}
bool SystemFrameExchange::AcquireForRender(SystemFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!AcquireFreeLocked(frame))
{
frame = SystemFrame();
++mCounters.acquireMisses;
return false;
}
++mCounters.acquiredFrames;
return true;
}
bool SystemFrameExchange::PublishCompleted(const SystemFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!IsValidLocked(frame))
return false;
Slot& slot = mSlots[frame.index];
if (slot.state != SystemFrameSlotState::Rendering)
return false;
slot.state = SystemFrameSlotState::Completed;
slot.frameIndex = frame.frameIndex;
mCompletedIndices.push_back(frame.index);
TrimCompletedLocked();
++mCounters.completedFrames;
mCondition.notify_all();
return true;
}
bool SystemFrameExchange::ConsumeCompletedForSchedule(SystemFrame& frame)
{
std::lock_guard<std::mutex> lock(mMutex);
while (!mCompletedIndices.empty())
{
const std::size_t index = mCompletedIndices.front();
mCompletedIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != SystemFrameSlotState::Completed)
continue;
mSlots[index].state = SystemFrameSlotState::Scheduled;
FillFrameLocked(index, frame);
++mCounters.scheduledFrames;
return true;
}
frame = SystemFrame();
++mCounters.completedPollMisses;
return false;
}
bool SystemFrameExchange::ReleaseScheduledByBytes(void* bytes)
{
if (bytes == nullptr)
return false;
std::lock_guard<std::mutex> lock(mMutex);
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
Slot& slot = mSlots[index];
if (slot.bytes.empty() || slot.bytes.data() != bytes)
continue;
if (slot.state != SystemFrameSlotState::Scheduled)
return false;
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
mCondition.notify_all();
return true;
}
return false;
}
bool SystemFrameExchange::WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(mMutex);
return mCondition.wait_for(lock, timeout, [&]() {
return CompletedCountLocked() >= targetDepth;
});
}
bool SystemFrameExchange::WaitForStableCompletedDepth(
std::size_t targetDepth,
std::chrono::milliseconds stableDuration,
std::chrono::milliseconds timeout)
{
if (targetDepth == 0)
return true;
const auto deadline = std::chrono::steady_clock::now() + timeout;
std::unique_lock<std::mutex> lock(mMutex);
bool stableWindowStarted = false;
std::chrono::steady_clock::time_point stableSince;
while (true)
{
const auto now = std::chrono::steady_clock::now();
if (now >= deadline)
return false;
if (CompletedCountLocked() >= targetDepth)
{
if (stableDuration <= std::chrono::milliseconds::zero())
return true;
if (!stableWindowStarted)
{
stableSince = now;
stableWindowStarted = true;
}
const auto stableDeadline = stableSince + stableDuration;
if (now >= stableDeadline)
return true;
mCondition.wait_until(lock, stableDeadline < deadline ? stableDeadline : deadline);
continue;
}
stableWindowStarted = false;
mCondition.wait_until(lock, deadline, [&]() {
return CompletedCountLocked() >= targetDepth;
});
}
}
void SystemFrameExchange::Clear()
{
std::lock_guard<std::mutex> lock(mMutex);
mCompletedIndices.clear();
for (Slot& slot : mSlots)
{
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
}
mCondition.notify_all();
}
SystemFrameExchangeMetrics SystemFrameExchange::Metrics() const
{
std::lock_guard<std::mutex> lock(mMutex);
SystemFrameExchangeMetrics metrics = mCounters;
metrics.capacity = mSlots.size();
metrics.completedDepth = mCompletedIndices.size();
for (const Slot& slot : mSlots)
{
switch (slot.state)
{
case SystemFrameSlotState::Free:
++metrics.freeCount;
break;
case SystemFrameSlotState::Rendering:
++metrics.renderingCount;
break;
case SystemFrameSlotState::Completed:
++metrics.completedCount;
break;
case SystemFrameSlotState::Scheduled:
++metrics.scheduledCount;
break;
}
}
return metrics;
}
bool SystemFrameExchange::AcquireFreeLocked(SystemFrame& frame)
{
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
Slot& slot = mSlots[index];
if (slot.state != SystemFrameSlotState::Free)
continue;
slot.state = SystemFrameSlotState::Rendering;
++slot.generation;
FillFrameLocked(index, frame);
return true;
}
return false;
}
bool SystemFrameExchange::DropOldestCompletedLocked()
{
while (!mCompletedIndices.empty())
{
const std::size_t index = mCompletedIndices.front();
mCompletedIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != SystemFrameSlotState::Completed)
continue;
Slot& slot = mSlots[index];
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
++mCounters.completedDrops;
mCondition.notify_all();
return true;
}
return false;
}
void SystemFrameExchange::TrimCompletedLocked()
{
if (mConfig.maxCompletedFrames == 0)
return;
while (CompletedCountLocked() > mConfig.maxCompletedFrames)
{
if (!DropOldestCompletedLocked())
return;
}
}
bool SystemFrameExchange::IsValidLocked(const SystemFrame& frame) const
{
return frame.index < mSlots.size() && mSlots[frame.index].generation == frame.generation;
}
void SystemFrameExchange::FillFrameLocked(std::size_t index, SystemFrame& frame)
{
Slot& slot = mSlots[index];
frame.bytes = slot.bytes.empty() ? nullptr : slot.bytes.data();
frame.rowBytes = static_cast<long>(mConfig.rowBytes);
frame.width = mConfig.width;
frame.height = mConfig.height;
frame.pixelFormat = mConfig.pixelFormat;
frame.index = index;
frame.generation = slot.generation;
frame.frameIndex = slot.frameIndex;
}
std::size_t SystemFrameExchange::CompletedCountLocked() const
{
std::size_t count = 0;
for (const Slot& slot : mSlots)
{
if (slot.state == SystemFrameSlotState::Completed)
++count;
}
return count;
}
std::size_t SystemFrameExchange::FrameByteCount() const
{
return static_cast<std::size_t>(mConfig.rowBytes) * static_cast<std::size_t>(mConfig.height);
}

View File

@@ -0,0 +1,56 @@
#pragma once
#include "SystemFrameTypes.h"
#include <chrono>
#include <condition_variable>
#include <deque>
#include <mutex>
#include <vector>
class SystemFrameExchange
{
public:
SystemFrameExchange() = default;
explicit SystemFrameExchange(const SystemFrameExchangeConfig& config);
void Configure(const SystemFrameExchangeConfig& config);
SystemFrameExchangeConfig Config() const;
bool AcquireForRender(SystemFrame& frame);
bool PublishCompleted(const SystemFrame& frame);
bool ConsumeCompletedForSchedule(SystemFrame& frame);
bool ReleaseScheduledByBytes(void* bytes);
bool WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout);
bool WaitForStableCompletedDepth(
std::size_t targetDepth,
std::chrono::milliseconds stableDuration,
std::chrono::milliseconds timeout);
void Clear();
SystemFrameExchangeMetrics Metrics() const;
private:
struct Slot
{
std::vector<unsigned char> bytes;
SystemFrameSlotState state = SystemFrameSlotState::Free;
uint64_t generation = 1;
uint64_t frameIndex = 0;
};
bool AcquireFreeLocked(SystemFrame& frame);
bool DropOldestCompletedLocked();
void TrimCompletedLocked();
bool IsValidLocked(const SystemFrame& frame) const;
void FillFrameLocked(std::size_t index, SystemFrame& frame);
std::size_t CompletedCountLocked() const;
std::size_t FrameByteCount() const;
mutable std::mutex mMutex;
std::condition_variable mCondition;
SystemFrameExchangeConfig mConfig;
std::vector<Slot> mSlots;
std::deque<std::size_t> mCompletedIndices;
SystemFrameExchangeMetrics mCounters;
};

View File

@@ -0,0 +1,52 @@
#pragma once
#include "VideoIOFormat.h"
#include <cstddef>
#include <cstdint>
enum class SystemFrameSlotState
{
Free,
Rendering,
Completed,
Scheduled
};
struct SystemFrameExchangeConfig
{
unsigned width = 0;
unsigned height = 0;
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
unsigned rowBytes = 0;
std::size_t capacity = 0;
std::size_t maxCompletedFrames = 0;
};
struct SystemFrame
{
void* bytes = nullptr;
long rowBytes = 0;
unsigned width = 0;
unsigned height = 0;
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
std::size_t index = 0;
uint64_t generation = 0;
uint64_t frameIndex = 0;
};
struct SystemFrameExchangeMetrics
{
std::size_t capacity = 0;
std::size_t freeCount = 0;
std::size_t renderingCount = 0;
std::size_t completedCount = 0;
std::size_t scheduledCount = 0;
std::size_t completedDepth = 0;
uint64_t acquiredFrames = 0;
uint64_t completedFrames = 0;
uint64_t scheduledFrames = 0;
uint64_t completedDrops = 0;
uint64_t acquireMisses = 0;
uint64_t completedPollMisses = 0;
};

View File

@@ -0,0 +1,235 @@
#include "JsonWriter.h"
#include <iomanip>
#include <stdexcept>
namespace RenderCadenceCompositor
{
namespace
{
constexpr int kMaxDepth = 32;
void AppendHexEscape(std::ostringstream& stream, unsigned char value)
{
stream << "\\u"
<< std::hex << std::uppercase << std::setw(4) << std::setfill('0')
<< static_cast<int>(value)
<< std::dec << std::nouppercase << std::setfill(' ');
}
}
void JsonWriter::BeginObject()
{
BeginValue();
mStream << "{";
PushScope(ScopeKind::Object);
}
void JsonWriter::EndObject()
{
PopScope(ScopeKind::Object);
mStream << "}";
}
void JsonWriter::BeginArray()
{
BeginValue();
mStream << "[";
PushScope(ScopeKind::Array);
}
void JsonWriter::EndArray()
{
PopScope(ScopeKind::Array);
mStream << "]";
}
void JsonWriter::Key(const std::string& name)
{
BeginKey();
mStream << "\"" << EscapeString(name) << "\":";
}
void JsonWriter::String(const std::string& value)
{
BeginValue();
mStream << "\"" << EscapeString(value) << "\"";
}
void JsonWriter::Bool(bool value)
{
BeginValue();
mStream << (value ? "true" : "false");
}
void JsonWriter::Null()
{
BeginValue();
mStream << "null";
}
void JsonWriter::Int(int64_t value)
{
BeginValue();
mStream << value;
}
void JsonWriter::UInt(uint64_t value)
{
BeginValue();
mStream << value;
}
void JsonWriter::Double(double value)
{
BeginValue();
mStream << std::setprecision(15) << value;
}
void JsonWriter::KeyString(const std::string& name, const std::string& value)
{
Key(name);
String(value);
}
void JsonWriter::KeyBool(const std::string& name, bool value)
{
Key(name);
Bool(value);
}
void JsonWriter::KeyNull(const std::string& name)
{
Key(name);
Null();
}
void JsonWriter::KeyInt(const std::string& name, int64_t value)
{
Key(name);
Int(value);
}
void JsonWriter::KeyUInt(const std::string& name, uint64_t value)
{
Key(name);
UInt(value);
}
void JsonWriter::KeyDouble(const std::string& name, double value)
{
Key(name);
Double(value);
}
std::string JsonWriter::StringValue() const
{
if (mScopeDepth != 0)
throw std::logic_error("JSON document has unclosed scopes.");
return mStream.str();
}
void JsonWriter::Reset()
{
mStream.str(std::string());
mStream.clear();
mScopeDepth = 0;
}
std::string JsonWriter::EscapeString(const std::string& value)
{
std::ostringstream stream;
for (unsigned char character : value)
{
switch (character)
{
case '"':
stream << "\\\"";
break;
case '\\':
stream << "\\\\";
break;
case '\b':
stream << "\\b";
break;
case '\f':
stream << "\\f";
break;
case '\n':
stream << "\\n";
break;
case '\r':
stream << "\\r";
break;
case '\t':
stream << "\\t";
break;
default:
if (character < 0x20)
AppendHexEscape(stream, character);
else
stream << character;
break;
}
}
return stream.str();
}
void JsonWriter::BeginValue()
{
if (mScopeDepth == 0)
return;
Scope& scope = mScopes[mScopeDepth - 1];
if (scope.kind == ScopeKind::Object)
{
if (!scope.expectingValue)
throw std::logic_error("JSON object value must follow a key.");
scope.expectingValue = false;
return;
}
if (!scope.first)
mStream << ",";
scope.first = false;
}
void JsonWriter::BeginKey()
{
if (mScopeDepth == 0)
throw std::logic_error("JSON key cannot be written outside an object.");
Scope& scope = mScopes[mScopeDepth - 1];
if (scope.kind != ScopeKind::Object)
throw std::logic_error("JSON key cannot be written inside an array.");
if (scope.expectingValue)
throw std::logic_error("JSON object key cannot be written before its previous value.");
if (!scope.first)
mStream << ",";
scope.first = false;
scope.expectingValue = true;
}
void JsonWriter::PushScope(ScopeKind kind)
{
if (mScopeDepth >= kMaxDepth)
throw std::logic_error("JSON nesting is too deep.");
mScopes[mScopeDepth++] = Scope{ kind, true, false };
}
void JsonWriter::PopScope(ScopeKind kind)
{
if (mScopeDepth == 0)
throw std::logic_error("JSON scope underflow.");
Scope& scope = mScopes[mScopeDepth - 1];
if (scope.kind != kind)
throw std::logic_error("JSON scope kind mismatch.");
if (scope.expectingValue)
throw std::logic_error("JSON object key is missing a value.");
--mScopeDepth;
}
}

View File

@@ -0,0 +1,60 @@
#pragma once
#include <cstdint>
#include <sstream>
#include <string>
namespace RenderCadenceCompositor
{
class JsonWriter
{
public:
void BeginObject();
void EndObject();
void BeginArray();
void EndArray();
void Key(const std::string& name);
void String(const std::string& value);
void Bool(bool value);
void Null();
void Int(int64_t value);
void UInt(uint64_t value);
void Double(double value);
void KeyString(const std::string& name, const std::string& value);
void KeyBool(const std::string& name, bool value);
void KeyNull(const std::string& name);
void KeyInt(const std::string& name, int64_t value);
void KeyUInt(const std::string& name, uint64_t value);
void KeyDouble(const std::string& name, double value);
std::string StringValue() const;
void Reset();
static std::string EscapeString(const std::string& value);
private:
enum class ScopeKind
{
Object,
Array
};
struct Scope
{
ScopeKind kind = ScopeKind::Object;
bool first = true;
bool expectingValue = false;
};
void BeginValue();
void BeginKey();
void PushScope(ScopeKind kind);
void PopScope(ScopeKind kind);
std::ostringstream mStream;
Scope mScopes[32];
int mScopeDepth = 0;
};
}

View File

@@ -0,0 +1,283 @@
#include "Logger.h"
#include <windows.h>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <ctime>
#include <filesystem>
namespace RenderCadenceCompositor
{
namespace
{
int Rank(LogLevel level)
{
switch (level)
{
case LogLevel::Log:
return 0;
case LogLevel::Warning:
return 1;
case LogLevel::Error:
return 2;
default:
return 0;
}
}
std::string FormatTimestamp(std::chrono::system_clock::time_point timestamp)
{
const std::time_t time = std::chrono::system_clock::to_time_t(timestamp);
std::tm localTime = {};
localtime_s(&localTime, &time);
std::ostringstream stream;
stream << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S");
return stream.str();
}
std::string FormatRecord(const Logger::Record& record)
{
std::ostringstream stream;
stream << FormatTimestamp(record.timestamp)
<< " [" << LogLevelName(record.level) << "]"
<< " [" << record.subsystem << "] "
<< record.message;
return stream.str();
}
}
const char* LogLevelName(LogLevel level)
{
switch (level)
{
case LogLevel::Log:
return "log";
case LogLevel::Warning:
return "warning";
case LogLevel::Error:
return "error";
default:
return "log";
}
}
Logger& Logger::Instance()
{
static Logger logger;
return logger;
}
Logger::~Logger()
{
Stop();
}
void Logger::Start(LoggerConfig config)
{
std::lock_guard<std::mutex> lock(mMutex);
if (mRunning)
return;
mConfig = config;
mStopping = false;
mRunning = true;
OpenFileSink();
{
std::lock_guard<std::mutex> countersLock(mCountersMutex);
mCounters = LoggerCounters();
}
mThread = std::thread([this]() { ThreadMain(); });
}
void Logger::Stop()
{
{
std::lock_guard<std::mutex> lock(mMutex);
if (!mRunning && !mThread.joinable())
return;
mStopping = true;
}
mCondition.notify_all();
if (mThread.joinable())
mThread.join();
std::lock_guard<std::mutex> lock(mMutex);
CloseFileSink();
mRunning = false;
}
void Logger::Write(LogLevel level, const std::string& subsystem, const std::string& message)
{
Enqueue(Record{ std::chrono::system_clock::now(), std::this_thread::get_id(), level, subsystem, message }, true);
}
bool Logger::TryWrite(LogLevel level, const std::string& subsystem, const std::string& message)
{
return Enqueue(Record{ std::chrono::system_clock::now(), std::this_thread::get_id(), level, subsystem, message }, false);
}
void Logger::Log(const std::string& subsystem, const std::string& message)
{
Write(LogLevel::Log, subsystem, message);
}
void Logger::Warning(const std::string& subsystem, const std::string& message)
{
Write(LogLevel::Warning, subsystem, message);
}
void Logger::Error(const std::string& subsystem, const std::string& message)
{
Write(LogLevel::Error, subsystem, message);
}
LoggerCounters Logger::Counters() const
{
std::lock_guard<std::mutex> lock(mCountersMutex);
return mCounters;
}
bool Logger::IsRunning() const
{
std::lock_guard<std::mutex> lock(mMutex);
return mRunning;
}
bool Logger::ShouldWrite(LogLevel level) const
{
return Rank(level) >= Rank(mConfig.minimumLevel);
}
bool Logger::Enqueue(Record record, bool block)
{
if (!ShouldWrite(record.level))
return true;
std::unique_lock<std::mutex> lock(mMutex, std::defer_lock);
if (block)
{
lock.lock();
}
else if (!lock.try_lock())
{
std::lock_guard<std::mutex> countersLock(mCountersMutex);
++mCounters.dropped;
return false;
}
if (!mRunning)
{
lock.unlock();
WriteRecord(record);
return true;
}
if (mQueue.size() >= mConfig.maxQueuedMessages)
{
std::lock_guard<std::mutex> countersLock(mCountersMutex);
++mCounters.dropped;
return false;
}
mQueue.push_back(std::move(record));
{
std::lock_guard<std::mutex> countersLock(mCountersMutex);
++mCounters.queued;
}
lock.unlock();
mCondition.notify_one();
return true;
}
void Logger::ThreadMain()
{
for (;;)
{
Record record;
{
std::unique_lock<std::mutex> lock(mMutex);
mCondition.wait(lock, [this]() {
return mStopping || !mQueue.empty();
});
if (mQueue.empty())
{
if (mStopping)
return;
continue;
}
record = std::move(mQueue.front());
mQueue.pop_front();
}
WriteRecord(record);
}
}
void Logger::WriteRecord(const Record& record)
{
const std::string line = FormatRecord(record);
if (mConfig.writeToDebugOutput)
OutputDebugStringA((line + "\n").c_str());
if (mConfig.writeToConsole)
{
if (record.level == LogLevel::Error)
std::cerr << line << "\n";
else
std::cout << line << "\n";
}
if (mFile.is_open())
{
mFile << line << "\n";
mFile.flush();
}
std::lock_guard<std::mutex> lock(mCountersMutex);
++mCounters.written;
}
void Logger::OpenFileSink()
{
CloseFileSink();
if (!mConfig.writeToFile || mConfig.filePath.empty())
return;
std::error_code error;
const std::filesystem::path logPath(mConfig.filePath);
if (logPath.has_parent_path())
std::filesystem::create_directories(logPath.parent_path(), error);
mFile.open(logPath, std::ios::out | std::ios::app);
}
void Logger::CloseFileSink()
{
if (mFile.is_open())
mFile.close();
}
bool TryLog(LogLevel level, const std::string& subsystem, const std::string& message)
{
return Logger::Instance().TryWrite(level, subsystem, message);
}
void Log(const std::string& subsystem, const std::string& message)
{
Logger::Instance().Log(subsystem, message);
}
void LogWarning(const std::string& subsystem, const std::string& message)
{
Logger::Instance().Warning(subsystem, message);
}
void LogError(const std::string& subsystem, const std::string& message)
{
Logger::Instance().Error(subsystem, message);
}
}

View File

@@ -0,0 +1,101 @@
#pragma once
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <fstream>
#include <mutex>
#include <string>
#include <thread>
namespace RenderCadenceCompositor
{
enum class LogLevel
{
Log,
Warning,
Error
};
struct LoggerConfig
{
LogLevel minimumLevel = LogLevel::Log;
bool writeToConsole = true;
bool writeToDebugOutput = true;
bool writeToFile = true;
std::string filePath = "logs/render-cadence-compositor.log";
std::size_t maxQueuedMessages = 1024;
};
struct LoggerCounters
{
uint64_t queued = 0;
uint64_t written = 0;
uint64_t dropped = 0;
};
const char* LogLevelName(LogLevel level);
class Logger
{
public:
struct Record
{
std::chrono::system_clock::time_point timestamp;
std::thread::id threadId;
LogLevel level = LogLevel::Log;
std::string subsystem;
std::string message;
};
static Logger& Instance();
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
~Logger();
void Start(LoggerConfig config = LoggerConfig());
void Stop();
void Write(LogLevel level, const std::string& subsystem, const std::string& message);
bool TryWrite(LogLevel level, const std::string& subsystem, const std::string& message);
void Log(const std::string& subsystem, const std::string& message);
void Warning(const std::string& subsystem, const std::string& message);
void Error(const std::string& subsystem, const std::string& message);
LoggerCounters Counters() const;
bool IsRunning() const;
private:
Logger() = default;
bool ShouldWrite(LogLevel level) const;
bool Enqueue(Record record, bool block);
void ThreadMain();
void WriteRecord(const Record& record);
void OpenFileSink();
void CloseFileSink();
LoggerConfig mConfig;
std::ofstream mFile;
std::thread mThread;
mutable std::mutex mMutex;
std::condition_variable mCondition;
std::deque<Record> mQueue;
bool mStopping = false;
bool mRunning = false;
mutable std::mutex mCountersMutex;
LoggerCounters mCounters;
};
bool TryLog(LogLevel level, const std::string& subsystem, const std::string& message);
void Log(const std::string& subsystem, const std::string& message);
void LogWarning(const std::string& subsystem, const std::string& message);
void LogError(const std::string& subsystem, const std::string& message);
}

View File

@@ -0,0 +1,134 @@
#include "HiddenGlWindow.h"
namespace
{
constexpr const char* kWindowClassName = "RenderCadenceCompositorHiddenGlWindow";
}
HiddenGlWindow::~HiddenGlWindow()
{
Destroy();
}
bool HiddenGlWindow::Create(unsigned width, unsigned height, std::string& error)
{
return CreateShared(width, height, nullptr, nullptr, error);
}
bool HiddenGlWindow::CreateShared(unsigned width, unsigned height, HDC sharedDeviceContext, HGLRC sharedContext, std::string& error)
{
Destroy();
mInstance = GetModuleHandle(nullptr);
WNDCLASSA wc = {};
wc.style = CS_OWNDC;
wc.lpfnWndProc = HiddenGlWindow::WindowProc;
wc.hInstance = mInstance;
wc.lpszClassName = kWindowClassName;
mClassAtom = RegisterClassA(&wc);
if (mClassAtom == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
{
error = "RegisterClassA failed for hidden OpenGL window.";
return false;
}
mWindow = CreateWindowA(
kWindowClassName,
"Render Cadence Compositor Hidden GL",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
static_cast<int>(width),
static_cast<int>(height),
nullptr,
nullptr,
mInstance,
nullptr);
if (!mWindow)
{
error = "CreateWindowA failed for hidden OpenGL window.";
return false;
}
mDc = GetDC(mWindow);
if (!mDc)
{
error = "GetDC failed for hidden OpenGL window.";
return false;
}
PIXELFORMATDESCRIPTOR pfd = {};
pfd.nSize = sizeof(pfd);
pfd.nVersion = 1;
pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER;
pfd.iPixelType = PFD_TYPE_RGBA;
pfd.cColorBits = 32;
pfd.cDepthBits = 0;
pfd.iLayerType = PFD_MAIN_PLANE;
int pixelFormat = 0;
if (sharedDeviceContext != nullptr)
pixelFormat = GetPixelFormat(sharedDeviceContext);
if (pixelFormat == 0)
pixelFormat = ChoosePixelFormat(mDc, &pfd);
if (pixelFormat == 0 || !SetPixelFormat(mDc, pixelFormat, &pfd))
{
error = "Could not choose/set pixel format for hidden OpenGL window.";
return false;
}
mGlrc = wglCreateContext(mDc);
if (!mGlrc)
{
error = "wglCreateContext failed for hidden OpenGL window.";
return false;
}
if (sharedContext != nullptr && wglShareLists(sharedContext, mGlrc) != TRUE)
{
error = "wglShareLists failed for hidden OpenGL shared context.";
return false;
}
return true;
}
bool HiddenGlWindow::MakeCurrent() const
{
return mDc != nullptr && mGlrc != nullptr && wglMakeCurrent(mDc, mGlrc) == TRUE;
}
void HiddenGlWindow::ClearCurrent() const
{
wglMakeCurrent(nullptr, nullptr);
}
void HiddenGlWindow::Destroy()
{
ClearCurrent();
if (mGlrc)
{
wglDeleteContext(mGlrc);
mGlrc = nullptr;
}
if (mWindow && mDc)
{
ReleaseDC(mWindow, mDc);
mDc = nullptr;
}
if (mWindow)
{
DestroyWindow(mWindow);
mWindow = nullptr;
}
mInstance = nullptr;
mClassAtom = 0;
}
LRESULT CALLBACK HiddenGlWindow::WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
return DefWindowProc(hwnd, message, wParam, lParam);
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <windows.h>
#include <string>
class HiddenGlWindow
{
public:
HiddenGlWindow() = default;
HiddenGlWindow(const HiddenGlWindow&) = delete;
HiddenGlWindow& operator=(const HiddenGlWindow&) = delete;
~HiddenGlWindow();
bool Create(unsigned width, unsigned height, std::string& error);
bool CreateShared(unsigned width, unsigned height, HDC sharedDeviceContext, HGLRC sharedContext, std::string& error);
bool MakeCurrent() const;
void ClearCurrent() const;
void Destroy();
HDC DeviceContext() const { return mDc; }
HGLRC Context() const { return mGlrc; }
private:
static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
HINSTANCE mInstance = nullptr;
HWND mWindow = nullptr;
HDC mDc = nullptr;
HGLRC mGlrc = nullptr;
ATOM mClassAtom = 0;
};

View File

@@ -0,0 +1,341 @@
#include "InputFrameTexture.h"
#include <chrono>
#ifndef GL_FRAMEBUFFER_BINDING
#define GL_FRAMEBUFFER_BINDING 0x8CA6
#endif
namespace
{
constexpr GLuint kUyvyTextureUnit = 0;
const char* kDecodeVertexShader = R"GLSL(
#version 430 core
out vec2 vTexCoord;
void main()
{
vec2 positions[3] = vec2[3](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0));
vec2 texCoords[3] = vec2[3](
vec2(0.0, 0.0),
vec2(2.0, 0.0),
vec2(0.0, 2.0));
gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
vTexCoord = texCoords[gl_VertexID];
}
)GLSL";
const char* kUyvyDecodeFragmentShader = R"GLSL(
#version 430 core
layout(binding = 0) uniform sampler2D uPackedUyvy;
uniform vec2 uDecodedSize;
in vec2 vTexCoord;
out vec4 fragColor;
vec4 rec709YCbCr2rgba(float yByte, float cbByte, float crByte)
{
float y = (yByte - 16.0) / 219.0;
float cb = (cbByte - 16.0) / 224.0 - 0.5;
float cr = (crByte - 16.0) / 224.0 - 0.5;
return vec4(
y + 1.5748 * cr,
y - 0.1873 * cb - 0.4681 * cr,
y + 1.8556 * cb,
1.0);
}
void main()
{
ivec2 decodedSize = ivec2(uDecodedSize);
ivec2 outputCoord = ivec2(clamp(gl_FragCoord.xy, vec2(0.0), vec2(decodedSize - ivec2(1))));
int sourceY = decodedSize.y - 1 - outputCoord.y;
ivec2 packedCoord = ivec2(clamp(outputCoord.x / 2, 0, max(decodedSize.x / 2 - 1, 0)), sourceY);
vec4 macroPixel = texelFetch(uPackedUyvy, packedCoord, 0) * 255.0;
float ySample = (outputCoord.x & 1) != 0 ? macroPixel.a : macroPixel.g;
fragColor = clamp(rec709YCbCr2rgba(ySample, macroPixel.r, macroPixel.b), vec4(0.0), vec4(1.0));
}
)GLSL";
}
InputFrameTexture::~InputFrameTexture()
{
ShutdownGl();
}
GLuint InputFrameTexture::PollAndUpload(InputFrameMailbox* mailbox)
{
if (mailbox == nullptr)
return mTexture;
InputFrame frame;
if (!mailbox->TryAcquireOldest(frame))
{
++mUploadMisses;
mLastUploadMilliseconds = 0.0;
return mTexture;
}
if (frame.bytes != nullptr && frame.pixelFormat == VideoIOPixelFormat::Bgra8 && EnsureTexture(frame))
{
mLastFrameFormatSupported = true;
const auto uploadStart = std::chrono::steady_clock::now();
UploadBgra8FrameFlippedVertically(frame);
const auto uploadEnd = std::chrono::steady_clock::now();
mLastUploadMilliseconds = std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(uploadEnd - uploadStart).count();
++mUploadedFrames;
}
else if (frame.bytes != nullptr && frame.pixelFormat == VideoIOPixelFormat::Uyvy8 && EnsureTexture(frame) && EnsureRawUyvyTexture(frame) && EnsureDecodeProgram())
{
mLastFrameFormatSupported = true;
const auto uploadStart = std::chrono::steady_clock::now();
UploadUyvy8Frame(frame);
DecodeUyvy8Frame(frame);
const auto uploadEnd = std::chrono::steady_clock::now();
mLastUploadMilliseconds = std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(uploadEnd - uploadStart).count();
++mUploadedFrames;
}
else
{
mLastFrameFormatSupported = frame.pixelFormat == VideoIOPixelFormat::Bgra8 || frame.pixelFormat == VideoIOPixelFormat::Uyvy8;
mLastUploadMilliseconds = 0.0;
}
mailbox->Release(frame);
return mTexture;
}
void InputFrameTexture::ShutdownGl()
{
if (mTexture != 0)
glDeleteTextures(1, &mTexture);
if (mRawTexture != 0)
glDeleteTextures(1, &mRawTexture);
mTexture = 0;
mRawTexture = 0;
mWidth = 0;
mHeight = 0;
mRawWidth = 0;
mRawHeight = 0;
DestroyDecodeResources();
}
bool InputFrameTexture::EnsureTexture(const InputFrame& frame)
{
if (frame.width == 0 || frame.height == 0)
return false;
if (mTexture != 0 && mWidth == frame.width && mHeight == frame.height)
return true;
ShutdownGl();
glGenTextures(1, &mTexture);
glBindTexture(GL_TEXTURE_2D, mTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
static_cast<GLsizei>(frame.width),
static_cast<GLsizei>(frame.height),
0,
GL_BGRA,
GL_UNSIGNED_INT_8_8_8_8_REV,
nullptr);
glBindTexture(GL_TEXTURE_2D, 0);
mWidth = frame.width;
mHeight = frame.height;
return mTexture != 0;
}
bool InputFrameTexture::EnsureRawUyvyTexture(const InputFrame& frame)
{
if (frame.width == 0 || frame.height == 0)
return false;
const unsigned rawWidth = (frame.width + 1u) / 2u;
if (mRawTexture != 0 && mRawWidth == rawWidth && mRawHeight == frame.height)
return true;
if (mRawTexture != 0)
glDeleteTextures(1, &mRawTexture);
mRawTexture = 0;
glGenTextures(1, &mRawTexture);
glBindTexture(GL_TEXTURE_2D, mRawTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
static_cast<GLsizei>(rawWidth),
static_cast<GLsizei>(frame.height),
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
nullptr);
glBindTexture(GL_TEXTURE_2D, 0);
mRawWidth = rawWidth;
mRawHeight = frame.height;
return mRawTexture != 0;
}
void InputFrameTexture::UploadBgra8FrameFlippedVertically(const InputFrame& frame)
{
glBindTexture(GL_TEXTURE_2D, mTexture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glPixelStorei(GL_UNPACK_ROW_LENGTH, frame.rowBytes > 0 ? static_cast<GLint>(frame.rowBytes / 4) : 0);
const unsigned char* sourceBytes = static_cast<const unsigned char*>(frame.bytes);
for (unsigned destinationY = 0; destinationY < frame.height; ++destinationY)
{
const unsigned sourceY = frame.height - 1u - destinationY;
const unsigned char* sourceRow = sourceBytes + static_cast<std::size_t>(sourceY) * static_cast<std::size_t>(frame.rowBytes);
glTexSubImage2D(
GL_TEXTURE_2D,
0,
0,
static_cast<GLint>(destinationY),
static_cast<GLsizei>(frame.width),
1,
GL_BGRA,
GL_UNSIGNED_INT_8_8_8_8_REV,
sourceRow);
}
glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
glBindTexture(GL_TEXTURE_2D, 0);
}
void InputFrameTexture::UploadUyvy8Frame(const InputFrame& frame)
{
glBindTexture(GL_TEXTURE_2D, mRawTexture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glPixelStorei(GL_UNPACK_ROW_LENGTH, frame.rowBytes > 0 ? static_cast<GLint>(frame.rowBytes / 4) : 0);
glTexSubImage2D(
GL_TEXTURE_2D,
0,
0,
0,
static_cast<GLsizei>((frame.width + 1u) / 2u),
static_cast<GLsizei>(frame.height),
GL_RGBA,
GL_UNSIGNED_BYTE,
frame.bytes);
glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
glBindTexture(GL_TEXTURE_2D, 0);
}
void InputFrameTexture::DecodeUyvy8Frame(const InputFrame& frame)
{
GLint previousFramebuffer = 0;
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &previousFramebuffer);
if (mDecodeFramebuffer == 0)
glGenFramebuffers(1, &mDecodeFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, mDecodeFramebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mTexture, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
glBindFramebuffer(GL_FRAMEBUFFER, static_cast<GLuint>(previousFramebuffer));
return;
}
glViewport(0, 0, static_cast<GLsizei>(frame.width), static_cast<GLsizei>(frame.height));
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(mDecodeProgram);
const GLint decodedSizeLocation = glGetUniformLocation(mDecodeProgram, "uDecodedSize");
if (decodedSizeLocation >= 0)
glUniform2f(decodedSizeLocation, static_cast<GLfloat>(frame.width), static_cast<GLfloat>(frame.height));
glActiveTexture(GL_TEXTURE0 + kUyvyTextureUnit);
glBindTexture(GL_TEXTURE_2D, mRawTexture);
glBindVertexArray(mDecodeVertexArray);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
glUseProgram(0);
glBindFramebuffer(GL_FRAMEBUFFER, static_cast<GLuint>(previousFramebuffer));
}
bool InputFrameTexture::EnsureDecodeProgram()
{
if (mDecodeProgram != 0)
return true;
if (!CompileShader(GL_VERTEX_SHADER, kDecodeVertexShader, mDecodeVertexShader))
return false;
if (!CompileShader(GL_FRAGMENT_SHADER, kUyvyDecodeFragmentShader, mDecodeFragmentShader))
return false;
if (!LinkProgram(mDecodeVertexShader, mDecodeFragmentShader, mDecodeProgram))
return false;
glUseProgram(mDecodeProgram);
const GLint samplerLocation = glGetUniformLocation(mDecodeProgram, "uPackedUyvy");
if (samplerLocation >= 0)
glUniform1i(samplerLocation, static_cast<GLint>(kUyvyTextureUnit));
glUseProgram(0);
if (mDecodeVertexArray == 0)
glGenVertexArrays(1, &mDecodeVertexArray);
return mDecodeProgram != 0 && mDecodeVertexArray != 0;
}
void InputFrameTexture::DestroyDecodeResources()
{
if (mDecodeFramebuffer != 0)
glDeleteFramebuffers(1, &mDecodeFramebuffer);
if (mDecodeVertexArray != 0)
glDeleteVertexArrays(1, &mDecodeVertexArray);
if (mDecodeProgram != 0)
glDeleteProgram(mDecodeProgram);
if (mDecodeVertexShader != 0)
glDeleteShader(mDecodeVertexShader);
if (mDecodeFragmentShader != 0)
glDeleteShader(mDecodeFragmentShader);
mDecodeFramebuffer = 0;
mDecodeVertexArray = 0;
mDecodeProgram = 0;
mDecodeVertexShader = 0;
mDecodeFragmentShader = 0;
}
bool InputFrameTexture::CompileShader(GLenum shaderType, const char* source, GLuint& shader)
{
shader = glCreateShader(shaderType);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compileResult = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compileResult);
if (compileResult != GL_FALSE)
return true;
glDeleteShader(shader);
shader = 0;
return false;
}
bool InputFrameTexture::LinkProgram(GLuint vertexShader, GLuint fragmentShader, GLuint& program)
{
program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
GLint linkResult = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linkResult);
if (linkResult != GL_FALSE)
return true;
glDeleteProgram(program);
program = 0;
return false;
}

View File

@@ -0,0 +1,50 @@
#pragma once
#include "../frames/InputFrameMailbox.h"
#include "GLExtensions.h"
#include <cstdint>
class InputFrameTexture
{
public:
InputFrameTexture() = default;
InputFrameTexture(const InputFrameTexture&) = delete;
InputFrameTexture& operator=(const InputFrameTexture&) = delete;
~InputFrameTexture();
GLuint PollAndUpload(InputFrameMailbox* mailbox);
GLuint Texture() const { return mTexture; }
uint64_t UploadedFrames() const { return mUploadedFrames; }
uint64_t UploadMisses() const { return mUploadMisses; }
double LastUploadMilliseconds() const { return mLastUploadMilliseconds; }
bool LastFrameFormatSupported() const { return mLastFrameFormatSupported; }
void ShutdownGl();
private:
bool EnsureTexture(const InputFrame& frame);
bool EnsureRawUyvyTexture(const InputFrame& frame);
bool EnsureDecodeProgram();
void UploadBgra8FrameFlippedVertically(const InputFrame& frame);
void UploadUyvy8Frame(const InputFrame& frame);
void DecodeUyvy8Frame(const InputFrame& frame);
void DestroyDecodeResources();
static bool CompileShader(GLenum shaderType, const char* source, GLuint& shader);
static bool LinkProgram(GLuint vertexShader, GLuint fragmentShader, GLuint& program);
GLuint mTexture = 0;
GLuint mRawTexture = 0;
GLuint mDecodeFramebuffer = 0;
GLuint mDecodeVertexArray = 0;
GLuint mDecodeProgram = 0;
GLuint mDecodeVertexShader = 0;
GLuint mDecodeFragmentShader = 0;
unsigned mWidth = 0;
unsigned mHeight = 0;
unsigned mRawWidth = 0;
unsigned mRawHeight = 0;
uint64_t mUploadedFrames = 0;
uint64_t mUploadMisses = 0;
double mLastUploadMilliseconds = 0.0;
bool mLastFrameFormatSupported = true;
};

View File

@@ -0,0 +1,49 @@
#include "RenderCadenceClock.h"
#include <algorithm>
RenderCadenceClock::RenderCadenceClock(double frameDurationMilliseconds)
{
mFrameDuration = std::chrono::duration_cast<Duration>(std::chrono::duration<double, std::milli>(frameDurationMilliseconds));
if (mFrameDuration <= Duration::zero())
mFrameDuration = std::chrono::milliseconds(16);
Reset();
}
void RenderCadenceClock::Reset(TimePoint now)
{
mNextRenderTime = now;
mPendingFrameAdvance = 1;
mOverrunCount = 0;
mSkippedFrameCount = 0;
}
RenderCadenceClock::Tick RenderCadenceClock::Poll(TimePoint now)
{
Tick tick;
if (now < mNextRenderTime)
{
tick.sleepFor = std::min(Duration(std::chrono::milliseconds(1)), mNextRenderTime - now);
return tick;
}
tick.due = true;
mPendingFrameAdvance = 1;
const Duration lateBy = now - mNextRenderTime;
if (lateBy > mFrameDuration)
{
tick.skippedFrames = static_cast<uint64_t>(lateBy / mFrameDuration);
mPendingFrameAdvance += tick.skippedFrames;
++mOverrunCount;
mSkippedFrameCount += tick.skippedFrames;
}
return tick;
}
void RenderCadenceClock::MarkRendered(TimePoint now)
{
mNextRenderTime += mFrameDuration * mPendingFrameAdvance;
mPendingFrameAdvance = 1;
if (now - mNextRenderTime > mFrameDuration * 4)
mNextRenderTime = now + mFrameDuration;
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <chrono>
#include <cstdint>
class RenderCadenceClock
{
public:
using Clock = std::chrono::steady_clock;
using Duration = Clock::duration;
using TimePoint = Clock::time_point;
struct Tick
{
bool due = false;
uint64_t skippedFrames = 0;
Duration sleepFor = Duration::zero();
};
explicit RenderCadenceClock(double frameDurationMilliseconds = 1000.0 / 60.0);
void Reset(TimePoint now = Clock::now());
Tick Poll(TimePoint now = Clock::now());
void MarkRendered(TimePoint now = Clock::now());
Duration FrameDuration() const { return mFrameDuration; }
TimePoint NextRenderTime() const { return mNextRenderTime; }
uint64_t OverrunCount() const { return mOverrunCount; }
uint64_t SkippedFrameCount() const { return mSkippedFrameCount; }
private:
Duration mFrameDuration;
TimePoint mNextRenderTime = Clock::now();
uint64_t mPendingFrameAdvance = 1;
uint64_t mOverrunCount = 0;
uint64_t mSkippedFrameCount = 0;
};

View File

@@ -0,0 +1,389 @@
#include "RenderThread.h"
#include "../frames/InputFrameMailbox.h"
#include "../frames/SystemFrameExchange.h"
#include "../frames/SystemFrameTypes.h"
#include "../logging/Logger.h"
#include "../platform/HiddenGlWindow.h"
#include "InputFrameTexture.h"
#include "readback/Bgra8ReadbackPipeline.h"
#include "GLExtensions.h"
#include "runtime/RuntimeRenderScene.h"
#include "runtime/RuntimeShaderRenderer.h"
#include "SimpleMotionRenderer.h"
#include <algorithm>
#include <memory>
#include <thread>
RenderThread::RenderThread(SystemFrameExchange& frameExchange, Config config) :
mFrameExchange(frameExchange),
mConfig(config)
{
}
RenderThread::RenderThread(SystemFrameExchange& frameExchange, InputFrameMailbox* inputMailbox, Config config) :
mFrameExchange(frameExchange),
mInputMailbox(inputMailbox),
mConfig(config)
{
}
RenderThread::~RenderThread()
{
Stop();
}
bool RenderThread::Start(std::string& error)
{
if (mThread.joinable())
return true;
{
std::lock_guard<std::mutex> lock(mStartupMutex);
mStarted = false;
mStartupError.clear();
}
mStopping.store(false, std::memory_order_release);
mThread = std::thread([this]() { ThreadMain(); });
std::unique_lock<std::mutex> lock(mStartupMutex);
if (!mStartupCondition.wait_for(lock, std::chrono::seconds(3), [this]() {
return mStarted || !mStartupError.empty();
}))
{
error = "Timed out starting render thread.";
return false;
}
if (!mStartupError.empty())
{
error = mStartupError;
lock.unlock();
if (mThread.joinable())
mThread.join();
return false;
}
return true;
}
void RenderThread::Stop()
{
mStopping.store(true, std::memory_order_release);
if (mThread.joinable())
mThread.join();
}
RenderThread::Metrics RenderThread::GetMetrics() const
{
Metrics metrics;
metrics.renderedFrames = mRenderedFrames.load(std::memory_order_relaxed);
metrics.completedReadbacks = mCompletedReadbacks.load(std::memory_order_relaxed);
metrics.acquireMisses = mAcquireMisses.load(std::memory_order_relaxed);
metrics.pboQueueMisses = mPboQueueMisses.load(std::memory_order_relaxed);
metrics.clockOverruns = mClockOverruns.load(std::memory_order_relaxed);
metrics.skippedFrames = mSkippedFrames.load(std::memory_order_relaxed);
metrics.shaderBuildsCommitted = mShaderBuildsCommitted.load(std::memory_order_relaxed);
metrics.shaderBuildFailures = mShaderBuildFailures.load(std::memory_order_relaxed);
metrics.renderFrameMilliseconds = mRenderFrameMilliseconds.load(std::memory_order_relaxed);
metrics.renderFrameBudgetUsedPercent = mRenderFrameBudgetUsedPercent.load(std::memory_order_relaxed);
metrics.renderFrameMaxMilliseconds = mRenderFrameMaxMilliseconds.load(std::memory_order_relaxed);
metrics.readbackQueueMilliseconds = mReadbackQueueMilliseconds.load(std::memory_order_relaxed);
metrics.completedReadbackCopyMilliseconds = mCompletedReadbackCopyMilliseconds.load(std::memory_order_relaxed);
metrics.inputFramesReceived = mInputFramesReceived.load(std::memory_order_relaxed);
metrics.inputFramesDropped = mInputFramesDropped.load(std::memory_order_relaxed);
metrics.inputConsumeMisses = mInputConsumeMisses.load(std::memory_order_relaxed);
metrics.inputUploadMisses = mInputUploadMisses.load(std::memory_order_relaxed);
metrics.inputReadyFrames = mInputReadyFrames.load(std::memory_order_relaxed);
metrics.inputReadingFrames = mInputReadingFrames.load(std::memory_order_relaxed);
metrics.inputLatestAgeMilliseconds = mInputLatestAgeMilliseconds.load(std::memory_order_relaxed);
metrics.inputUploadMilliseconds = mInputUploadMilliseconds.load(std::memory_order_relaxed);
metrics.inputFormatSupported = mInputFormatSupported.load(std::memory_order_relaxed);
metrics.inputSignalPresent = mInputSignalPresent.load(std::memory_order_relaxed);
return metrics;
}
void RenderThread::ThreadMain()
{
RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread starting.");
HiddenGlWindow window;
std::string error;
if (!window.Create(mConfig.width, mConfig.height, error))
{
SignalStartupFailure(error.empty() ? "OpenGL context creation failed." : error);
return;
}
std::unique_ptr<HiddenGlWindow> prepareWindow = std::make_unique<HiddenGlWindow>();
if (!prepareWindow->CreateShared(mConfig.width, mConfig.height, window.DeviceContext(), window.Context(), error))
{
SignalStartupFailure(error.empty() ? "Runtime shader prepare shared context creation failed." : error);
return;
}
if (!window.MakeCurrent())
{
SignalStartupFailure("OpenGL context creation failed.");
return;
}
if (!ResolveGLExtensions())
{
SignalStartupFailure("OpenGL extension resolution failed.");
return;
}
SimpleMotionRenderer renderer;
RuntimeRenderScene runtimeRenderScene;
Bgra8ReadbackPipeline 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) || !readback.Initialize(mConfig.width, mConfig.height, mConfig.pboDepth))
{
SignalStartupFailure("Render pipeline initialization failed.");
return;
}
RenderCadenceClock clock(mConfig.frameDurationMilliseconds);
uint64_t frameIndex = 0;
mRunning.store(true, std::memory_order_release);
SignalStarted();
while (!mStopping.load(std::memory_order_acquire))
{
readback.ConsumeCompleted(
[this](SystemFrame& frame) { return mFrameExchange.AcquireForRender(frame); },
[this](const SystemFrame& frame) { return mFrameExchange.PublishCompleted(frame); },
[this]() {
CountAcquireMiss();
},
[this]() { CountCompleted(); });
PublishReadbackMetrics(readback);
const auto now = RenderCadenceClock::Clock::now();
const RenderCadenceClock::Tick tick = clock.Poll(now);
if (!tick.due)
{
if (tick.sleepFor > RenderCadenceClock::Duration::zero())
std::this_thread::sleep_for(tick.sleepFor);
continue;
}
TryCommitReadyRuntimeShader(runtimeRenderScene);
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);
}))
{
mPboQueueMisses.fetch_add(1, std::memory_order_relaxed);
}
PublishReadbackMetrics(readback);
CountRendered();
++frameIndex;
clock.MarkRendered(RenderCadenceClock::Clock::now());
mClockOverruns.store(clock.OverrunCount(), std::memory_order_relaxed);
mSkippedFrames.store(clock.SkippedFrameCount(), std::memory_order_relaxed);
}
for (std::size_t i = 0; i < mConfig.pboDepth * 2; ++i)
{
readback.ConsumeCompleted(
[this](SystemFrame& frame) { return mFrameExchange.AcquireForRender(frame); },
[this](const SystemFrame& frame) { return mFrameExchange.PublishCompleted(frame); },
[this]() {
CountAcquireMiss();
},
[this]() { CountCompleted(); });
PublishReadbackMetrics(readback);
}
readback.Shutdown();
inputTexture.ShutdownGl();
runtimeRenderScene.ShutdownGl();
renderer.ShutdownGl();
window.ClearCurrent();
mRunning.store(false, std::memory_order_release);
RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Log, "render-thread", "Render thread stopped.");
}
void RenderThread::SignalStarted()
{
std::lock_guard<std::mutex> lock(mStartupMutex);
mStarted = true;
mStartupCondition.notify_all();
}
void RenderThread::SignalStartupFailure(const std::string& error)
{
RenderCadenceCompositor::TryLog(RenderCadenceCompositor::LogLevel::Error, "render-thread", error);
std::lock_guard<std::mutex> lock(mStartupMutex);
mStartupError = error;
mStartupCondition.notify_all();
}
void RenderThread::CountRendered()
{
mRenderedFrames.fetch_add(1, std::memory_order_relaxed);
}
void RenderThread::CountCompleted()
{
mCompletedReadbacks.fetch_add(1, std::memory_order_relaxed);
}
void RenderThread::CountAcquireMiss()
{
mAcquireMisses.fetch_add(1, std::memory_order_relaxed);
}
void RenderThread::PublishReadbackMetrics(const Bgra8ReadbackPipeline& readback)
{
const double renderMilliseconds = readback.LastRenderFrameMilliseconds();
mRenderFrameMilliseconds.store(renderMilliseconds, std::memory_order_relaxed);
if (mConfig.frameDurationMilliseconds > 0.0)
{
mRenderFrameBudgetUsedPercent.store(
(renderMilliseconds / mConfig.frameDurationMilliseconds) * 100.0,
std::memory_order_relaxed);
}
else
{
mRenderFrameBudgetUsedPercent.store(0.0, std::memory_order_relaxed);
}
const double previousMax = mRenderFrameMaxMilliseconds.load(std::memory_order_relaxed);
if (renderMilliseconds > previousMax)
mRenderFrameMaxMilliseconds.store(renderMilliseconds, std::memory_order_relaxed);
mReadbackQueueMilliseconds.store(readback.LastReadbackQueueMilliseconds(), std::memory_order_relaxed);
mCompletedReadbackCopyMilliseconds.store(readback.LastCompletedReadbackCopyMilliseconds(), std::memory_order_relaxed);
}
void RenderThread::PublishInputMetrics(const InputFrameTexture& inputTexture)
{
if (mInputMailbox != nullptr)
{
const InputFrameMailboxMetrics mailboxMetrics = mInputMailbox->Metrics();
mInputFramesReceived.store(mailboxMetrics.submittedFrames, std::memory_order_relaxed);
mInputFramesDropped.store(mailboxMetrics.droppedReadyFrames + mailboxMetrics.submitMisses, std::memory_order_relaxed);
mInputConsumeMisses.store(mailboxMetrics.consumeMisses, std::memory_order_relaxed);
mInputReadyFrames.store(mailboxMetrics.readyCount, std::memory_order_relaxed);
mInputReadingFrames.store(mailboxMetrics.readingCount, std::memory_order_relaxed);
mInputLatestAgeMilliseconds.store(mailboxMetrics.latestFrameAgeMilliseconds, std::memory_order_relaxed);
mInputSignalPresent.store(mailboxMetrics.hasSubmittedFrame, std::memory_order_relaxed);
}
else
{
mInputFramesReceived.store(0, std::memory_order_relaxed);
mInputFramesDropped.store(0, std::memory_order_relaxed);
mInputConsumeMisses.store(0, std::memory_order_relaxed);
mInputReadyFrames.store(0, std::memory_order_relaxed);
mInputReadingFrames.store(0, std::memory_order_relaxed);
mInputLatestAgeMilliseconds.store(0.0, std::memory_order_relaxed);
mInputSignalPresent.store(false, std::memory_order_relaxed);
}
mInputUploadMisses.store(inputTexture.UploadMisses(), std::memory_order_relaxed);
mInputUploadMilliseconds.store(inputTexture.LastUploadMilliseconds(), std::memory_order_relaxed);
mInputFormatSupported.store(inputTexture.LastFrameFormatSupported(), std::memory_order_relaxed);
}
void RenderThread::SubmitRuntimeShaderArtifact(const RuntimeShaderArtifact& artifact)
{
if (artifact.fragmentShaderSource.empty())
return;
std::lock_guard<std::mutex> lock(mShaderArtifactMutex);
mPendingShaderArtifact = artifact;
mHasPendingShaderArtifact = true;
}
void RenderThread::SubmitRuntimeRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers)
{
std::lock_guard<std::mutex> lock(mRenderLayersMutex);
mPendingRenderLayers = layers;
mHasPendingRenderLayers = true;
}
bool RenderThread::TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact)
{
std::lock_guard<std::mutex> lock(mShaderArtifactMutex);
if (!mHasPendingShaderArtifact)
return false;
artifact = std::move(mPendingShaderArtifact);
mPendingShaderArtifact = RuntimeShaderArtifact();
mHasPendingShaderArtifact = false;
return true;
}
bool RenderThread::TryTakePendingRenderLayers(std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers)
{
std::lock_guard<std::mutex> lock(mRenderLayersMutex);
if (!mHasPendingRenderLayers)
return false;
layers = std::move(mPendingRenderLayers);
mPendingRenderLayers.clear();
mHasPendingRenderLayers = false;
return true;
}
void RenderThread::TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene)
{
std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> layers;
std::string commitError;
if (TryTakePendingRenderLayers(layers))
{
if (!runtimeRenderScene.CommitRenderLayers(layers, commitError))
{
RenderCadenceCompositor::TryLog(
RenderCadenceCompositor::LogLevel::Error,
"render-thread",
"Runtime render-layer commit failed: " + commitError);
mShaderBuildFailures.fetch_add(1, std::memory_order_relaxed);
return;
}
RenderCadenceCompositor::TryLog(
RenderCadenceCompositor::LogLevel::Log,
"render-thread",
"Runtime render layer snapshot committed.");
mShaderBuildsCommitted.fetch_add(1, std::memory_order_relaxed);
return;
}
RuntimeShaderArtifact artifact;
if (!TryTakePendingRuntimeShaderArtifact(artifact))
return;
RenderCadenceCompositor::RuntimeRenderLayerModel layer;
layer.id = artifact.layerId.empty() ? "runtime-layer-1" : artifact.layerId;
layer.shaderId = artifact.shaderId;
layer.artifact = artifact;
layers.push_back(std::move(layer));
if (!runtimeRenderScene.CommitRenderLayers(layers, commitError))
{
RenderCadenceCompositor::TryLog(
RenderCadenceCompositor::LogLevel::Error,
"render-thread",
"Runtime shader GL commit failed: " + commitError);
mShaderBuildFailures.fetch_add(1, std::memory_order_relaxed);
return;
}
RenderCadenceCompositor::TryLog(
RenderCadenceCompositor::LogLevel::Log,
"render-thread",
"Runtime shader committed: " + artifact.shaderId + ". " + artifact.message);
mShaderBuildsCommitted.fetch_add(1, std::memory_order_relaxed);
}

View File

@@ -0,0 +1,129 @@
#pragma once
#include "RenderCadenceClock.h"
#include "../runtime/RuntimeLayerModel.h"
#include "../runtime/RuntimeShaderArtifact.h"
#include "runtime/RuntimeRenderScene.h"
#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
class SystemFrameExchange;
class InputFrameMailbox;
class InputFrameTexture;
class Bgra8ReadbackPipeline;
class RenderThread
{
public:
struct Config
{
unsigned width = 1920;
unsigned height = 1080;
double frameDurationMilliseconds = 1000.0 / 59.94;
std::size_t pboDepth = 6;
};
struct Metrics
{
uint64_t renderedFrames = 0;
uint64_t completedReadbacks = 0;
uint64_t acquireMisses = 0;
uint64_t pboQueueMisses = 0;
uint64_t clockOverruns = 0;
uint64_t skippedFrames = 0;
uint64_t shaderBuildsCommitted = 0;
uint64_t shaderBuildFailures = 0;
double renderFrameMilliseconds = 0.0;
double renderFrameBudgetUsedPercent = 0.0;
double renderFrameMaxMilliseconds = 0.0;
double readbackQueueMilliseconds = 0.0;
double completedReadbackCopyMilliseconds = 0.0;
uint64_t inputFramesReceived = 0;
uint64_t inputFramesDropped = 0;
uint64_t inputConsumeMisses = 0;
uint64_t inputUploadMisses = 0;
std::size_t inputReadyFrames = 0;
std::size_t inputReadingFrames = 0;
double inputLatestAgeMilliseconds = 0.0;
double inputUploadMilliseconds = 0.0;
bool inputFormatSupported = true;
bool inputSignalPresent = false;
};
RenderThread(SystemFrameExchange& frameExchange, Config config);
RenderThread(SystemFrameExchange& frameExchange, InputFrameMailbox* inputMailbox, Config config);
RenderThread(const RenderThread&) = delete;
RenderThread& operator=(const RenderThread&) = delete;
~RenderThread();
bool Start(std::string& error);
void Stop();
void SubmitRuntimeShaderArtifact(const RuntimeShaderArtifact& artifact);
void SubmitRuntimeRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers);
Metrics GetMetrics() const;
bool IsRunning() const { return mRunning.load(std::memory_order_acquire); }
private:
void ThreadMain();
void SignalStarted();
void SignalStartupFailure(const std::string& error);
void CountRendered();
void CountCompleted();
void CountAcquireMiss();
void PublishReadbackMetrics(const Bgra8ReadbackPipeline& readback);
void PublishInputMetrics(const InputFrameTexture& inputTexture);
void TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene);
bool TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact);
bool TryTakePendingRenderLayers(std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers);
SystemFrameExchange& mFrameExchange;
InputFrameMailbox* mInputMailbox = nullptr;
Config mConfig;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mRunning{ false };
mutable std::mutex mStartupMutex;
std::condition_variable mStartupCondition;
bool mStarted = false;
std::string mStartupError;
std::atomic<uint64_t> mRenderedFrames{ 0 };
std::atomic<uint64_t> mCompletedReadbacks{ 0 };
std::atomic<uint64_t> mAcquireMisses{ 0 };
std::atomic<uint64_t> mPboQueueMisses{ 0 };
std::atomic<uint64_t> mClockOverruns{ 0 };
std::atomic<uint64_t> mSkippedFrames{ 0 };
std::atomic<uint64_t> mShaderBuildsCommitted{ 0 };
std::atomic<uint64_t> mShaderBuildFailures{ 0 };
std::atomic<double> mRenderFrameMilliseconds{ 0.0 };
std::atomic<double> mRenderFrameBudgetUsedPercent{ 0.0 };
std::atomic<double> mRenderFrameMaxMilliseconds{ 0.0 };
std::atomic<double> mReadbackQueueMilliseconds{ 0.0 };
std::atomic<double> mCompletedReadbackCopyMilliseconds{ 0.0 };
std::atomic<uint64_t> mInputFramesReceived{ 0 };
std::atomic<uint64_t> mInputFramesDropped{ 0 };
std::atomic<uint64_t> mInputConsumeMisses{ 0 };
std::atomic<uint64_t> mInputUploadMisses{ 0 };
std::atomic<std::size_t> mInputReadyFrames{ 0 };
std::atomic<std::size_t> mInputReadingFrames{ 0 };
std::atomic<double> mInputLatestAgeMilliseconds{ 0.0 };
std::atomic<double> mInputUploadMilliseconds{ 0.0 };
std::atomic<bool> mInputFormatSupported{ true };
std::atomic<bool> mInputSignalPresent{ false };
std::mutex mShaderArtifactMutex;
bool mHasPendingShaderArtifact = false;
RuntimeShaderArtifact mPendingShaderArtifact;
std::mutex mRenderLayersMutex;
bool mHasPendingRenderLayers = false;
std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> mPendingRenderLayers;
};

View File

@@ -0,0 +1,313 @@
#include "SimpleMotionRenderer.h"
#include "GLExtensions.h"
#include <algorithm>
#include <array>
#include <cmath>
namespace
{
constexpr GLuint kInputTextureUnit = 0;
const char* kTextureVertexShader = R"GLSL(
#version 430 core
out vec2 vTexCoord;
void main()
{
vec2 positions[3] = vec2[3](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0));
vec2 texCoords[3] = vec2[3](
vec2(0.0, 0.0),
vec2(2.0, 0.0),
vec2(0.0, 2.0));
gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
vTexCoord = texCoords[gl_VertexID];
}
)GLSL";
const char* kTextureFragmentShader = R"GLSL(
#version 430 core
layout(binding = 0) uniform sampler2D uInputTexture;
in vec2 vTexCoord;
out vec4 fragColor;
void main()
{
fragColor = texture(uInputTexture, clamp(vTexCoord, vec2(0.0), vec2(1.0)));
}
)GLSL";
const char* kPatternFragmentShader = R"GLSL(
#version 430 core
uniform vec2 uResolution;
uniform float uFrameIndex;
in vec2 vTexCoord;
out vec4 fragColor;
vec3 hexColor(float r, float g, float b)
{
return vec3(r, g, b) / 255.0;
}
vec3 smpteTop(float x)
{
if (x < 240.0) return hexColor(102.0, 102.0, 102.0);
if (x < 445.0) return hexColor(191.0, 191.0, 191.0);
if (x < 651.0) return hexColor(191.0, 191.0, 0.0);
if (x < 857.0) return hexColor(0.0, 191.0, 191.0);
if (x < 1063.0) return hexColor(0.0, 191.0, 0.0);
if (x < 1269.0) return hexColor(191.0, 0.0, 191.0);
if (x < 1475.0) return hexColor(191.0, 0.0, 0.0);
if (x < 1680.0) return hexColor(0.0, 0.0, 191.0);
return hexColor(102.0, 102.0, 102.0);
}
vec3 smpteMiddleA(float x)
{
if (x < 240.0) return hexColor(0.0, 255.0, 255.0);
if (x < 445.0) return hexColor(0.0, 63.0, 105.0);
if (x < 1680.0) return hexColor(191.0, 191.0, 191.0);
return hexColor(0.0, 0.0, 255.0);
}
vec3 smpteMiddleB(float x)
{
if (x < 240.0) return hexColor(255.0, 255.0, 0.0);
if (x < 445.0) return hexColor(65.0, 0.0, 119.0);
if (x < 1475.0)
{
float ramp = clamp((x - 445.0) / (1475.0 - 445.0), 0.0, 1.0);
return vec3(ramp);
}
if (x < 1680.0) return vec3(1.0);
return hexColor(255.0, 0.0, 0.0);
}
vec3 smpteBottom(float x)
{
if (x < 240.0) return hexColor(38.0, 38.0, 38.0);
if (x < 549.0) return vec3(0.0);
if (x < 960.0) return vec3(1.0);
if (x < 1268.0) return vec3(0.0);
if (x < 1337.0) return hexColor(5.0, 5.0, 5.0);
if (x < 1405.0) return vec3(0.0);
if (x < 1474.0) return hexColor(10.0, 10.0, 10.0);
if (x < 1680.0) return vec3(0.0);
return hexColor(38.0, 38.0, 38.0);
}
vec3 smpteColor(vec2 uv)
{
vec2 pixel = vec2(clamp(uv.x, 0.0, 1.0), 1.0 - clamp(uv.y, 0.0, 1.0)) * vec2(1920.0, 1080.0);
if (pixel.y < 630.0) return smpteTop(pixel.x);
if (pixel.y < 720.0) return smpteMiddleA(pixel.x);
if (pixel.y < 810.0) return smpteMiddleB(pixel.x);
return smpteBottom(pixel.x);
}
void main()
{
vec2 uv = clamp(vTexCoord, vec2(0.0), vec2(1.0));
vec3 color = smpteColor(uv);
float t = uFrameIndex / 60.0;
vec2 cubeSize = vec2(0.16, 0.20);
vec2 cubeMin = vec2(
(0.5 + 0.5 * sin(t * 1.7)) * (1.0 - cubeSize.x),
(0.5 + 0.5 * sin(t * 1.1 + 0.8)) * (1.0 - cubeSize.y));
vec2 cubeMax = cubeMin + cubeSize;
bool insideCube = uv.x >= cubeMin.x && uv.x <= cubeMax.x && uv.y >= cubeMin.y && uv.y <= cubeMax.y;
if (insideCube)
{
vec2 local = (uv - cubeMin) / cubeSize;
vec3 cubeColor = vec3(1.0, 0.74 + 0.18 * sin(t * 2.1), 0.08);
float edge = step(local.x, 0.04) + step(local.y, 0.04) + step(0.96, local.x) + step(0.96, local.y);
color = edge > 0.0 ? vec3(1.0) : cubeColor;
}
fragColor = vec4(color, 1.0);
}
)GLSL";
}
bool SimpleMotionRenderer::InitializeGl(unsigned width, unsigned height)
{
mWidth = width;
mHeight = height;
return mWidth > 0 && mHeight > 0;
}
void SimpleMotionRenderer::RenderFrame(uint64_t frameIndex)
{
if (!EnsurePatternProgram())
{
glViewport(0, 0, static_cast<GLsizei>(mWidth), static_cast<GLsizei>(mHeight));
glDisable(GL_SCISSOR_TEST);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
return;
}
glViewport(0, 0, static_cast<GLsizei>(mWidth), static_cast<GLsizei>(mHeight));
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(mPatternProgram);
const GLint resolutionLocation = glGetUniformLocation(mPatternProgram, "uResolution");
if (resolutionLocation >= 0)
glUniform2f(resolutionLocation, static_cast<float>(mWidth), static_cast<float>(mHeight));
const GLint frameIndexLocation = glGetUniformLocation(mPatternProgram, "uFrameIndex");
if (frameIndexLocation >= 0)
glUniform1f(frameIndexLocation, static_cast<float>(frameIndex));
glBindVertexArray(mPatternVertexArray);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glUseProgram(0);
}
void SimpleMotionRenderer::RenderTexture(GLuint texture)
{
if (texture == 0 || !EnsureTextureProgram())
{
RenderFrame(0);
return;
}
glViewport(0, 0, static_cast<GLsizei>(mWidth), static_cast<GLsizei>(mHeight));
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glActiveTexture(GL_TEXTURE0 + kInputTextureUnit);
glBindTexture(GL_TEXTURE_2D, texture);
glUseProgram(mTextureProgram);
glBindVertexArray(mTextureVertexArray);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glUseProgram(0);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
}
void SimpleMotionRenderer::ShutdownGl()
{
DestroyPatternProgram();
DestroyTextureProgram();
mWidth = 0;
mHeight = 0;
}
bool SimpleMotionRenderer::EnsurePatternProgram()
{
if (mPatternProgram != 0)
return true;
if (!CompileShader(GL_VERTEX_SHADER, kTextureVertexShader, mPatternVertexShader))
return false;
if (!CompileShader(GL_FRAGMENT_SHADER, kPatternFragmentShader, mPatternFragmentShader))
{
DestroyPatternProgram();
return false;
}
mPatternProgram = glCreateProgram();
glAttachShader(mPatternProgram, mPatternVertexShader);
glAttachShader(mPatternProgram, mPatternFragmentShader);
glLinkProgram(mPatternProgram);
GLint linkResult = GL_FALSE;
glGetProgramiv(mPatternProgram, GL_LINK_STATUS, &linkResult);
if (linkResult == GL_FALSE)
{
DestroyPatternProgram();
return false;
}
glGenVertexArrays(1, &mPatternVertexArray);
return mPatternVertexArray != 0;
}
bool SimpleMotionRenderer::EnsureTextureProgram()
{
if (mTextureProgram != 0)
return true;
if (!CompileShader(GL_VERTEX_SHADER, kTextureVertexShader, mTextureVertexShader))
return false;
if (!CompileShader(GL_FRAGMENT_SHADER, kTextureFragmentShader, mTextureFragmentShader))
{
DestroyTextureProgram();
return false;
}
mTextureProgram = glCreateProgram();
glAttachShader(mTextureProgram, mTextureVertexShader);
glAttachShader(mTextureProgram, mTextureFragmentShader);
glLinkProgram(mTextureProgram);
GLint linkResult = GL_FALSE;
glGetProgramiv(mTextureProgram, GL_LINK_STATUS, &linkResult);
if (linkResult == GL_FALSE)
{
DestroyTextureProgram();
return false;
}
glUseProgram(mTextureProgram);
const GLint inputLocation = glGetUniformLocation(mTextureProgram, "uInputTexture");
if (inputLocation >= 0)
glUniform1i(inputLocation, static_cast<GLint>(kInputTextureUnit));
glUseProgram(0);
glGenVertexArrays(1, &mTextureVertexArray);
return mTextureVertexArray != 0;
}
void SimpleMotionRenderer::DestroyTextureProgram()
{
if (mTextureProgram != 0)
glDeleteProgram(mTextureProgram);
if (mTextureVertexShader != 0)
glDeleteShader(mTextureVertexShader);
if (mTextureFragmentShader != 0)
glDeleteShader(mTextureFragmentShader);
if (mTextureVertexArray != 0)
glDeleteVertexArrays(1, &mTextureVertexArray);
mTextureProgram = 0;
mTextureVertexShader = 0;
mTextureFragmentShader = 0;
mTextureVertexArray = 0;
}
void SimpleMotionRenderer::DestroyPatternProgram()
{
if (mPatternProgram != 0)
glDeleteProgram(mPatternProgram);
if (mPatternVertexShader != 0)
glDeleteShader(mPatternVertexShader);
if (mPatternFragmentShader != 0)
glDeleteShader(mPatternFragmentShader);
if (mPatternVertexArray != 0)
glDeleteVertexArrays(1, &mPatternVertexArray);
mPatternProgram = 0;
mPatternVertexShader = 0;
mPatternFragmentShader = 0;
mPatternVertexArray = 0;
}
bool SimpleMotionRenderer::CompileShader(GLenum shaderType, const char* source, GLuint& shader)
{
shader = glCreateShader(shaderType);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compileResult = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compileResult);
if (compileResult != GL_FALSE)
return true;
glDeleteShader(shader);
shader = 0;
return false;
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include "GLExtensions.h"
#include <cstdint>
class SimpleMotionRenderer
{
public:
SimpleMotionRenderer() = default;
bool InitializeGl(unsigned width, unsigned height);
void RenderFrame(uint64_t frameIndex);
void RenderTexture(GLuint texture);
void ShutdownGl();
unsigned Width() const { return mWidth; }
unsigned Height() const { return mHeight; }
private:
bool EnsureTextureProgram();
bool EnsurePatternProgram();
void DestroyTextureProgram();
void DestroyPatternProgram();
static bool CompileShader(GLenum shaderType, const char* source, GLuint& shader);
unsigned mWidth = 0;
unsigned mHeight = 0;
GLuint mPatternProgram = 0;
GLuint mPatternVertexShader = 0;
GLuint mPatternFragmentShader = 0;
GLuint mPatternVertexArray = 0;
GLuint mTextureProgram = 0;
GLuint mTextureVertexShader = 0;
GLuint mTextureFragmentShader = 0;
GLuint mTextureVertexArray = 0;
};

View File

@@ -0,0 +1,160 @@
#include "Bgra8ReadbackPipeline.h"
#include "../frames/SystemFrameTypes.h"
#include <chrono>
#include <cstring>
namespace
{
double MillisecondsSince(std::chrono::steady_clock::time_point start)
{
return std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(
std::chrono::steady_clock::now() - start).count();
}
}
Bgra8ReadbackPipeline::~Bgra8ReadbackPipeline()
{
Shutdown();
}
bool Bgra8ReadbackPipeline::Initialize(unsigned width, unsigned height, std::size_t pboDepth)
{
Shutdown();
mWidth = width;
mHeight = height;
mRowBytes = VideoIORowBytes(VideoIOPixelFormat::Bgra8, width);
if (mWidth == 0 || mHeight == 0 || mRowBytes == 0)
return false;
if (!CreateRenderTarget())
{
Shutdown();
return false;
}
const std::size_t byteCount = static_cast<std::size_t>(mRowBytes) * static_cast<std::size_t>(mHeight);
if (!mPboRing.Initialize(pboDepth, byteCount))
{
Shutdown();
return false;
}
return true;
}
void Bgra8ReadbackPipeline::Shutdown()
{
mPboRing.Shutdown();
DestroyRenderTarget();
mWidth = 0;
mHeight = 0;
mRowBytes = 0;
}
bool Bgra8ReadbackPipeline::RenderAndQueue(uint64_t frameIndex, const RenderCallback& renderFrame)
{
if (mFramebuffer == 0 || !renderFrame)
return false;
glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);
const auto renderStart = std::chrono::steady_clock::now();
renderFrame(frameIndex);
mLastRenderFrameMilliseconds = MillisecondsSince(renderStart);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
const auto queueStart = std::chrono::steady_clock::now();
const bool queued = mPboRing.QueueReadback(mFramebuffer, mWidth, mHeight, frameIndex);
mLastReadbackQueueMilliseconds = MillisecondsSince(queueStart);
return queued;
}
void Bgra8ReadbackPipeline::ConsumeCompleted(
const AcquireFrameCallback& acquireFrame,
const PublishFrameCallback& publishFrame,
const CounterCallback& onAcquireMiss,
const CounterCallback& onCompleted)
{
if (!acquireFrame || !publishFrame)
return;
PboReadbackRing::CompletedReadback readback;
while (mPboRing.TryAcquireCompleted(readback))
{
const auto copyStart = std::chrono::steady_clock::now();
glBindBuffer(GL_PIXEL_PACK_BUFFER, readback.pbo);
void* mapped = glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY);
if (!mapped)
{
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
mPboRing.ReleaseCompleted(readback);
mLastCompletedReadbackCopyMilliseconds = MillisecondsSince(copyStart);
continue;
}
SystemFrame frame;
if (acquireFrame(frame))
{
const std::size_t byteCount = static_cast<std::size_t>(frame.rowBytes) * static_cast<std::size_t>(frame.height);
if (frame.bytes != nullptr && byteCount <= readback.byteCount)
{
std::memcpy(frame.bytes, mapped, byteCount);
frame.frameIndex = readback.frameIndex;
frame.pixelFormat = VideoIOPixelFormat::Bgra8;
publishFrame(frame);
if (onCompleted)
onCompleted();
}
}
else if (onAcquireMiss)
{
onAcquireMiss();
}
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
mPboRing.ReleaseCompleted(readback);
mLastCompletedReadbackCopyMilliseconds = MillisecondsSince(copyStart);
}
}
bool Bgra8ReadbackPipeline::CreateRenderTarget()
{
glGenFramebuffers(1, &mFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);
glGenTextures(1, &mTexture);
glBindTexture(GL_TEXTURE_2D, mTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
static_cast<GLsizei>(mWidth),
static_cast<GLsizei>(mHeight),
0,
GL_BGRA,
GL_UNSIGNED_INT_8_8_8_8_REV,
nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mTexture, 0);
const bool complete = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
glBindTexture(GL_TEXTURE_2D, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return complete;
}
void Bgra8ReadbackPipeline::DestroyRenderTarget()
{
if (mFramebuffer != 0)
glDeleteFramebuffers(1, &mFramebuffer);
if (mTexture != 0)
glDeleteTextures(1, &mTexture);
mFramebuffer = 0;
mTexture = 0;
}

View File

@@ -0,0 +1,58 @@
#pragma once
#include "PboReadbackRing.h"
#include "VideoIOFormat.h"
#include <cstddef>
#include <cstdint>
#include <functional>
struct SystemFrame;
class Bgra8ReadbackPipeline
{
public:
using RenderCallback = std::function<void(uint64_t frameIndex)>;
using AcquireFrameCallback = std::function<bool(SystemFrame& frame)>;
using PublishFrameCallback = std::function<bool(const SystemFrame& frame)>;
using CounterCallback = std::function<void()>;
Bgra8ReadbackPipeline() = default;
Bgra8ReadbackPipeline(const Bgra8ReadbackPipeline&) = delete;
Bgra8ReadbackPipeline& operator=(const Bgra8ReadbackPipeline&) = delete;
~Bgra8ReadbackPipeline();
bool Initialize(unsigned width, unsigned height, std::size_t pboDepth);
void Shutdown();
bool RenderAndQueue(uint64_t frameIndex, const RenderCallback& renderFrame);
void ConsumeCompleted(
const AcquireFrameCallback& acquireFrame,
const PublishFrameCallback& publishFrame,
const CounterCallback& onAcquireMiss = {},
const CounterCallback& onCompleted = {});
GLuint Framebuffer() const { return mFramebuffer; }
unsigned Width() const { return mWidth; }
unsigned Height() const { return mHeight; }
unsigned RowBytes() const { return mRowBytes; }
VideoIOPixelFormat PixelFormat() const { return VideoIOPixelFormat::Bgra8; }
uint64_t PboQueueMisses() const { return mPboRing.QueueMisses(); }
double LastRenderFrameMilliseconds() const { return mLastRenderFrameMilliseconds; }
double LastReadbackQueueMilliseconds() const { return mLastReadbackQueueMilliseconds; }
double LastCompletedReadbackCopyMilliseconds() const { return mLastCompletedReadbackCopyMilliseconds; }
private:
bool CreateRenderTarget();
void DestroyRenderTarget();
unsigned mWidth = 0;
unsigned mHeight = 0;
unsigned mRowBytes = 0;
GLuint mFramebuffer = 0;
GLuint mTexture = 0;
double mLastRenderFrameMilliseconds = 0.0;
double mLastReadbackQueueMilliseconds = 0.0;
double mLastCompletedReadbackCopyMilliseconds = 0.0;
PboReadbackRing mPboRing;
};

View File

@@ -0,0 +1,138 @@
#include "PboReadbackRing.h"
#include <algorithm>
PboReadbackRing::~PboReadbackRing()
{
Shutdown();
}
bool PboReadbackRing::Initialize(std::size_t depth, std::size_t byteCount)
{
Shutdown();
if (depth == 0 || byteCount == 0)
return false;
mSlots.resize(depth);
mByteCount = byteCount;
for (Slot& slot : mSlots)
{
glGenBuffers(1, &slot.pbo);
if (slot.pbo == 0)
{
Shutdown();
return false;
}
glBindBuffer(GL_PIXEL_PACK_BUFFER, slot.pbo);
glBufferData(GL_PIXEL_PACK_BUFFER, static_cast<GLsizeiptr>(mByteCount), nullptr, GL_STREAM_READ);
}
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
return true;
}
void PboReadbackRing::Shutdown()
{
for (Slot& slot : mSlots)
{
if (slot.fence)
glDeleteSync(slot.fence);
if (slot.pbo != 0)
glDeleteBuffers(1, &slot.pbo);
slot = {};
}
mSlots.clear();
mWriteIndex = 0;
mReadIndex = 0;
mByteCount = 0;
}
bool PboReadbackRing::QueueReadback(GLuint framebuffer, unsigned width, unsigned height, uint64_t frameIndex)
{
if (mSlots.empty())
return false;
Slot& slot = mSlots[mWriteIndex];
if (slot.inFlight || slot.acquired)
{
++mQueueMisses;
return false;
}
glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer);
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
glBindBuffer(GL_PIXEL_PACK_BUFFER, slot.pbo);
glBufferData(GL_PIXEL_PACK_BUFFER, static_cast<GLsizeiptr>(mByteCount), nullptr, GL_STREAM_READ);
glReadPixels(0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height), GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, nullptr);
slot.fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
slot.inFlight = slot.fence != nullptr;
slot.frameIndex = frameIndex;
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
if (!slot.inFlight)
return false;
mWriteIndex = (mWriteIndex + 1) % mSlots.size();
return true;
}
bool PboReadbackRing::TryAcquireCompleted(CompletedReadback& readback)
{
if (mSlots.empty())
return false;
for (std::size_t checked = 0; checked < mSlots.size(); ++checked)
{
Slot& slot = mSlots[mReadIndex];
if (!slot.inFlight || slot.acquired || slot.fence == nullptr)
{
mReadIndex = (mReadIndex + 1) % mSlots.size();
continue;
}
const GLenum waitResult = glClientWaitSync(slot.fence, 0, 0);
if (waitResult != GL_ALREADY_SIGNALED && waitResult != GL_CONDITION_SATISFIED)
return false;
slot.acquired = true;
readback.pbo = slot.pbo;
readback.frameIndex = slot.frameIndex;
readback.byteCount = mByteCount;
return true;
}
return false;
}
void PboReadbackRing::ReleaseCompleted(const CompletedReadback& readback)
{
for (std::size_t index = 0; index < mSlots.size(); ++index)
{
Slot& slot = mSlots[index];
if (!slot.acquired || slot.pbo != readback.pbo)
continue;
ResetSlot(slot);
mReadIndex = (index + 1) % mSlots.size();
return;
}
}
void PboReadbackRing::DrainCompleted()
{
for (std::size_t pass = 0; pass < mSlots.size() * 2; ++pass)
{
CompletedReadback readback;
if (!TryAcquireCompleted(readback))
break;
ReleaseCompleted(readback);
}
}
void PboReadbackRing::ResetSlot(Slot& slot)
{
if (slot.fence)
glDeleteSync(slot.fence);
slot.fence = nullptr;
slot.inFlight = false;
slot.acquired = false;
slot.frameIndex = 0;
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include "GLExtensions.h"
#include <cstddef>
#include <cstdint>
#include <vector>
class PboReadbackRing
{
public:
struct CompletedReadback
{
GLuint pbo = 0;
uint64_t frameIndex = 0;
std::size_t byteCount = 0;
};
PboReadbackRing() = default;
PboReadbackRing(const PboReadbackRing&) = delete;
PboReadbackRing& operator=(const PboReadbackRing&) = delete;
~PboReadbackRing();
bool Initialize(std::size_t depth, std::size_t byteCount);
void Shutdown();
bool QueueReadback(GLuint framebuffer, unsigned width, unsigned height, uint64_t frameIndex);
bool TryAcquireCompleted(CompletedReadback& readback);
void ReleaseCompleted(const CompletedReadback& readback);
void DrainCompleted();
std::size_t Depth() const { return mSlots.size(); }
uint64_t QueueMisses() const { return mQueueMisses; }
private:
struct Slot
{
GLuint pbo = 0;
GLsync fence = nullptr;
bool inFlight = false;
bool acquired = false;
uint64_t frameIndex = 0;
};
void ResetSlot(Slot& slot);
std::vector<Slot> mSlots;
std::size_t mWriteIndex = 0;
std::size_t mReadIndex = 0;
std::size_t mByteCount = 0;
uint64_t mQueueMisses = 0;
};

View File

@@ -0,0 +1,271 @@
#include "RuntimeRenderScene.h"
#include "../../platform/HiddenGlWindow.h"
#include <algorithm>
#include <functional>
#include <utility>
#ifndef GL_FRAMEBUFFER_BINDING
#define GL_FRAMEBUFFER_BINDING 0x8CA6
#endif
RuntimeRenderScene::~RuntimeRenderScene()
{
ShutdownGl();
}
bool RuntimeRenderScene::StartPrepareWorker(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error)
{
return mPrepareWorker.Start(std::move(sharedWindow), error);
}
bool RuntimeRenderScene::CommitRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers, std::string& error)
{
ConsumePreparedPrograms();
std::vector<std::string> nextOrder;
std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel> layersToPrepare;
nextOrder.reserve(layers.size());
for (const RenderCadenceCompositor::RuntimeRenderLayerModel& layer : layers)
{
if (!layer.bypass)
nextOrder.push_back(layer.id);
}
for (auto layerIt = mLayers.begin(); layerIt != mLayers.end();)
{
const bool stillPresent = std::find(nextOrder.begin(), nextOrder.end(), layerIt->layerId) != nextOrder.end();
if (stillPresent)
{
++layerIt;
continue;
}
for (LayerProgram::PassProgram& pass : layerIt->passes)
{
if (pass.renderer)
pass.renderer->ShutdownGl();
}
ReleasePendingPrograms(*layerIt);
layerIt = mLayers.erase(layerIt);
}
for (const RenderCadenceCompositor::RuntimeRenderLayerModel& layer : layers)
{
if (layer.bypass)
continue;
if (layer.artifact.passes.empty() && layer.artifact.fragmentShaderSource.empty())
continue;
const std::string fingerprint = Fingerprint(layer.artifact);
LayerProgram* program = FindLayer(layer.id);
if (!program)
{
LayerProgram next;
next.layerId = layer.id;
mLayers.push_back(std::move(next));
program = &mLayers.back();
}
bool hasReadyPass = false;
for (const LayerProgram::PassProgram& pass : program->passes)
{
if (pass.renderer && pass.renderer->HasProgram())
{
hasReadyPass = true;
break;
}
}
if (program->shaderId == layer.shaderId && program->sourceFingerprint == fingerprint && hasReadyPass)
{
for (LayerProgram::PassProgram& pass : program->passes)
{
if (pass.renderer)
pass.renderer->UpdateArtifactState(layer.artifact);
}
continue;
}
if (program->pendingFingerprint == fingerprint)
continue;
ReleasePendingPrograms(*program);
program->shaderId = layer.shaderId;
program->pendingFingerprint = fingerprint;
layersToPrepare.push_back(layer);
}
mLayerOrder = std::move(nextOrder);
if (!layersToPrepare.empty())
mPrepareWorker.Submit(layersToPrepare);
error.clear();
return true;
}
void RuntimeRenderScene::ShutdownGl()
{
mPrepareWorker.Stop();
for (LayerProgram& layer : mLayers)
{
for (LayerProgram::PassProgram& pass : layer.passes)
{
if (pass.renderer)
pass.renderer->ShutdownGl();
}
ReleasePendingPrograms(layer);
}
mLayers.clear();
mLayerOrder.clear();
DestroyLayerTargets();
}
void RuntimeRenderScene::ConsumePreparedPrograms()
{
RuntimePreparedShaderProgram preparedProgram;
while (mPrepareWorker.TryConsume(preparedProgram))
{
if (!preparedProgram.succeeded)
{
preparedProgram.ReleaseGl();
continue;
}
LayerProgram* layer = FindLayer(preparedProgram.layerId);
if (!layer || layer->pendingFingerprint != preparedProgram.sourceFingerprint)
{
preparedProgram.ReleaseGl();
continue;
}
bool replacesExistingPendingPass = false;
for (RuntimePreparedShaderProgram& existing : layer->pendingPreparedPrograms)
{
if (existing.passId != preparedProgram.passId)
continue;
existing.ReleaseGl();
existing = std::move(preparedProgram);
replacesExistingPendingPass = true;
break;
}
if (!replacesExistingPendingPass)
layer->pendingPreparedPrograms.push_back(std::move(preparedProgram));
TryCommitPendingPrograms(*layer);
}
}
void RuntimeRenderScene::ReleasePendingPrograms(LayerProgram& layer)
{
for (RuntimePreparedShaderProgram& program : layer.pendingPreparedPrograms)
program.ReleaseGl();
layer.pendingPreparedPrograms.clear();
}
void RuntimeRenderScene::TryCommitPendingPrograms(LayerProgram& layer)
{
if (layer.pendingPreparedPrograms.empty())
return;
const RuntimeShaderArtifact& artifact = layer.pendingPreparedPrograms.front().artifact;
const std::size_t expectedPassCount = artifact.passes.empty() ? 1 : artifact.passes.size();
if (layer.pendingPreparedPrograms.size() < expectedPassCount)
return;
std::vector<LayerProgram::PassProgram> nextPasses;
nextPasses.reserve(expectedPassCount);
for (const RuntimeShaderPassArtifact& passArtifact : artifact.passes)
{
auto preparedIt = std::find_if(
layer.pendingPreparedPrograms.begin(),
layer.pendingPreparedPrograms.end(),
[&passArtifact](const RuntimePreparedShaderProgram& prepared) {
return prepared.passId == passArtifact.passId;
});
if (preparedIt == layer.pendingPreparedPrograms.end())
return;
std::unique_ptr<RuntimeShaderRenderer> nextRenderer = std::make_unique<RuntimeShaderRenderer>();
std::string error;
if (!nextRenderer->CommitPreparedProgram(*preparedIt, error))
{
ReleasePendingPrograms(layer);
return;
}
LayerProgram::PassProgram nextPass;
nextPass.passId = preparedIt->passId;
nextPass.inputNames = preparedIt->inputNames;
nextPass.outputName = preparedIt->outputName.empty() ? preparedIt->passId : preparedIt->outputName;
nextPass.renderer = std::move(nextRenderer);
nextPasses.push_back(std::move(nextPass));
}
if (artifact.passes.empty())
{
RuntimePreparedShaderProgram& prepared = layer.pendingPreparedPrograms.front();
std::unique_ptr<RuntimeShaderRenderer> nextRenderer = std::make_unique<RuntimeShaderRenderer>();
std::string error;
if (!nextRenderer->CommitPreparedProgram(prepared, error))
{
ReleasePendingPrograms(layer);
return;
}
LayerProgram::PassProgram nextPass;
nextPass.passId = prepared.passId;
nextPass.inputNames = prepared.inputNames;
nextPass.outputName = prepared.outputName.empty() ? prepared.passId : prepared.outputName;
nextPass.renderer = std::move(nextRenderer);
nextPasses.push_back(std::move(nextPass));
}
for (LayerProgram::PassProgram& pass : layer.passes)
{
if (pass.renderer)
pass.renderer->ShutdownGl();
}
layer.passes = std::move(nextPasses);
layer.shaderId = artifact.shaderId;
layer.sourceFingerprint = layer.pendingPreparedPrograms.front().sourceFingerprint;
layer.pendingFingerprint.clear();
layer.pendingPreparedPrograms.clear();
}
RuntimeRenderScene::LayerProgram* RuntimeRenderScene::FindLayer(const std::string& layerId)
{
for (LayerProgram& layer : mLayers)
{
if (layer.layerId == layerId)
return &layer;
}
return nullptr;
}
const RuntimeRenderScene::LayerProgram* RuntimeRenderScene::FindLayer(const std::string& layerId) const
{
for (const LayerProgram& layer : mLayers)
{
if (layer.layerId == layerId)
return &layer;
}
return nullptr;
}
RuntimeRenderScene::LayerProgram::PassProgram* RuntimeRenderScene::FindPass(LayerProgram& layer, const std::string& passId)
{
for (LayerProgram::PassProgram& pass : layer.passes)
{
if (pass.passId == passId)
return &pass;
}
return nullptr;
}
std::string RuntimeRenderScene::Fingerprint(const RuntimeShaderArtifact& artifact)
{
const std::hash<std::string> hasher;
std::string source;
for (const RuntimeShaderPassArtifact& pass : artifact.passes)
source += pass.passId + ":" + pass.outputName + ":" + pass.fragmentShaderSource + "\n";
if (source.empty())
source = artifact.fragmentShaderSource;
return artifact.shaderId + ":" + std::to_string(source.size()) + ":" + std::to_string(hasher(source));
}

View File

@@ -0,0 +1,64 @@
#pragma once
#include "../runtime/RuntimeLayerModel.h"
#include "RuntimeShaderPrepareWorker.h"
#include "RuntimeShaderRenderer.h"
#include <windows.h>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
class RuntimeRenderScene
{
public:
RuntimeRenderScene() = default;
RuntimeRenderScene(const RuntimeRenderScene&) = delete;
RuntimeRenderScene& operator=(const RuntimeRenderScene&) = delete;
~RuntimeRenderScene();
bool StartPrepareWorker(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error);
bool CommitRenderLayers(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers, std::string& error);
bool HasLayers();
void RenderFrame(uint64_t frameIndex, unsigned width, unsigned height, GLuint videoInputTexture = 0);
void ShutdownGl();
private:
struct LayerProgram
{
std::string layerId;
std::string shaderId;
std::string sourceFingerprint;
std::string pendingFingerprint;
std::vector<RuntimePreparedShaderProgram> pendingPreparedPrograms;
struct PassProgram
{
std::string passId;
std::vector<std::string> inputNames;
std::string outputName;
std::unique_ptr<RuntimeShaderRenderer> renderer;
};
std::vector<PassProgram> passes;
};
void ConsumePreparedPrograms();
void ReleasePendingPrograms(LayerProgram& layer);
void TryCommitPendingPrograms(LayerProgram& layer);
bool EnsureLayerTargets(unsigned width, unsigned height);
void DestroyLayerTargets();
GLuint RenderLayer(LayerProgram& layer, uint64_t frameIndex, unsigned width, unsigned height, GLuint videoInputTexture, GLuint layerInputTexture, GLuint outputFramebuffer, bool renderToOutput);
LayerProgram* FindLayer(const std::string& layerId);
const LayerProgram* FindLayer(const std::string& layerId) const;
LayerProgram::PassProgram* FindPass(LayerProgram& layer, const std::string& passId);
static std::string Fingerprint(const RuntimeShaderArtifact& artifact);
RuntimeShaderPrepareWorker mPrepareWorker;
std::vector<LayerProgram> mLayers;
std::vector<std::string> mLayerOrder;
GLuint mLayerFramebuffers[4] = {};
GLuint mLayerTextures[4] = {};
unsigned mLayerTargetWidth = 0;
unsigned mLayerTargetHeight = 0;
};

View File

@@ -0,0 +1,219 @@
#include "RuntimeRenderScene.h"
#include <string>
#include <vector>
#ifndef GL_FRAMEBUFFER_BINDING
#define GL_FRAMEBUFFER_BINDING 0x8CA6
#endif
bool RuntimeRenderScene::HasLayers()
{
ConsumePreparedPrograms();
for (const std::string& layerId : mLayerOrder)
{
const LayerProgram* layer = FindLayer(layerId);
if (!layer)
continue;
for (const LayerProgram::PassProgram& pass : layer->passes)
{
if (pass.renderer && pass.renderer->HasProgram())
return true;
}
}
return false;
}
void RuntimeRenderScene::RenderFrame(uint64_t frameIndex, unsigned width, unsigned height, GLuint videoInputTexture)
{
ConsumePreparedPrograms();
std::vector<LayerProgram*> readyLayers;
for (const std::string& layerId : mLayerOrder)
{
LayerProgram* layer = FindLayer(layerId);
if (!layer)
continue;
for (const LayerProgram::PassProgram& pass : layer->passes)
{
if (pass.renderer && pass.renderer->HasProgram())
{
readyLayers.push_back(layer);
break;
}
}
}
if (readyLayers.empty())
return;
GLint outputFramebuffer = 0;
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &outputFramebuffer);
if (readyLayers.size() == 1)
{
RenderLayer(*readyLayers.front(), frameIndex, width, height, videoInputTexture, videoInputTexture, static_cast<GLuint>(outputFramebuffer), true);
return;
}
if (!EnsureLayerTargets(width, height))
{
glBindFramebuffer(GL_FRAMEBUFFER, static_cast<GLuint>(outputFramebuffer));
RenderLayer(*readyLayers.back(), frameIndex, width, height, videoInputTexture, videoInputTexture, static_cast<GLuint>(outputFramebuffer), true);
return;
}
// Shader source contract:
// - gVideoInput is the decoded current input texture for every layer in the stack.
// - gLayerInput starts as gVideoInput for the first layer, then becomes the previous layer output.
GLuint layerInputTexture = videoInputTexture;
std::size_t nextTargetIndex = 0;
for (std::size_t layerIndex = 0; layerIndex < readyLayers.size(); ++layerIndex)
{
const bool isFinalLayer = layerIndex == readyLayers.size() - 1;
if (isFinalLayer)
{
glBindFramebuffer(GL_FRAMEBUFFER, static_cast<GLuint>(outputFramebuffer));
RenderLayer(*readyLayers[layerIndex], frameIndex, width, height, videoInputTexture, layerInputTexture, static_cast<GLuint>(outputFramebuffer), true);
continue;
}
RenderLayer(*readyLayers[layerIndex], frameIndex, width, height, videoInputTexture, layerInputTexture, mLayerFramebuffers[nextTargetIndex], true);
layerInputTexture = mLayerTextures[nextTargetIndex];
nextTargetIndex = 1 - nextTargetIndex;
}
}
GLuint RuntimeRenderScene::RenderLayer(
LayerProgram& layer,
uint64_t frameIndex,
unsigned width,
unsigned height,
GLuint videoInputTexture,
GLuint layerInputTexture,
GLuint outputFramebuffer,
bool renderToOutput)
{
GLuint namedOutputs[2] = {};
std::string namedOutputNames[2];
std::size_t nextTargetIndex = 2;
GLuint lastOutputTexture = layerInputTexture;
for (LayerProgram::PassProgram& pass : layer.passes)
{
if (!pass.renderer || !pass.renderer->HasProgram())
continue;
GLuint sourceTexture = videoInputTexture;
if (!pass.inputNames.empty())
{
const std::string& inputName = pass.inputNames.front();
if (inputName == "videoInput")
{
sourceTexture = videoInputTexture;
}
else if (inputName != "layerInput")
{
// Named intermediate pass inputs currently use the gVideoInput binding slot as the
// selected pass source. Layer stack shaders should use gLayerInput for previous-layer
// sampling and gVideoInput for the original input frame.
for (std::size_t index = 0; index < 2; ++index)
{
if (namedOutputNames[index] == inputName)
{
sourceTexture = namedOutputs[index];
break;
}
}
}
}
const bool writesLayerOutput = pass.outputName == "layerOutput";
if (writesLayerOutput && renderToOutput)
{
glBindFramebuffer(GL_FRAMEBUFFER, outputFramebuffer);
pass.renderer->RenderFrame(frameIndex, width, height, sourceTexture, layerInputTexture);
lastOutputTexture = 0;
continue;
}
if (!EnsureLayerTargets(width, height))
continue;
const std::size_t targetIndex = nextTargetIndex;
nextTargetIndex = nextTargetIndex == 2 ? 3 : 2;
glBindFramebuffer(GL_FRAMEBUFFER, mLayerFramebuffers[targetIndex]);
pass.renderer->RenderFrame(frameIndex, width, height, sourceTexture, layerInputTexture);
const std::size_t namedIndex = targetIndex - 2;
namedOutputs[namedIndex] = mLayerTextures[targetIndex];
namedOutputNames[namedIndex] = pass.outputName;
lastOutputTexture = mLayerTextures[targetIndex];
}
return lastOutputTexture;
}
bool RuntimeRenderScene::EnsureLayerTargets(unsigned width, unsigned height)
{
if (width == 0 || height == 0)
return false;
if (mLayerFramebuffers[0] != 0 && mLayerFramebuffers[1] != 0 && mLayerFramebuffers[2] != 0 && mLayerFramebuffers[3] != 0
&& mLayerTextures[0] != 0 && mLayerTextures[1] != 0 && mLayerTextures[2] != 0 && mLayerTextures[3] != 0
&& mLayerTargetWidth == width && mLayerTargetHeight == height)
return true;
DestroyLayerTargets();
mLayerTargetWidth = width;
mLayerTargetHeight = height;
glGenFramebuffers(4, mLayerFramebuffers);
glGenTextures(4, mLayerTextures);
for (int index = 0; index < 4; ++index)
{
glBindTexture(GL_TEXTURE_2D, mLayerTextures[index]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
static_cast<GLsizei>(width),
static_cast<GLsizei>(height),
0,
GL_BGRA,
GL_UNSIGNED_INT_8_8_8_8_REV,
nullptr);
glBindFramebuffer(GL_FRAMEBUFFER, mLayerFramebuffers[index]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mLayerTextures[index], 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindTexture(GL_TEXTURE_2D, 0);
DestroyLayerTargets();
return false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindTexture(GL_TEXTURE_2D, 0);
return true;
}
void RuntimeRenderScene::DestroyLayerTargets()
{
if (mLayerFramebuffers[0] != 0 || mLayerFramebuffers[1] != 0 || mLayerFramebuffers[2] != 0 || mLayerFramebuffers[3] != 0)
glDeleteFramebuffers(4, mLayerFramebuffers);
if (mLayerTextures[0] != 0 || mLayerTextures[1] != 0 || mLayerTextures[2] != 0 || mLayerTextures[3] != 0)
glDeleteTextures(4, mLayerTextures);
for (int index = 0; index < 4; ++index)
{
mLayerFramebuffers[index] = 0;
mLayerTextures[index] = 0;
}
mLayerTargetWidth = 0;
mLayerTargetHeight = 0;
}

View File

@@ -0,0 +1,123 @@
#include "RuntimeShaderParams.h"
#include "Std140Buffer.h"
#include <chrono>
namespace
{
ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& definition)
{
ShaderParameterValue value;
switch (definition.type)
{
case ShaderParameterType::Float:
value.numberValues = definition.defaultNumbers.empty() ? std::vector<double>{ 0.0 } : definition.defaultNumbers;
break;
case ShaderParameterType::Vec2:
value.numberValues = definition.defaultNumbers.size() == 2 ? definition.defaultNumbers : std::vector<double>{ 0.0, 0.0 };
break;
case ShaderParameterType::Color:
value.numberValues = definition.defaultNumbers.size() == 4 ? definition.defaultNumbers : std::vector<double>{ 1.0, 1.0, 1.0, 1.0 };
break;
case ShaderParameterType::Boolean:
value.booleanValue = definition.defaultBoolean;
break;
case ShaderParameterType::Enum:
value.enumValue = definition.defaultEnumValue;
break;
case ShaderParameterType::Text:
value.textValue = definition.defaultTextValue;
break;
case ShaderParameterType::Trigger:
value.numberValues = { 0.0, -1000000.0 };
break;
}
return value;
}
int EnumIndexForDefault(const ShaderParameterDefinition& definition, const ShaderParameterValue& value)
{
for (std::size_t optionIndex = 0; optionIndex < definition.enumOptions.size(); ++optionIndex)
{
if (definition.enumOptions[optionIndex].value == value.enumValue)
return static_cast<int>(optionIndex);
}
return 0;
}
double UtcSecondsOfDay()
{
const auto now = std::chrono::system_clock::now();
const auto seconds = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
const long long secondsPerDay = 24 * 60 * 60;
long long daySeconds = seconds % secondsPerDay;
if (daySeconds < 0)
daySeconds += secondsPerDay;
return static_cast<double>(daySeconds);
}
}
std::vector<unsigned char> BuildRuntimeShaderGlobalParamsStd140(
const RuntimeShaderArtifact& artifact,
uint64_t frameIndex,
unsigned width,
unsigned height)
{
std::vector<unsigned char> buffer;
buffer.reserve(512);
AppendStd140Float(buffer, static_cast<float>(frameIndex) / 60.0f);
AppendStd140Vec2(buffer, static_cast<float>(width), static_cast<float>(height));
AppendStd140Vec2(buffer, static_cast<float>(width), static_cast<float>(height));
AppendStd140Float(buffer, static_cast<float>(UtcSecondsOfDay()));
AppendStd140Float(buffer, 0.0f);
AppendStd140Float(buffer, 0.37f);
AppendStd140Float(buffer, static_cast<float>(frameIndex));
AppendStd140Float(buffer, 1.0f);
AppendStd140Float(buffer, 0.0f);
AppendStd140Int(buffer, 0);
AppendStd140Int(buffer, 0);
AppendStd140Int(buffer, 0);
for (const ShaderParameterDefinition& definition : artifact.parameterDefinitions)
{
const auto valueIt = artifact.parameterValues.find(definition.id);
const ShaderParameterValue value = valueIt == artifact.parameterValues.end()
? DefaultValueForDefinition(definition)
: valueIt->second;
switch (definition.type)
{
case ShaderParameterType::Float:
AppendStd140Float(buffer, value.numberValues.empty() ? 0.0f : static_cast<float>(value.numberValues[0]));
break;
case ShaderParameterType::Vec2:
AppendStd140Vec2(buffer,
value.numberValues.size() > 0 ? static_cast<float>(value.numberValues[0]) : 0.0f,
value.numberValues.size() > 1 ? static_cast<float>(value.numberValues[1]) : 0.0f);
break;
case ShaderParameterType::Color:
AppendStd140Vec4(buffer,
value.numberValues.size() > 0 ? static_cast<float>(value.numberValues[0]) : 1.0f,
value.numberValues.size() > 1 ? static_cast<float>(value.numberValues[1]) : 1.0f,
value.numberValues.size() > 2 ? static_cast<float>(value.numberValues[2]) : 1.0f,
value.numberValues.size() > 3 ? static_cast<float>(value.numberValues[3]) : 1.0f);
break;
case ShaderParameterType::Boolean:
AppendStd140Int(buffer, value.booleanValue ? 1 : 0);
break;
case ShaderParameterType::Enum:
AppendStd140Int(buffer, EnumIndexForDefault(definition, value));
break;
case ShaderParameterType::Text:
break;
case ShaderParameterType::Trigger:
AppendStd140Int(buffer, value.numberValues.empty() ? 0 : static_cast<int>(value.numberValues[0]));
AppendStd140Float(buffer, value.numberValues.size() > 1 ? static_cast<float>(value.numberValues[1]) : -1000000.0f);
break;
}
}
buffer.resize(AlignStd140(buffer.size(), 16), 0);
return buffer;
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include "../runtime/RuntimeShaderArtifact.h"
#include <cstdint>
#include <vector>
std::vector<unsigned char> BuildRuntimeShaderGlobalParamsStd140(
const RuntimeShaderArtifact& artifact,
uint64_t frameIndex,
unsigned width,
unsigned height);

View File

@@ -0,0 +1,179 @@
#include "RuntimeShaderPrepareWorker.h"
#include "../../platform/HiddenGlWindow.h"
#include "RuntimeShaderRenderer.h"
#include <algorithm>
#include <chrono>
#include <functional>
#include <utility>
RuntimeShaderPrepareWorker::~RuntimeShaderPrepareWorker()
{
Stop();
}
bool RuntimeShaderPrepareWorker::Start(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error)
{
if (mThread.joinable())
return true;
if (!sharedWindow || sharedWindow->DeviceContext() == nullptr || sharedWindow->Context() == nullptr)
{
error = "Runtime shader prepare worker needs an existing shared GL context.";
return false;
}
mWindow = std::move(sharedWindow);
mStopping.store(false, std::memory_order_release);
mStarted.store(false, std::memory_order_release);
{
std::lock_guard<std::mutex> lock(mMutex);
mStartupReady = false;
mStartupError.clear();
}
mThread = std::thread([this]() { ThreadMain(); });
std::unique_lock<std::mutex> lock(mMutex);
if (!mStartupCondition.wait_for(lock, std::chrono::seconds(3), [this]() {
return mStartupReady || !mStartupError.empty();
}))
{
error = "Timed out starting runtime shader prepare worker.";
lock.unlock();
Stop();
return false;
}
if (!mStartupError.empty())
{
error = mStartupError;
lock.unlock();
Stop();
return false;
}
return true;
}
void RuntimeShaderPrepareWorker::Stop()
{
mStopping.store(true, std::memory_order_release);
mCondition.notify_all();
if (mThread.joinable())
mThread.join();
std::deque<RuntimePreparedShaderProgram> completed;
{
std::lock_guard<std::mutex> lock(mMutex);
mRequests.clear();
completed.swap(mCompleted);
}
for (RuntimePreparedShaderProgram& program : completed)
program.ReleaseGl();
mWindow.reset();
mStarted.store(false, std::memory_order_release);
}
void RuntimeShaderPrepareWorker::Submit(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers)
{
std::lock_guard<std::mutex> lock(mMutex);
for (const RenderCadenceCompositor::RuntimeRenderLayerModel& layer : layers)
{
if (layer.artifact.passes.empty() && layer.artifact.fragmentShaderSource.empty())
continue;
std::vector<RuntimeShaderPassArtifact> passes = layer.artifact.passes;
if (passes.empty())
{
RuntimeShaderPassArtifact pass;
pass.passId = "main";
pass.fragmentShaderSource = layer.artifact.fragmentShaderSource;
pass.outputName = "layerOutput";
passes.push_back(std::move(pass));
}
auto sameLayer = [&layer](const PrepareRequest& existing) {
return existing.layerId == layer.id;
};
mRequests.erase(std::remove_if(mRequests.begin(), mRequests.end(), sameLayer), mRequests.end());
for (const RuntimeShaderPassArtifact& pass : passes)
{
PrepareRequest request;
request.layerId = layer.id;
request.shaderId = layer.shaderId;
request.passId = pass.passId;
request.sourceFingerprint = Fingerprint(layer.artifact);
request.artifact = layer.artifact;
request.passArtifact = pass;
mRequests.push_back(std::move(request));
}
}
mCondition.notify_one();
}
bool RuntimeShaderPrepareWorker::TryConsume(RuntimePreparedShaderProgram& preparedProgram)
{
std::lock_guard<std::mutex> lock(mMutex);
if (mCompleted.empty())
return false;
preparedProgram = std::move(mCompleted.front());
mCompleted.pop_front();
return true;
}
void RuntimeShaderPrepareWorker::ThreadMain()
{
if (!mWindow || !mWindow->MakeCurrent())
{
std::lock_guard<std::mutex> lock(mMutex);
mStartupError = "Runtime shader prepare worker could not make shared GL context current.";
mStartupCondition.notify_all();
return;
}
{
std::lock_guard<std::mutex> lock(mMutex);
mStartupReady = true;
}
mStarted.store(true, std::memory_order_release);
mStartupCondition.notify_all();
while (!mStopping.load(std::memory_order_acquire))
{
PrepareRequest request;
{
std::unique_lock<std::mutex> lock(mMutex);
mCondition.wait(lock, [this]() {
return mStopping.load(std::memory_order_acquire) || !mRequests.empty();
});
if (mStopping.load(std::memory_order_acquire))
break;
request = std::move(mRequests.front());
mRequests.pop_front();
}
RuntimePreparedShaderProgram preparedProgram;
RuntimeShaderRenderer::BuildPreparedPassProgram(
request.layerId,
request.sourceFingerprint,
request.artifact,
request.passArtifact,
preparedProgram);
glFlush();
std::lock_guard<std::mutex> lock(mMutex);
mCompleted.push_back(std::move(preparedProgram));
}
mWindow->ClearCurrent();
}
std::string RuntimeShaderPrepareWorker::Fingerprint(const RuntimeShaderArtifact& artifact)
{
const std::hash<std::string> hasher;
std::string source;
for (const RuntimeShaderPassArtifact& pass : artifact.passes)
source += pass.passId + ":" + pass.outputName + ":" + pass.fragmentShaderSource + "\n";
if (source.empty())
source = artifact.fragmentShaderSource;
return artifact.shaderId + ":" + std::to_string(source.size()) + ":" + std::to_string(hasher(source));
}

View File

@@ -0,0 +1,58 @@
#pragma once
#include "RuntimeShaderProgram.h"
#include "../../runtime/RuntimeLayerModel.h"
#include <windows.h>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
class HiddenGlWindow;
class RuntimeShaderPrepareWorker
{
public:
RuntimeShaderPrepareWorker() = default;
RuntimeShaderPrepareWorker(const RuntimeShaderPrepareWorker&) = delete;
RuntimeShaderPrepareWorker& operator=(const RuntimeShaderPrepareWorker&) = delete;
~RuntimeShaderPrepareWorker();
bool Start(std::unique_ptr<HiddenGlWindow> sharedWindow, std::string& error);
void Stop();
void Submit(const std::vector<RenderCadenceCompositor::RuntimeRenderLayerModel>& layers);
bool TryConsume(RuntimePreparedShaderProgram& preparedProgram);
private:
struct PrepareRequest
{
std::string layerId;
std::string shaderId;
std::string passId;
std::string sourceFingerprint;
RuntimeShaderArtifact artifact;
RuntimeShaderPassArtifact passArtifact;
};
void ThreadMain();
static std::string Fingerprint(const RuntimeShaderArtifact& artifact);
std::unique_ptr<HiddenGlWindow> mWindow;
std::mutex mMutex;
std::condition_variable mCondition;
std::deque<PrepareRequest> mRequests;
std::deque<RuntimePreparedShaderProgram> mCompleted;
std::condition_variable mStartupCondition;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mStarted{ false };
bool mStartupReady = false;
std::string mStartupError;
};

View File

@@ -0,0 +1,37 @@
#pragma once
#include "GLExtensions.h"
#include "../../runtime/RuntimeShaderArtifact.h"
#include <string>
#include <vector>
struct RuntimePreparedShaderProgram
{
std::string layerId;
std::string shaderId;
std::string passId;
std::string sourceFingerprint;
RuntimeShaderArtifact artifact;
RuntimeShaderPassArtifact passArtifact;
std::vector<std::string> inputNames;
std::string outputName;
GLuint program = 0;
GLuint vertexShader = 0;
GLuint fragmentShader = 0;
bool succeeded = false;
std::string error;
void ReleaseGl()
{
if (program != 0)
glDeleteProgram(program);
if (vertexShader != 0)
glDeleteShader(vertexShader);
if (fragmentShader != 0)
glDeleteShader(fragmentShader);
program = 0;
vertexShader = 0;
fragmentShader = 0;
}
};

View File

@@ -0,0 +1,320 @@
#include "RuntimeShaderRenderer.h"
#include "RuntimeShaderParams.h"
#include <array>
#include <cstring>
#include <string>
namespace
{
constexpr GLuint kGlobalParamsBindingPoint = 0;
constexpr GLuint kVideoInputTextureUnit = 0;
constexpr GLuint kLayerInputTextureUnit = 1;
const char* kVertexShaderSource = R"GLSL(
#version 430 core
out vec2 vTexCoord;
void main()
{
vec2 positions[3] = vec2[3](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0));
vec2 texCoords[3] = vec2[3](
vec2(0.0, 0.0),
vec2(2.0, 0.0),
vec2(0.0, 2.0));
gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
vTexCoord = texCoords[gl_VertexID];
}
)GLSL";
}
RuntimeShaderRenderer::~RuntimeShaderRenderer()
{
ShutdownGl();
}
bool RuntimeShaderRenderer::CommitPreparedProgram(RuntimePreparedShaderProgram& preparedProgram, std::string& error)
{
if (!preparedProgram.succeeded || preparedProgram.program == 0)
{
error = preparedProgram.error.empty() ? "Prepared runtime shader program is not valid." : preparedProgram.error;
return false;
}
if (!EnsureStaticGlResources(error))
return false;
DestroyProgram();
mProgram = preparedProgram.program;
mVertexShader = preparedProgram.vertexShader;
mFragmentShader = preparedProgram.fragmentShader;
mArtifact = preparedProgram.artifact;
preparedProgram.program = 0;
preparedProgram.vertexShader = 0;
preparedProgram.fragmentShader = 0;
return true;
}
void RuntimeShaderRenderer::UpdateArtifactState(const RuntimeShaderArtifact& artifact)
{
mArtifact.parameterDefinitions = artifact.parameterDefinitions;
mArtifact.parameterValues = artifact.parameterValues;
mArtifact.message = artifact.message;
}
bool RuntimeShaderRenderer::BuildPreparedProgram(
const std::string& layerId,
const std::string& sourceFingerprint,
const RuntimeShaderArtifact& artifact,
RuntimePreparedShaderProgram& preparedProgram)
{
RuntimeShaderPassArtifact passArtifact;
passArtifact.passId = "main";
passArtifact.fragmentShaderSource = artifact.fragmentShaderSource;
passArtifact.outputName = "layerOutput";
if (!artifact.passes.empty())
passArtifact = artifact.passes.front();
return BuildPreparedPassProgram(layerId, sourceFingerprint, artifact, passArtifact, preparedProgram);
}
bool RuntimeShaderRenderer::BuildPreparedPassProgram(
const std::string& layerId,
const std::string& sourceFingerprint,
const RuntimeShaderArtifact& artifact,
const RuntimeShaderPassArtifact& passArtifact,
RuntimePreparedShaderProgram& preparedProgram)
{
preparedProgram = RuntimePreparedShaderProgram();
preparedProgram.layerId = layerId;
preparedProgram.shaderId = artifact.shaderId;
preparedProgram.passId = passArtifact.passId;
preparedProgram.sourceFingerprint = sourceFingerprint;
preparedProgram.artifact = artifact;
preparedProgram.passArtifact = passArtifact;
preparedProgram.inputNames = passArtifact.inputNames;
preparedProgram.outputName = passArtifact.outputName.empty() ? passArtifact.passId : passArtifact.outputName;
if (passArtifact.fragmentShaderSource.empty())
{
preparedProgram.error = "Cannot prepare an empty fragment shader.";
return false;
}
if (!BuildProgram(
passArtifact.fragmentShaderSource,
preparedProgram.program,
preparedProgram.vertexShader,
preparedProgram.fragmentShader,
preparedProgram.error))
{
preparedProgram.ReleaseGl();
return false;
}
preparedProgram.succeeded = true;
AssignSamplerUniforms(preparedProgram.program);
return true;
}
void RuntimeShaderRenderer::RenderFrame(uint64_t frameIndex, unsigned width, unsigned height, GLuint sourceTexture, GLuint layerInputTexture)
{
if (mProgram == 0)
return;
glViewport(0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height));
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
UpdateGlobalParams(frameIndex, width, height);
BindRuntimeTextures(sourceTexture, layerInputTexture);
glBindVertexArray(mVertexArray);
glUseProgram(mProgram);
glDrawArrays(GL_TRIANGLES, 0, 3);
glUseProgram(0);
glBindVertexArray(0);
}
void RuntimeShaderRenderer::ShutdownGl()
{
DestroyProgram();
DestroyStaticGlResources();
}
bool RuntimeShaderRenderer::EnsureStaticGlResources(std::string& error)
{
if (mVertexArray == 0)
glGenVertexArrays(1, &mVertexArray);
if (mGlobalParamsBuffer == 0)
{
glGenBuffers(1, &mGlobalParamsBuffer);
glBindBuffer(GL_UNIFORM_BUFFER, mGlobalParamsBuffer);
glBufferData(GL_UNIFORM_BUFFER, 1024, nullptr, GL_DYNAMIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
}
if (mFallbackSourceTexture == 0)
{
const unsigned char pixels[] = {
0, 0, 0, 255,
96, 64, 32, 255,
64, 96, 160, 255,
255, 255, 255, 255
};
glGenTextures(1, &mFallbackSourceTexture);
glBindTexture(GL_TEXTURE_2D, mFallbackSourceTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_BGRA, GL_UNSIGNED_BYTE, pixels);
glBindTexture(GL_TEXTURE_2D, 0);
}
if (mVertexArray == 0 || mGlobalParamsBuffer == 0 || mFallbackSourceTexture == 0)
{
error = "Failed to create runtime shader GL resources.";
return false;
}
return true;
}
bool RuntimeShaderRenderer::BuildProgram(const std::string& fragmentShaderSource, GLuint& program, GLuint& vertexShader, GLuint& fragmentShader, std::string& error)
{
program = 0;
vertexShader = 0;
fragmentShader = 0;
if (!CompileShader(GL_VERTEX_SHADER, kVertexShaderSource, vertexShader, error))
return false;
if (!CompileShader(GL_FRAGMENT_SHADER, fragmentShaderSource.c_str(), fragmentShader, error))
{
glDeleteShader(vertexShader);
vertexShader = 0;
return false;
}
program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
GLint linkResult = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linkResult);
if (linkResult == GL_FALSE)
{
std::array<char, 4096> log = {};
GLsizei length = 0;
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), &length, log.data());
error = std::string(log.data(), static_cast<std::size_t>(length));
glDeleteProgram(program);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
program = 0;
vertexShader = 0;
fragmentShader = 0;
return false;
}
const GLuint globalParamsIndex = glGetUniformBlockIndex(program, "GlobalParams");
if (globalParamsIndex != GL_INVALID_INDEX)
glUniformBlockBinding(program, globalParamsIndex, kGlobalParamsBindingPoint);
return true;
}
void RuntimeShaderRenderer::AssignSamplerUniforms(GLuint program)
{
glUseProgram(program);
const GLint videoInputLocation = glGetUniformLocation(program, "gVideoInput");
if (videoInputLocation >= 0)
glUniform1i(videoInputLocation, static_cast<GLint>(kVideoInputTextureUnit));
const GLint videoInputArrayLocation = glGetUniformLocation(program, "gVideoInput_0");
if (videoInputArrayLocation >= 0)
glUniform1i(videoInputArrayLocation, static_cast<GLint>(kVideoInputTextureUnit));
const GLint layerInputLocation = glGetUniformLocation(program, "gLayerInput");
if (layerInputLocation >= 0)
glUniform1i(layerInputLocation, static_cast<GLint>(kLayerInputTextureUnit));
const GLint layerInputArrayLocation = glGetUniformLocation(program, "gLayerInput_0");
if (layerInputArrayLocation >= 0)
glUniform1i(layerInputArrayLocation, static_cast<GLint>(kLayerInputTextureUnit));
glUseProgram(0);
}
void RuntimeShaderRenderer::UpdateGlobalParams(uint64_t frameIndex, unsigned width, unsigned height)
{
std::vector<unsigned char>& buffer = mGlobalParamsScratch;
buffer = BuildRuntimeShaderGlobalParamsStd140(mArtifact, frameIndex, width, height);
glBindBuffer(GL_UNIFORM_BUFFER, mGlobalParamsBuffer);
const GLsizeiptr bufferSize = static_cast<GLsizeiptr>(buffer.size());
if (mGlobalParamsBufferSize != bufferSize)
{
glBufferData(GL_UNIFORM_BUFFER, bufferSize, buffer.data(), GL_DYNAMIC_DRAW);
mGlobalParamsBufferSize = bufferSize;
}
else
{
glBufferSubData(GL_UNIFORM_BUFFER, 0, bufferSize, buffer.data());
}
glBindBufferBase(GL_UNIFORM_BUFFER, kGlobalParamsBindingPoint, mGlobalParamsBuffer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
}
void RuntimeShaderRenderer::BindRuntimeTextures(GLuint sourceTexture, GLuint layerInputTexture)
{
const GLuint resolvedSourceTexture = sourceTexture != 0 ? sourceTexture : mFallbackSourceTexture;
const GLuint resolvedLayerInputTexture = layerInputTexture != 0 ? layerInputTexture : resolvedSourceTexture;
glActiveTexture(GL_TEXTURE0 + kVideoInputTextureUnit);
glBindTexture(GL_TEXTURE_2D, resolvedSourceTexture);
glActiveTexture(GL_TEXTURE0 + kLayerInputTextureUnit);
glBindTexture(GL_TEXTURE_2D, resolvedLayerInputTexture);
glActiveTexture(GL_TEXTURE0);
}
bool RuntimeShaderRenderer::CompileShader(GLenum shaderType, const char* source, GLuint& shader, std::string& error)
{
shader = glCreateShader(shaderType);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compileResult = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compileResult);
if (compileResult != GL_FALSE)
return true;
std::array<char, 4096> log = {};
GLsizei length = 0;
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), &length, log.data());
error = std::string(log.data(), static_cast<std::size_t>(length));
glDeleteShader(shader);
shader = 0;
return false;
}
void RuntimeShaderRenderer::DestroyProgram()
{
if (mProgram != 0)
glDeleteProgram(mProgram);
if (mVertexShader != 0)
glDeleteShader(mVertexShader);
if (mFragmentShader != 0)
glDeleteShader(mFragmentShader);
mProgram = 0;
mVertexShader = 0;
mFragmentShader = 0;
}
void RuntimeShaderRenderer::DestroyStaticGlResources()
{
if (mGlobalParamsBuffer != 0)
glDeleteBuffers(1, &mGlobalParamsBuffer);
if (mVertexArray != 0)
glDeleteVertexArrays(1, &mVertexArray);
if (mFallbackSourceTexture != 0)
glDeleteTextures(1, &mFallbackSourceTexture);
mGlobalParamsBuffer = 0;
mGlobalParamsBufferSize = 0;
mVertexArray = 0;
mFallbackSourceTexture = 0;
}

View File

@@ -0,0 +1,56 @@
#pragma once
#include "GLExtensions.h"
#include "RuntimeShaderProgram.h"
#include "../../runtime/RuntimeShaderArtifact.h"
#include <cstdint>
#include <string>
#include <vector>
class RuntimeShaderRenderer
{
public:
RuntimeShaderRenderer() = default;
RuntimeShaderRenderer(const RuntimeShaderRenderer&) = delete;
RuntimeShaderRenderer& operator=(const RuntimeShaderRenderer&) = delete;
~RuntimeShaderRenderer();
bool CommitPreparedProgram(RuntimePreparedShaderProgram& preparedProgram, std::string& error);
bool HasProgram() const { return mProgram != 0; }
void UpdateArtifactState(const RuntimeShaderArtifact& artifact);
void RenderFrame(uint64_t frameIndex, unsigned width, unsigned height, GLuint sourceTexture = 0, GLuint layerInputTexture = 0);
void ShutdownGl();
static bool BuildPreparedProgram(
const std::string& layerId,
const std::string& sourceFingerprint,
const RuntimeShaderArtifact& artifact,
RuntimePreparedShaderProgram& preparedProgram);
static bool BuildPreparedPassProgram(
const std::string& layerId,
const std::string& sourceFingerprint,
const RuntimeShaderArtifact& artifact,
const RuntimeShaderPassArtifact& passArtifact,
RuntimePreparedShaderProgram& preparedProgram);
private:
bool EnsureStaticGlResources(std::string& error);
static bool CompileShader(GLenum shaderType, const char* source, GLuint& shader, std::string& error);
static bool BuildProgram(const std::string& fragmentShaderSource, GLuint& program, GLuint& vertexShader, GLuint& fragmentShader, std::string& error);
static void AssignSamplerUniforms(GLuint program);
void UpdateGlobalParams(uint64_t frameIndex, unsigned width, unsigned height);
void BindRuntimeTextures(GLuint sourceTexture, GLuint layerInputTexture);
void DestroyProgram();
void DestroyStaticGlResources();
RuntimeShaderArtifact mArtifact;
GLuint mProgram = 0;
GLuint mVertexShader = 0;
GLuint mFragmentShader = 0;
GLuint mVertexArray = 0;
GLuint mGlobalParamsBuffer = 0;
GLsizeiptr mGlobalParamsBufferSize = 0;
GLuint mFallbackSourceTexture = 0;
std::vector<unsigned char> mGlobalParamsScratch;
};

View File

@@ -0,0 +1,379 @@
#include "RuntimeLayerModel.h"
#include "RuntimeParameterUtils.h"
#include <algorithm>
#include <chrono>
#include <utility>
namespace RenderCadenceCompositor
{
bool RuntimeLayerModel::InitializeSingleLayer(const SupportedShaderCatalog& shaderCatalog, const std::string& shaderId, std::string& error)
{
Clear();
if (shaderId.empty())
{
error.clear();
return true;
}
const ShaderPackage* shaderPackage = shaderCatalog.FindPackage(shaderId);
if (!shaderPackage)
{
error = "Shader '" + shaderId + "' is not in the supported shader catalog.";
return false;
}
Layer layer;
layer.id = AllocateLayerId();
layer.shaderId = shaderPackage->id;
layer.shaderName = shaderPackage->displayName.empty() ? shaderPackage->id : shaderPackage->displayName;
layer.buildState = RuntimeLayerBuildState::Pending;
layer.message = "Runtime Slang build is waiting to start.";
InitializeDefaultParameterValues(layer, *shaderPackage);
mLayers.push_back(std::move(layer));
error.clear();
return true;
}
bool RuntimeLayerModel::AddLayer(const SupportedShaderCatalog& shaderCatalog, const std::string& shaderId, std::string& layerId, std::string& error)
{
const ShaderPackage* shaderPackage = shaderCatalog.FindPackage(shaderId);
if (!shaderPackage)
{
error = "Shader '" + shaderId + "' is not in the supported shader catalog.";
return false;
}
Layer layer;
layer.id = AllocateLayerId();
layer.shaderId = shaderPackage->id;
layer.shaderName = shaderPackage->displayName.empty() ? shaderPackage->id : shaderPackage->displayName;
layer.buildState = RuntimeLayerBuildState::Pending;
layer.message = "Runtime Slang build is waiting to start.";
InitializeDefaultParameterValues(layer, *shaderPackage);
layerId = layer.id;
mLayers.push_back(std::move(layer));
error.clear();
return true;
}
bool RuntimeLayerModel::RemoveLayer(const std::string& layerId, std::string& error)
{
for (auto layerIt = mLayers.begin(); layerIt != mLayers.end(); ++layerIt)
{
if (layerIt->id != layerId)
continue;
mLayers.erase(layerIt);
error.clear();
return true;
}
error = "Unknown runtime layer id: " + layerId;
return false;
}
bool RuntimeLayerModel::ReorderLayer(const std::string& layerId, int targetIndex, std::string& error)
{
auto layerIt = std::find_if(mLayers.begin(), mLayers.end(), [&layerId](const Layer& layer) {
return layer.id == layerId;
});
if (layerIt == mLayers.end())
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
if (targetIndex < 0)
targetIndex = 0;
if (targetIndex >= static_cast<int>(mLayers.size()))
targetIndex = static_cast<int>(mLayers.size()) - 1;
Layer layer = std::move(*layerIt);
mLayers.erase(layerIt);
std::size_t destinationIndex = static_cast<std::size_t>(targetIndex);
if (destinationIndex > mLayers.size())
destinationIndex = mLayers.size();
mLayers.insert(mLayers.begin() + static_cast<std::ptrdiff_t>(destinationIndex), std::move(layer));
error.clear();
return true;
}
bool RuntimeLayerModel::SetLayerBypass(const std::string& layerId, bool bypass, std::string& error)
{
Layer* layer = FindLayer(layerId);
if (!layer)
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
layer->bypass = bypass;
error.clear();
return true;
}
bool RuntimeLayerModel::SetLayerShader(const SupportedShaderCatalog& shaderCatalog, const std::string& layerId, const std::string& shaderId, std::string& error)
{
Layer* layer = FindLayer(layerId);
if (!layer)
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
const ShaderPackage* shaderPackage = shaderCatalog.FindPackage(shaderId);
if (!shaderPackage)
{
error = "Shader '" + shaderId + "' is not in the supported shader catalog.";
return false;
}
layer->shaderId = shaderPackage->id;
layer->shaderName = shaderPackage->displayName.empty() ? shaderPackage->id : shaderPackage->displayName;
layer->buildState = RuntimeLayerBuildState::Pending;
layer->message = "Runtime Slang build is waiting to start.";
layer->renderReady = false;
layer->artifact = RuntimeShaderArtifact();
InitializeDefaultParameterValues(*layer, *shaderPackage);
error.clear();
return true;
}
bool RuntimeLayerModel::UpdateParameter(const std::string& layerId, const std::string& parameterId, const JsonValue& value, std::string& error)
{
Layer* layer = FindLayer(layerId);
if (!layer)
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
const ShaderParameterDefinition* definition = FindParameterDefinition(*layer, parameterId);
if (!definition)
{
error = "Unknown parameter id '" + parameterId + "' for layer " + layerId + ".";
return false;
}
ShaderParameterValue normalizedValue;
if (definition->type == ShaderParameterType::Trigger)
{
const auto currentIt = layer->parameterValues.find(parameterId);
const double previousCount = currentIt == layer->parameterValues.end() || currentIt->second.numberValues.empty()
? 0.0
: currentIt->second.numberValues.front();
normalizedValue.numberValues = { previousCount + 1.0, RuntimeElapsedSeconds() };
}
else if (!NormalizeAndValidateParameterValue(*definition, value, normalizedValue, error))
{
return false;
}
layer->parameterValues[parameterId] = normalizedValue;
if (layer->renderReady)
layer->artifact.parameterValues = layer->parameterValues;
error.clear();
return true;
}
bool RuntimeLayerModel::ResetParameters(const std::string& layerId, std::string& error)
{
Layer* layer = FindLayer(layerId);
if (!layer)
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
layer->parameterValues.clear();
for (const ShaderParameterDefinition& definition : layer->parameterDefinitions)
layer->parameterValues[definition.id] = DefaultValueForDefinition(definition);
if (layer->renderReady)
layer->artifact.parameterValues = layer->parameterValues;
error.clear();
return true;
}
void RuntimeLayerModel::Clear()
{
mLayers.clear();
}
bool RuntimeLayerModel::MarkBuildStarted(const std::string& layerId, const std::string& message, std::string& error)
{
Layer* layer = FindLayer(layerId);
if (!layer)
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
layer->buildState = RuntimeLayerBuildState::Pending;
layer->message = message;
layer->renderReady = false;
layer->artifact = RuntimeShaderArtifact();
error.clear();
return true;
}
bool RuntimeLayerModel::MarkBuildReady(const RuntimeShaderArtifact& artifact, std::string& error)
{
Layer* layer = artifact.layerId.empty() ? FindFirstLayerForShader(artifact.shaderId) : FindLayer(artifact.layerId);
if (!layer)
{
error = artifact.layerId.empty()
? "No runtime layer is waiting for shader artifact: " + artifact.shaderId
: "No runtime layer is waiting for shader artifact on layer: " + artifact.layerId;
return false;
}
layer->shaderName = artifact.displayName.empty() ? artifact.shaderId : artifact.displayName;
layer->buildState = RuntimeLayerBuildState::Ready;
layer->message = artifact.message;
layer->renderReady = true;
layer->artifact = artifact;
layer->artifact.parameterValues = layer->parameterValues;
error.clear();
return true;
}
bool RuntimeLayerModel::MarkBuildFailedForShader(const std::string& shaderId, const std::string& message)
{
Layer* layer = FindFirstLayerForShader(shaderId);
if (!layer)
return false;
std::string error;
return MarkBuildFailed(layer->id, message, error);
}
bool RuntimeLayerModel::MarkBuildFailed(const std::string& layerId, const std::string& message, std::string& error)
{
Layer* layer = FindLayer(layerId);
if (!layer)
{
error = "Unknown runtime layer id: " + layerId;
return false;
}
layer->buildState = RuntimeLayerBuildState::Failed;
layer->message = message;
layer->renderReady = false;
layer->artifact = RuntimeShaderArtifact();
error.clear();
return true;
}
bool RuntimeLayerModel::MarkRenderCommitFailed(const std::string& layerId, const std::string& message, std::string& error)
{
return MarkBuildFailed(layerId, message, error);
}
RuntimeLayerModelSnapshot RuntimeLayerModel::Snapshot() const
{
RuntimeLayerModelSnapshot snapshot;
snapshot.compileSucceeded = true;
for (const Layer& layer : mLayers)
{
snapshot.displayLayers.push_back(ToReadModel(layer));
if (!layer.message.empty() && snapshot.compileMessage.empty())
snapshot.compileMessage = layer.message;
if (layer.buildState == RuntimeLayerBuildState::Failed)
snapshot.compileSucceeded = false;
if (layer.renderReady)
{
RuntimeRenderLayerModel renderLayer;
renderLayer.id = layer.id;
renderLayer.shaderId = layer.shaderId;
renderLayer.bypass = layer.bypass;
renderLayer.artifact = layer.artifact;
renderLayer.artifact.parameterValues = layer.parameterValues;
snapshot.renderLayers.push_back(std::move(renderLayer));
}
}
if (snapshot.compileMessage.empty())
snapshot.compileMessage = mLayers.empty() ? "Runtime shader build disabled." : "Runtime shader build has not completed yet.";
return snapshot;
}
std::string RuntimeLayerModel::FirstLayerId() const
{
return mLayers.empty() ? std::string() : mLayers.front().id;
}
RuntimeLayerModel::Layer* RuntimeLayerModel::FindLayer(const std::string& layerId)
{
for (Layer& layer : mLayers)
{
if (layer.id == layerId)
return &layer;
}
return nullptr;
}
const RuntimeLayerModel::Layer* RuntimeLayerModel::FindLayer(const std::string& layerId) const
{
for (const Layer& layer : mLayers)
{
if (layer.id == layerId)
return &layer;
}
return nullptr;
}
RuntimeLayerModel::Layer* RuntimeLayerModel::FindFirstLayerForShader(const std::string& shaderId)
{
for (Layer& layer : mLayers)
{
if (layer.shaderId == shaderId)
return &layer;
}
return nullptr;
}
void RuntimeLayerModel::InitializeDefaultParameterValues(Layer& layer, const ShaderPackage& shaderPackage)
{
layer.parameterDefinitions = shaderPackage.parameters;
layer.parameterValues.clear();
for (const ShaderParameterDefinition& definition : layer.parameterDefinitions)
layer.parameterValues[definition.id] = DefaultValueForDefinition(definition);
}
const ShaderParameterDefinition* RuntimeLayerModel::FindParameterDefinition(const Layer& layer, const std::string& parameterId)
{
for (const ShaderParameterDefinition& definition : layer.parameterDefinitions)
{
if (definition.id == parameterId)
return &definition;
}
return nullptr;
}
std::string RuntimeLayerModel::AllocateLayerId()
{
return "runtime-layer-" + std::to_string(mNextLayerNumber++);
}
RuntimeLayerReadModel RuntimeLayerModel::ToReadModel(const Layer& layer)
{
RuntimeLayerReadModel readModel;
readModel.id = layer.id;
readModel.shaderId = layer.shaderId;
readModel.shaderName = layer.shaderName;
readModel.bypass = layer.bypass;
readModel.buildState = layer.buildState;
readModel.message = layer.message;
readModel.renderReady = layer.renderReady;
readModel.parameterDefinitions = layer.parameterDefinitions;
readModel.parameterValues = layer.parameterValues;
return readModel;
}
double RuntimeLayerModel::RuntimeElapsedSeconds() const
{
return std::chrono::duration_cast<std::chrono::duration<double>>(std::chrono::steady_clock::now() - mStartTime).count();
}
}

View File

@@ -0,0 +1,101 @@
#pragma once
#include "RuntimeJson.h"
#include "RuntimeShaderArtifact.h"
#include "SupportedShaderCatalog.h"
#include <chrono>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace RenderCadenceCompositor
{
enum class RuntimeLayerBuildState
{
Pending,
Ready,
Failed
};
struct RuntimeLayerReadModel
{
std::string id;
std::string shaderId;
std::string shaderName;
bool bypass = false;
RuntimeLayerBuildState buildState = RuntimeLayerBuildState::Pending;
std::string message;
bool renderReady = false;
std::vector<ShaderParameterDefinition> parameterDefinitions;
std::map<std::string, ShaderParameterValue> parameterValues;
};
struct RuntimeRenderLayerModel
{
std::string id;
std::string shaderId;
bool bypass = false;
RuntimeShaderArtifact artifact;
};
struct RuntimeLayerModelSnapshot
{
bool compileSucceeded = true;
std::string compileMessage;
std::vector<RuntimeLayerReadModel> displayLayers;
std::vector<RuntimeRenderLayerModel> renderLayers;
};
class RuntimeLayerModel
{
public:
bool InitializeSingleLayer(const SupportedShaderCatalog& shaderCatalog, const std::string& shaderId, std::string& error);
void Clear();
bool AddLayer(const SupportedShaderCatalog& shaderCatalog, const std::string& shaderId, std::string& layerId, std::string& error);
bool RemoveLayer(const std::string& layerId, std::string& error);
bool ReorderLayer(const std::string& layerId, int targetIndex, std::string& error);
bool SetLayerBypass(const std::string& layerId, bool bypass, std::string& error);
bool SetLayerShader(const SupportedShaderCatalog& shaderCatalog, const std::string& layerId, const std::string& shaderId, std::string& error);
bool UpdateParameter(const std::string& layerId, const std::string& parameterId, const JsonValue& value, std::string& error);
bool ResetParameters(const std::string& layerId, std::string& error);
bool MarkBuildStarted(const std::string& layerId, const std::string& message, std::string& error);
bool MarkBuildReady(const RuntimeShaderArtifact& artifact, std::string& error);
bool MarkBuildFailedForShader(const std::string& shaderId, const std::string& message);
bool MarkBuildFailed(const std::string& layerId, const std::string& message, std::string& error);
bool MarkRenderCommitFailed(const std::string& layerId, const std::string& message, std::string& error);
RuntimeLayerModelSnapshot Snapshot() const;
std::string FirstLayerId() const;
private:
struct Layer
{
std::string id;
std::string shaderId;
std::string shaderName;
bool bypass = false;
RuntimeLayerBuildState buildState = RuntimeLayerBuildState::Pending;
std::string message;
bool renderReady = false;
std::vector<ShaderParameterDefinition> parameterDefinitions;
std::map<std::string, ShaderParameterValue> parameterValues;
RuntimeShaderArtifact artifact;
};
Layer* FindLayer(const std::string& layerId);
const Layer* FindLayer(const std::string& layerId) const;
Layer* FindFirstLayerForShader(const std::string& shaderId);
static void InitializeDefaultParameterValues(Layer& layer, const ShaderPackage& shaderPackage);
static const ShaderParameterDefinition* FindParameterDefinition(const Layer& layer, const std::string& parameterId);
std::string AllocateLayerId();
static RuntimeLayerReadModel ToReadModel(const Layer& layer);
double RuntimeElapsedSeconds() const;
std::vector<Layer> mLayers;
uint64_t mNextLayerNumber = 1;
std::chrono::steady_clock::time_point mStartTime = std::chrono::steady_clock::now();
};
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include "ShaderTypes.h"
#include <map>
#include <string>
#include <vector>
struct RuntimeShaderPassArtifact
{
std::string passId;
std::string fragmentShaderSource;
std::vector<std::string> inputNames;
std::string outputName;
};
struct RuntimeShaderArtifact
{
std::string layerId;
std::string shaderId;
std::string displayName;
std::string fragmentShaderSource;
std::vector<RuntimeShaderPassArtifact> passes;
std::string message;
std::vector<ShaderParameterDefinition> parameterDefinitions;
std::map<std::string, ShaderParameterValue> parameterValues;
};

View File

@@ -0,0 +1,75 @@
#include "RuntimeShaderBridge.h"
#include <chrono>
RuntimeShaderBridge::~RuntimeShaderBridge()
{
Stop();
}
void RuntimeShaderBridge::Start(const std::string& shaderId, ArtifactCallback onArtifactReady, ErrorCallback onError)
{
Start(std::string(), shaderId, std::move(onArtifactReady), std::move(onError));
}
void RuntimeShaderBridge::Start(const std::string& layerId, const std::string& shaderId, ArtifactCallback onArtifactReady, ErrorCallback onError)
{
Stop();
if (shaderId.empty())
return;
mLayerId = layerId;
mOnArtifactReady = std::move(onArtifactReady);
mOnError = std::move(onError);
mStopping.store(false, std::memory_order_release);
mFinished.store(false, std::memory_order_release);
mCompiler.StartShaderBuild(shaderId);
mThread = std::thread([this]() { ThreadMain(); });
}
void RuntimeShaderBridge::RequestStop()
{
mStopping.store(true, std::memory_order_release);
}
void RuntimeShaderBridge::Stop()
{
RequestStop();
if (mThread.joinable())
mThread.join();
mCompiler.Stop();
mLayerId.clear();
mOnArtifactReady = ArtifactCallback();
mOnError = ErrorCallback();
mFinished.store(true, std::memory_order_release);
}
bool RuntimeShaderBridge::CanStopWithoutWaiting() const
{
return mFinished.load(std::memory_order_acquire) && !mCompiler.Running();
}
void RuntimeShaderBridge::ThreadMain()
{
while (!mStopping.load(std::memory_order_acquire))
{
RuntimeSlangShaderBuild build;
if (mCompiler.TryConsume(build))
{
if (build.succeeded)
{
build.artifact.layerId = mLayerId;
if (mOnArtifactReady)
mOnArtifactReady(build.artifact);
}
else if (mOnError)
{
mOnError(build.message);
}
mFinished.store(true, std::memory_order_release);
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
mFinished.store(true, std::memory_order_release);
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include "RuntimeShaderArtifact.h"
#include "RuntimeSlangShaderCompiler.h"
#include <atomic>
#include <functional>
#include <string>
#include <thread>
class RuntimeShaderBridge
{
public:
using ArtifactCallback = std::function<void(const RuntimeShaderArtifact&)>;
using ErrorCallback = std::function<void(const std::string&)>;
RuntimeShaderBridge() = default;
RuntimeShaderBridge(const RuntimeShaderBridge&) = delete;
RuntimeShaderBridge& operator=(const RuntimeShaderBridge&) = delete;
~RuntimeShaderBridge();
void Start(const std::string& shaderId, ArtifactCallback onArtifactReady, ErrorCallback onError);
void Start(const std::string& layerId, const std::string& shaderId, ArtifactCallback onArtifactReady, ErrorCallback onError);
void RequestStop();
void Stop();
bool CanStopWithoutWaiting() const;
private:
void ThreadMain();
RuntimeSlangShaderCompiler mCompiler;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mFinished{ true };
std::string mLayerId;
ArtifactCallback mOnArtifactReady;
ErrorCallback mOnError;
};

View File

@@ -0,0 +1,159 @@
#include "RuntimeSlangShaderCompiler.h"
#include "ShaderCompiler.h"
#include "ShaderPackageRegistry.h"
#include "ShaderTypes.h"
#include "SupportedShaderCatalog.h"
#include <chrono>
#include <filesystem>
namespace
{
std::filesystem::path FindRepoRoot()
{
std::filesystem::path current = std::filesystem::current_path();
for (;;)
{
if (std::filesystem::exists(current / "shaders" / "happy-accident" / "shader.slang") &&
std::filesystem::exists(current / "runtime" / "templates" / "shader_wrapper.slang.in"))
{
return current;
}
const std::filesystem::path parent = current.parent_path();
if (parent.empty() || parent == current)
return std::filesystem::current_path();
current = parent;
}
}
}
RuntimeSlangShaderCompiler::~RuntimeSlangShaderCompiler()
{
Stop();
}
void RuntimeSlangShaderCompiler::StartHappyAccidentBuild()
{
StartShaderBuild("happy-accident");
}
void RuntimeSlangShaderCompiler::StartShaderBuild(const std::string& shaderId)
{
if (mRunning.load(std::memory_order_acquire))
return;
if (mThread.joinable())
mThread.join();
{
std::lock_guard<std::mutex> lock(mMutex);
mReadyBuild = RuntimeSlangShaderBuild();
}
mRunning.store(true, std::memory_order_release);
mThread = std::thread([this, shaderId]() {
RuntimeSlangShaderBuild build = BuildShader(shaderId);
{
std::lock_guard<std::mutex> lock(mMutex);
mReadyBuild = std::move(build);
mReadyBuild.available = true;
}
mRunning.store(false, std::memory_order_release);
});
}
void RuntimeSlangShaderCompiler::Stop()
{
if (mThread.joinable())
mThread.join();
mRunning.store(false, std::memory_order_release);
}
bool RuntimeSlangShaderCompiler::TryConsume(RuntimeSlangShaderBuild& build)
{
std::lock_guard<std::mutex> lock(mMutex);
if (!mReadyBuild.available)
return false;
build = std::move(mReadyBuild);
mReadyBuild = RuntimeSlangShaderBuild();
return true;
}
RuntimeSlangShaderBuild RuntimeSlangShaderCompiler::BuildShader(const std::string& shaderId) const
{
RuntimeSlangShaderBuild build;
build.artifact.shaderId = shaderId;
try
{
const std::filesystem::path repoRoot = FindRepoRoot();
const std::filesystem::path shaderDir = repoRoot / "shaders" / shaderId;
const std::filesystem::path runtimeBuildDir = repoRoot / "runtime" / "generated" / "render-cadence-compositor";
ShaderPackageRegistry registry(0);
ShaderPackage shaderPackage;
std::string error;
if (!registry.ParseManifest(shaderDir / "shader.json", shaderPackage, error))
{
build.succeeded = false;
build.message = error.empty() ? "Shader manifest parse failed." : error;
return build;
}
const RenderCadenceCompositor::ShaderSupportResult support =
RenderCadenceCompositor::CheckStatelessSinglePassShaderSupport(shaderPackage);
if (!support.supported)
{
build.succeeded = false;
build.message = support.reason;
return build;
}
ShaderCompiler compiler(
repoRoot,
runtimeBuildDir / (shaderId + ".wrapper.slang"),
runtimeBuildDir / (shaderId + ".generated.glsl"),
runtimeBuildDir / (shaderId + ".patched.glsl"),
0);
const auto start = std::chrono::steady_clock::now();
for (const ShaderPassDefinition& pass : shaderPackage.passes)
{
std::string fragmentShaderSource;
if (!compiler.BuildPassFragmentShaderSource(shaderPackage, pass, fragmentShaderSource, error))
{
build.succeeded = false;
build.message = error.empty() ? "Slang compile failed." : error;
return build;
}
RuntimeShaderPassArtifact passArtifact;
passArtifact.passId = pass.id;
passArtifact.fragmentShaderSource = std::move(fragmentShaderSource);
passArtifact.inputNames = pass.inputNames;
passArtifact.outputName = pass.outputName;
build.artifact.passes.push_back(std::move(passArtifact));
}
const auto end = std::chrono::steady_clock::now();
const double milliseconds = std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(end - start).count();
build.succeeded = true;
build.artifact.shaderId = shaderPackage.id;
build.artifact.displayName = shaderPackage.displayName;
build.artifact.parameterDefinitions = shaderPackage.parameters;
if (!build.artifact.passes.empty())
build.artifact.fragmentShaderSource = build.artifact.passes.front().fragmentShaderSource;
build.artifact.message = shaderPackage.displayName + " Slang compile completed in " + std::to_string(milliseconds) + " ms.";
build.message = build.artifact.message;
return build;
}
catch (const std::exception& exception)
{
build.succeeded = false;
build.message = exception.what();
return build;
}
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include "RuntimeShaderArtifact.h"
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
struct RuntimeSlangShaderBuild
{
bool available = false;
bool succeeded = false;
RuntimeShaderArtifact artifact;
std::string message;
};
class RuntimeSlangShaderCompiler
{
public:
RuntimeSlangShaderCompiler() = default;
RuntimeSlangShaderCompiler(const RuntimeSlangShaderCompiler&) = delete;
RuntimeSlangShaderCompiler& operator=(const RuntimeSlangShaderCompiler&) = delete;
~RuntimeSlangShaderCompiler();
void StartHappyAccidentBuild();
void StartShaderBuild(const std::string& shaderId);
void Stop();
bool TryConsume(RuntimeSlangShaderBuild& build);
bool Running() const { return mRunning.load(std::memory_order_acquire); }
private:
RuntimeSlangShaderBuild BuildShader(const std::string& shaderId) const;
std::thread mThread;
std::atomic<bool> mRunning{ false };
std::mutex mMutex;
RuntimeSlangShaderBuild mReadyBuild;
};

View File

@@ -0,0 +1,112 @@
#include "SupportedShaderCatalog.h"
#include "ShaderPackageRegistry.h"
#include <map>
#include <utility>
namespace RenderCadenceCompositor
{
ShaderSupportResult CheckStatelessSinglePassShaderSupport(const ShaderPackage& shaderPackage)
{
if (shaderPackage.passes.empty())
return { false, "Shader package has no render passes." };
if (shaderPackage.temporal.enabled)
return { false, "RenderCadenceCompositor currently supports only stateless shaders; temporal history is not enabled in this app." };
if (shaderPackage.feedback.enabled)
return { false, "RenderCadenceCompositor currently supports only stateless shaders; feedback storage is not enabled in this app." };
if (!shaderPackage.textureAssets.empty())
return { false, "RenderCadenceCompositor does not load shader texture assets yet; texture-backed shaders need a CPU-prepared asset handoff first." };
if (!shaderPackage.fontAssets.empty())
return { false, "RenderCadenceCompositor does not load shader font assets yet; text shaders need a CPU-prepared asset handoff first." };
for (const ShaderParameterDefinition& parameter : shaderPackage.parameters)
{
if (parameter.type == ShaderParameterType::Text)
return { false, "RenderCadenceCompositor currently skips text parameters because they require per-shader text texture storage." };
}
bool writesLayerOutput = false;
for (const ShaderPassDefinition& pass : shaderPackage.passes)
{
if (pass.sourcePath.empty())
{
return { false, "Shader pass '" + pass.id + "' has no source." };
}
if (pass.outputName == "layerOutput")
writesLayerOutput = true;
for (const std::string& inputName : pass.inputNames)
{
if (inputName == "videoInput" || inputName == "layerInput")
continue;
bool matchesNamedOutput = false;
for (const ShaderPassDefinition& outputPass : shaderPackage.passes)
{
if (outputPass.outputName == inputName)
{
matchesNamedOutput = true;
break;
}
}
if (!matchesNamedOutput)
return { false, "Shader pass '" + pass.id + "' references unknown input '" + inputName + "'." };
}
}
if (!writesLayerOutput)
return { false, "Shader package must write a pass output named 'layerOutput'." };
return { true, std::string() };
}
bool SupportedShaderCatalog::Load(const std::filesystem::path& shaderRoot, unsigned maxTemporalHistoryFrames, std::string& error)
{
mShaders.clear();
mPackagesById.clear();
if (shaderRoot.empty())
{
error = "Shader library path is empty.";
return false;
}
ShaderPackageRegistry registry(maxTemporalHistoryFrames);
std::map<std::string, ShaderPackage> packagesById;
std::vector<std::string> packageOrder;
std::vector<ShaderPackageStatus> packageStatuses;
if (!registry.Scan(shaderRoot, packagesById, packageOrder, packageStatuses, error))
return false;
for (const std::string& packageId : packageOrder)
{
const auto packageIt = packagesById.find(packageId);
if (packageIt == packagesById.end())
continue;
const ShaderPackage& shaderPackage = packageIt->second;
const ShaderSupportResult support = CheckStatelessSinglePassShaderSupport(shaderPackage);
if (!support.supported)
continue;
SupportedShaderSummary summary;
summary.id = shaderPackage.id;
summary.name = shaderPackage.displayName.empty() ? shaderPackage.id : shaderPackage.displayName;
summary.description = shaderPackage.description;
summary.category = shaderPackage.category;
mShaders.push_back(std::move(summary));
mPackagesById[shaderPackage.id] = shaderPackage;
}
error.clear();
return true;
}
const ShaderPackage* SupportedShaderCatalog::FindPackage(const std::string& shaderId) const
{
const auto packageIt = mPackagesById.find(shaderId);
return packageIt == mPackagesById.end() ? nullptr : &packageIt->second;
}
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include "ShaderTypes.h"
#include <filesystem>
#include <map>
#include <string>
#include <vector>
namespace RenderCadenceCompositor
{
struct SupportedShaderSummary
{
std::string id;
std::string name;
std::string description;
std::string category;
};
struct ShaderSupportResult
{
bool supported = false;
std::string reason;
};
ShaderSupportResult CheckStatelessSinglePassShaderSupport(const ShaderPackage& shaderPackage);
class SupportedShaderCatalog
{
public:
bool Load(const std::filesystem::path& shaderRoot, unsigned maxTemporalHistoryFrames, std::string& error);
const std::vector<SupportedShaderSummary>& Shaders() const { return mShaders; }
const ShaderPackage* FindPackage(const std::string& shaderId) const;
private:
std::vector<SupportedShaderSummary> mShaders;
std::map<std::string, ShaderPackage> mPackagesById;
};
}

View File

@@ -0,0 +1,183 @@
#pragma once
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <string>
namespace RenderCadenceCompositor
{
struct CadenceTelemetrySnapshot
{
double sampleSeconds = 0.0;
double renderFps = 0.0;
double scheduleFps = 0.0;
std::size_t freeFrames = 0;
std::size_t completedFrames = 0;
std::size_t scheduledFrames = 0;
uint64_t renderedTotal = 0;
uint64_t scheduledTotal = 0;
uint64_t completedPollMisses = 0;
uint64_t scheduleFailures = 0;
uint64_t completedDrops = 0;
uint64_t acquireMisses = 0;
uint64_t completions = 0;
uint64_t displayedLate = 0;
uint64_t dropped = 0;
uint64_t clockOverruns = 0;
uint64_t clockSkippedFrames = 0;
uint64_t shaderBuildsCommitted = 0;
uint64_t shaderBuildFailures = 0;
double renderFrameMilliseconds = 0.0;
double renderFrameBudgetUsedPercent = 0.0;
double renderFrameMaxMilliseconds = 0.0;
double readbackQueueMilliseconds = 0.0;
double completedReadbackCopyMilliseconds = 0.0;
uint64_t inputFramesReceived = 0;
uint64_t inputFramesDropped = 0;
uint64_t inputConsumeMisses = 0;
uint64_t inputUploadMisses = 0;
std::size_t inputReadyFrames = 0;
std::size_t inputReadingFrames = 0;
double inputLatestAgeMilliseconds = 0.0;
double inputUploadMilliseconds = 0.0;
bool inputFormatSupported = true;
bool inputSignalPresent = false;
double inputCaptureFps = 0.0;
double inputConvertMilliseconds = 0.0;
double inputSubmitMilliseconds = 0.0;
uint64_t inputNoSignalFrames = 0;
uint64_t inputUnsupportedFrames = 0;
uint64_t inputSubmitMisses = 0;
std::string inputCaptureFormat = "none";
bool deckLinkBufferedAvailable = false;
uint64_t deckLinkBuffered = 0;
double deckLinkScheduleCallMilliseconds = 0.0;
bool deckLinkScheduleLeadAvailable = false;
int64_t deckLinkPlaybackStreamTime = 0;
uint64_t deckLinkPlaybackFrameIndex = 0;
uint64_t deckLinkNextScheduleFrameIndex = 0;
int64_t deckLinkScheduleLeadFrames = 0;
uint64_t deckLinkScheduleRealignments = 0;
};
class CadenceTelemetry
{
public:
template <typename SystemFrameExchange, typename Output, typename OutputThread>
CadenceTelemetrySnapshot Sample(
const SystemFrameExchange& exchange,
const Output& output,
const OutputThread& outputThread)
{
const auto now = Clock::now();
const double seconds = mHasLastSample
? std::chrono::duration_cast<std::chrono::duration<double>>(now - mLastSampleTime).count()
: 0.0;
const auto exchangeMetrics = exchange.Metrics();
const auto outputMetrics = output.Metrics();
const auto threadMetrics = outputThread.Metrics();
CadenceTelemetrySnapshot snapshot;
snapshot.sampleSeconds = seconds;
snapshot.renderedTotal = exchangeMetrics.completedFrames;
snapshot.scheduledTotal = exchangeMetrics.scheduledFrames;
snapshot.freeFrames = exchangeMetrics.freeCount;
snapshot.completedFrames = exchangeMetrics.completedCount;
snapshot.scheduledFrames = exchangeMetrics.scheduledCount;
snapshot.completedPollMisses = threadMetrics.completedPollMisses;
snapshot.scheduleFailures = outputMetrics.scheduleFailures > threadMetrics.scheduleFailures
? outputMetrics.scheduleFailures
: threadMetrics.scheduleFailures;
snapshot.completedDrops = exchangeMetrics.completedDrops;
snapshot.acquireMisses = exchangeMetrics.acquireMisses;
snapshot.completions = outputMetrics.completions;
snapshot.displayedLate = outputMetrics.displayedLate;
snapshot.dropped = outputMetrics.dropped;
snapshot.deckLinkBufferedAvailable = outputMetrics.actualBufferedFramesAvailable;
snapshot.deckLinkBuffered = outputMetrics.actualBufferedFrames;
snapshot.deckLinkScheduleCallMilliseconds = outputMetrics.scheduleCallMilliseconds;
snapshot.deckLinkScheduleLeadAvailable = outputMetrics.scheduleLeadAvailable;
snapshot.deckLinkPlaybackStreamTime = outputMetrics.playbackStreamTime;
snapshot.deckLinkPlaybackFrameIndex = outputMetrics.playbackFrameIndex;
snapshot.deckLinkNextScheduleFrameIndex = outputMetrics.nextScheduleFrameIndex;
snapshot.deckLinkScheduleLeadFrames = outputMetrics.scheduleLeadFrames;
snapshot.deckLinkScheduleRealignments = outputMetrics.scheduleRealignmentCount;
if (mHasLastSample && seconds > 0.0)
{
snapshot.renderFps = static_cast<double>(snapshot.renderedTotal - mLastRenderedFrames) / seconds;
snapshot.scheduleFps = static_cast<double>(snapshot.scheduledTotal - mLastScheduledFrames) / seconds;
}
mLastSampleTime = now;
mLastRenderedFrames = snapshot.renderedTotal;
mLastScheduledFrames = snapshot.scheduledTotal;
mHasLastSample = true;
return snapshot;
}
template <typename SystemFrameExchange, typename Output, typename OutputThread, typename RenderThread>
CadenceTelemetrySnapshot Sample(
const SystemFrameExchange& exchange,
const Output& output,
const OutputThread& outputThread,
const RenderThread& renderThread)
{
CadenceTelemetrySnapshot snapshot = Sample(exchange, output, outputThread);
const auto renderMetrics = renderThread.GetMetrics();
snapshot.clockOverruns = renderMetrics.clockOverruns;
snapshot.clockSkippedFrames = renderMetrics.skippedFrames;
snapshot.shaderBuildsCommitted = renderMetrics.shaderBuildsCommitted;
snapshot.shaderBuildFailures = renderMetrics.shaderBuildFailures;
snapshot.renderFrameMilliseconds = renderMetrics.renderFrameMilliseconds;
snapshot.renderFrameBudgetUsedPercent = renderMetrics.renderFrameBudgetUsedPercent;
snapshot.renderFrameMaxMilliseconds = renderMetrics.renderFrameMaxMilliseconds;
snapshot.readbackQueueMilliseconds = renderMetrics.readbackQueueMilliseconds;
snapshot.completedReadbackCopyMilliseconds = renderMetrics.completedReadbackCopyMilliseconds;
snapshot.inputFramesReceived = renderMetrics.inputFramesReceived;
snapshot.inputFramesDropped = renderMetrics.inputFramesDropped;
snapshot.inputConsumeMisses = renderMetrics.inputConsumeMisses;
snapshot.inputUploadMisses = renderMetrics.inputUploadMisses;
snapshot.inputReadyFrames = renderMetrics.inputReadyFrames;
snapshot.inputReadingFrames = renderMetrics.inputReadingFrames;
snapshot.inputLatestAgeMilliseconds = renderMetrics.inputLatestAgeMilliseconds;
snapshot.inputUploadMilliseconds = renderMetrics.inputUploadMilliseconds;
snapshot.inputFormatSupported = renderMetrics.inputFormatSupported;
snapshot.inputSignalPresent = renderMetrics.inputSignalPresent;
return snapshot;
}
template <typename SystemFrameExchange, typename Output, typename OutputThread, typename RenderThread, typename InputEdge>
CadenceTelemetrySnapshot Sample(
const SystemFrameExchange& exchange,
const Output& output,
const OutputThread& outputThread,
const RenderThread& renderThread,
const InputEdge& inputEdge)
{
CadenceTelemetrySnapshot snapshot = Sample(exchange, output, outputThread, renderThread);
const auto inputMetrics = inputEdge.Metrics();
snapshot.inputConvertMilliseconds = inputMetrics.convertMilliseconds;
snapshot.inputSubmitMilliseconds = inputMetrics.submitMilliseconds;
snapshot.inputNoSignalFrames = inputMetrics.noInputSourceFrames;
snapshot.inputUnsupportedFrames = inputMetrics.unsupportedFrames;
snapshot.inputSubmitMisses = inputMetrics.submitMisses;
snapshot.inputCaptureFormat = inputMetrics.captureFormat ? inputMetrics.captureFormat : "none";
if (snapshot.sampleSeconds > 0.0)
snapshot.inputCaptureFps = static_cast<double>(inputMetrics.capturedFrames - mLastInputCapturedFrames) / snapshot.sampleSeconds;
mLastInputCapturedFrames = inputMetrics.capturedFrames;
return snapshot;
}
private:
using Clock = std::chrono::steady_clock;
Clock::time_point mLastSampleTime = Clock::now();
uint64_t mLastRenderedFrames = 0;
uint64_t mLastScheduledFrames = 0;
uint64_t mLastInputCapturedFrames = 0;
bool mHasLastSample = false;
};
}

View File

@@ -0,0 +1,83 @@
#pragma once
#include "CadenceTelemetry.h"
#include "../json/JsonWriter.h"
#include <cstdint>
#include <string>
namespace RenderCadenceCompositor
{
inline void WriteCadenceTelemetryJson(JsonWriter& writer, const CadenceTelemetrySnapshot& snapshot)
{
writer.BeginObject();
writer.KeyDouble("sampleSeconds", snapshot.sampleSeconds);
writer.KeyDouble("renderFps", snapshot.renderFps);
writer.KeyDouble("scheduleFps", snapshot.scheduleFps);
writer.KeyUInt("free", static_cast<uint64_t>(snapshot.freeFrames));
writer.KeyUInt("completed", static_cast<uint64_t>(snapshot.completedFrames));
writer.KeyUInt("scheduled", static_cast<uint64_t>(snapshot.scheduledFrames));
writer.KeyUInt("renderedTotal", snapshot.renderedTotal);
writer.KeyUInt("scheduledTotal", snapshot.scheduledTotal);
writer.KeyUInt("completedPollMisses", snapshot.completedPollMisses);
writer.KeyUInt("scheduleFailures", snapshot.scheduleFailures);
writer.KeyUInt("completedDrops", snapshot.completedDrops);
writer.KeyUInt("acquireMisses", snapshot.acquireMisses);
writer.KeyUInt("completions", snapshot.completions);
writer.KeyUInt("late", snapshot.displayedLate);
writer.KeyUInt("dropped", snapshot.dropped);
writer.KeyUInt("clockOverruns", snapshot.clockOverruns);
writer.KeyUInt("clockSkippedFrames", snapshot.clockSkippedFrames);
writer.KeyUInt("clockOveruns", snapshot.clockOverruns);
writer.KeyUInt("clockSkipped", snapshot.clockSkippedFrames);
writer.KeyUInt("shaderCommitted", snapshot.shaderBuildsCommitted);
writer.KeyUInt("shaderFailures", snapshot.shaderBuildFailures);
writer.KeyDouble("renderFrameMs", snapshot.renderFrameMilliseconds);
writer.KeyDouble("renderFrameBudgetUsedPercent", snapshot.renderFrameBudgetUsedPercent);
writer.KeyDouble("renderFrameMaxMs", snapshot.renderFrameMaxMilliseconds);
writer.KeyDouble("readbackQueueMs", snapshot.readbackQueueMilliseconds);
writer.KeyDouble("completedReadbackCopyMs", snapshot.completedReadbackCopyMilliseconds);
writer.KeyUInt("inputFramesReceived", snapshot.inputFramesReceived);
writer.KeyUInt("inputFramesDropped", snapshot.inputFramesDropped);
writer.KeyUInt("inputConsumeMisses", snapshot.inputConsumeMisses);
writer.KeyUInt("inputUploadMisses", snapshot.inputUploadMisses);
writer.KeyUInt("inputReadyFrames", static_cast<uint64_t>(snapshot.inputReadyFrames));
writer.KeyUInt("inputReadingFrames", static_cast<uint64_t>(snapshot.inputReadingFrames));
writer.KeyDouble("inputLatestAgeMs", snapshot.inputLatestAgeMilliseconds);
writer.KeyDouble("inputUploadMs", snapshot.inputUploadMilliseconds);
writer.KeyBool("inputFormatSupported", snapshot.inputFormatSupported);
writer.KeyBool("inputSignalPresent", snapshot.inputSignalPresent);
writer.KeyDouble("inputCaptureFps", snapshot.inputCaptureFps);
writer.KeyDouble("inputConvertMs", snapshot.inputConvertMilliseconds);
writer.KeyDouble("inputSubmitMs", snapshot.inputSubmitMilliseconds);
writer.KeyUInt("inputNoSignalFrames", snapshot.inputNoSignalFrames);
writer.KeyUInt("inputUnsupportedFrames", snapshot.inputUnsupportedFrames);
writer.KeyUInt("inputSubmitMisses", snapshot.inputSubmitMisses);
writer.KeyString("inputCaptureFormat", snapshot.inputCaptureFormat);
writer.KeyBool("deckLinkBufferedAvailable", snapshot.deckLinkBufferedAvailable);
writer.Key("deckLinkBuffered");
if (snapshot.deckLinkBufferedAvailable)
writer.UInt(snapshot.deckLinkBuffered);
else
writer.Null();
writer.KeyDouble("scheduleCallMs", snapshot.deckLinkScheduleCallMilliseconds);
writer.KeyBool("deckLinkScheduleLeadAvailable", snapshot.deckLinkScheduleLeadAvailable);
writer.Key("deckLinkScheduleLeadFrames");
if (snapshot.deckLinkScheduleLeadAvailable)
writer.Int(snapshot.deckLinkScheduleLeadFrames);
else
writer.Null();
writer.KeyUInt("deckLinkPlaybackFrameIndex", snapshot.deckLinkPlaybackFrameIndex);
writer.KeyUInt("deckLinkNextScheduleFrameIndex", snapshot.deckLinkNextScheduleFrameIndex);
writer.KeyInt("deckLinkPlaybackStreamTime", snapshot.deckLinkPlaybackStreamTime);
writer.KeyUInt("deckLinkScheduleRealignments", snapshot.deckLinkScheduleRealignments);
writer.EndObject();
}
inline std::string CadenceTelemetryToJson(const CadenceTelemetrySnapshot& snapshot)
{
JsonWriter writer;
WriteCadenceTelemetryJson(writer, snapshot);
return writer.StringValue();
}
}

View File

@@ -0,0 +1,122 @@
#pragma once
#include "CadenceTelemetry.h"
#include "../logging/Logger.h"
#include <atomic>
#include <chrono>
#include <sstream>
#include <thread>
namespace RenderCadenceCompositor
{
struct TelemetryHealthMonitorConfig
{
std::chrono::milliseconds interval = std::chrono::seconds(1);
std::size_t scheduledStarvationThreshold = 0;
};
class TelemetryHealthMonitor
{
public:
explicit TelemetryHealthMonitor(TelemetryHealthMonitorConfig config = TelemetryHealthMonitorConfig()) :
mConfig(config)
{
}
TelemetryHealthMonitor(const TelemetryHealthMonitor&) = delete;
TelemetryHealthMonitor& operator=(const TelemetryHealthMonitor&) = delete;
~TelemetryHealthMonitor()
{
Stop();
}
template <typename SystemFrameExchange, typename Output, typename OutputThread, typename RenderThread>
void Start(const SystemFrameExchange& exchange, const Output& output, const OutputThread& outputThread, const RenderThread& renderThread)
{
if (mRunning)
return;
mStopping = false;
mThread = std::thread([this, &exchange, &output, &outputThread, &renderThread]() {
CadenceTelemetry telemetry;
CadenceTelemetrySnapshot previous;
bool hasPrevious = false;
while (!mStopping)
{
std::this_thread::sleep_for(mConfig.interval);
const CadenceTelemetrySnapshot snapshot = telemetry.Sample(exchange, output, outputThread, renderThread);
ReportHealth(snapshot, hasPrevious ? &previous : nullptr);
previous = snapshot;
hasPrevious = true;
}
});
mRunning = true;
}
void Stop()
{
mStopping = true;
if (mThread.joinable())
mThread.join();
mRunning = false;
}
private:
void ReportHealth(const CadenceTelemetrySnapshot& snapshot, const CadenceTelemetrySnapshot* previous) const
{
if (!previous)
return;
const uint64_t lateDelta = snapshot.displayedLate - previous->displayedLate;
const uint64_t droppedDelta = snapshot.dropped - previous->dropped;
const uint64_t scheduleFailureDelta = snapshot.scheduleFailures - previous->scheduleFailures;
if (droppedDelta > 0 || lateDelta > 0)
{
std::ostringstream message;
message << "DeckLink reported frame timing issue: lateDelta=" << lateDelta
<< " droppedDelta=" << droppedDelta
<< " totalLate=" << snapshot.displayedLate
<< " totalDropped=" << snapshot.dropped
<< " scheduleLead=";
if (snapshot.deckLinkScheduleLeadAvailable)
message << snapshot.deckLinkScheduleLeadFrames;
else
message << "n/a";
message << " realignments=" << snapshot.deckLinkScheduleRealignments;
LogWarning("telemetry", message.str());
}
if (scheduleFailureDelta > 0)
{
std::ostringstream message;
message << "DeckLink schedule failures increased: delta=" << scheduleFailureDelta
<< " total=" << snapshot.scheduleFailures;
LogWarning("telemetry", message.str());
}
const bool appScheduledStarved = snapshot.scheduledFrames <= mConfig.scheduledStarvationThreshold
&& snapshot.scheduledTotal > 0;
const bool deckLinkStarved = snapshot.deckLinkBufferedAvailable && snapshot.deckLinkBuffered == 0;
if (appScheduledStarved || deckLinkStarved)
{
std::ostringstream message;
message << "Output buffer starvation detected: scheduled=" << snapshot.scheduledFrames
<< " decklinkBuffered=";
if (snapshot.deckLinkBufferedAvailable)
message << snapshot.deckLinkBuffered;
else
message << "n/a";
message << " renderFps=" << snapshot.renderFps
<< " scheduleFps=" << snapshot.scheduleFps;
LogError("telemetry", message.str());
}
}
TelemetryHealthMonitorConfig mConfig;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mRunning{ false };
};
}

View File

@@ -0,0 +1,367 @@
#include "DeckLinkInput.h"
#include "DeckLinkVideoIOFormat.h"
#include "../logging/Logger.h"
#include <chrono>
#include <new>
namespace RenderCadenceCompositor
{
namespace
{
bool FindInputDisplayMode(IDeckLinkInput* input, BMDDisplayMode targetMode, IDeckLinkDisplayMode** foundMode)
{
if (input == nullptr || foundMode == nullptr)
return false;
*foundMode = nullptr;
CComPtr<IDeckLinkDisplayModeIterator> iterator;
if (input->GetDisplayModeIterator(&iterator) != S_OK)
return false;
return FindDeckLinkDisplayMode(iterator, targetMode, foundMode);
}
}
DeckLinkInputCallback::DeckLinkInputCallback(DeckLinkInput& owner) :
mOwner(owner)
{
}
HRESULT STDMETHODCALLTYPE DeckLinkInputCallback::QueryInterface(REFIID iid, LPVOID* ppv)
{
if (ppv == nullptr)
return E_POINTER;
if (iid == IID_IUnknown || iid == IID_IDeckLinkInputCallback)
{
*ppv = static_cast<IDeckLinkInputCallback*>(this);
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE DeckLinkInputCallback::AddRef()
{
return ++mRefCount;
}
ULONG STDMETHODCALLTYPE DeckLinkInputCallback::Release()
{
const ULONG refCount = --mRefCount;
if (refCount == 0)
delete this;
return refCount;
}
HRESULT STDMETHODCALLTYPE DeckLinkInputCallback::VideoInputFrameArrived(IDeckLinkVideoInputFrame* videoFrame, IDeckLinkAudioInputPacket*)
{
if (videoFrame != nullptr)
mOwner.HandleFrameArrived(videoFrame);
return S_OK;
}
HRESULT STDMETHODCALLTYPE DeckLinkInputCallback::VideoInputFormatChanged(BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags)
{
mOwner.HandleFormatChanged();
return S_OK;
}
DeckLinkInput::DeckLinkInput(InputFrameMailbox& mailbox) :
mMailbox(mailbox)
{
}
DeckLinkInput::~DeckLinkInput()
{
ReleaseResources();
}
bool DeckLinkInput::Initialize(const DeckLinkInputConfig& config, std::string& error)
{
ReleaseResources();
mConfig = config;
Log("decklink-input", "Initializing DeckLink input for " + config.videoFormat.displayName + ".");
if (!DiscoverInput(config, error))
return false;
if (mInput->EnableVideoInput(config.videoFormat.displayMode, mCapturePixelFormat, bmdVideoInputFlagDefault) != S_OK)
{
error = "DeckLink input setup failed while enabling " +
std::string(mCapturePixelFormat == bmdFormat8BitBGRA ? "BGRA8" : "UYVY8") +
" input for " + config.videoFormat.displayName + ".";
ReleaseResources();
return false;
}
Log(
"decklink-input",
std::string("DeckLink input enabled in ") + (mCapturePixelFormat == bmdFormat8BitBGRA ? "BGRA8" : "UYVY8 raw capture") + " mode.");
mCallback.Attach(new (std::nothrow) DeckLinkInputCallback(*this));
if (mCallback == nullptr)
{
error = "DeckLink input setup failed while creating the capture callback.";
ReleaseResources();
return false;
}
if (mInput->SetCallback(mCallback) != S_OK)
{
error = "DeckLink input setup failed while installing the capture callback.";
ReleaseResources();
return false;
}
Log("decklink-input", "DeckLink input callback installed.");
return true;
}
bool DeckLinkInput::Start(std::string& error)
{
if (mInput == nullptr)
{
error = "DeckLink input has not been initialized.";
return false;
}
if (mRunning.load(std::memory_order_acquire))
return true;
if (mInput->StartStreams() != S_OK)
{
error = "DeckLink input stream failed to start.";
return false;
}
mRunning.store(true, std::memory_order_release);
Log("decklink-input", "DeckLink input stream started.");
return true;
}
void DeckLinkInput::Stop()
{
if (mInput != nullptr && mRunning.exchange(false, std::memory_order_acq_rel))
{
mInput->StopStreams();
Log("decklink-input", "DeckLink input stream stopped.");
}
}
void DeckLinkInput::ReleaseResources()
{
Stop();
if (mInput != nullptr)
{
mInput->SetCallback(nullptr);
mInput->DisableVideoInput();
}
mCallback.Release();
mInput.Release();
}
DeckLinkInputMetrics DeckLinkInput::Metrics() const
{
DeckLinkInputMetrics metrics;
metrics.capturedFrames = mCapturedFrames.load(std::memory_order_relaxed);
metrics.noInputSourceFrames = mNoInputSourceFrames.load(std::memory_order_relaxed);
metrics.unsupportedFrames = mUnsupportedFrames.load(std::memory_order_relaxed);
metrics.submitMisses = mSubmitMisses.load(std::memory_order_relaxed);
metrics.convertMilliseconds = mConvertMilliseconds.load(std::memory_order_relaxed);
metrics.submitMilliseconds = mSubmitMilliseconds.load(std::memory_order_relaxed);
metrics.captureFormat = CaptureFormatName();
return metrics;
}
VideoIOPixelFormat DeckLinkInput::CapturePixelFormat() const
{
return mCapturePixelFormat == bmdFormat8BitYUV ? VideoIOPixelFormat::Uyvy8 : VideoIOPixelFormat::Bgra8;
}
void DeckLinkInput::HandleFrameArrived(IDeckLinkVideoInputFrame* inputFrame)
{
if (inputFrame == nullptr)
return;
if ((inputFrame->GetFlags() & bmdFrameHasNoInputSource) == bmdFrameHasNoInputSource)
{
mNoInputSourceFrames.fetch_add(1, std::memory_order_relaxed);
bool expected = false;
if (mLoggedNoInputSource.compare_exchange_strong(expected, true, std::memory_order_relaxed))
TryLog(LogLevel::Warning, "decklink-input", "DeckLink input callback reports no input source.");
return;
}
if (inputFrame->GetWidth() != static_cast<long>(mMailbox.Config().width) ||
inputFrame->GetHeight() != static_cast<long>(mMailbox.Config().height))
{
mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed);
bool expected = false;
if (mLoggedUnsupportedFrame.compare_exchange_strong(expected, true, std::memory_order_relaxed))
TryLog(LogLevel::Warning, "decklink-input", "DeckLink input frame dimensions do not match the configured mailbox.");
return;
}
CComPtr<IDeckLinkVideoBuffer> inputFrameBuffer;
if (inputFrame->QueryInterface(IID_IDeckLinkVideoBuffer, reinterpret_cast<void**>(&inputFrameBuffer)) != S_OK)
{
mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed);
bool expected = false;
if (mLoggedUnsupportedFrame.compare_exchange_strong(expected, true, std::memory_order_relaxed))
TryLog(LogLevel::Warning, "decklink-input", "DeckLink input frame does not expose IDeckLinkVideoBuffer.");
return;
}
if (inputFrameBuffer->StartAccess(bmdBufferAccessRead) != S_OK)
{
mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed);
bool expected = false;
if (mLoggedUnsupportedFrame.compare_exchange_strong(expected, true, std::memory_order_relaxed))
TryLog(LogLevel::Warning, "decklink-input", "DeckLink input frame buffer could not be opened for read access.");
return;
}
void* bytes = nullptr;
inputFrameBuffer->GetBytes(&bytes);
bool submitted = false;
if (mCapturePixelFormat == bmdFormat8BitBGRA)
submitted = SubmitBgra8Frame(inputFrame, bytes);
else if (mCapturePixelFormat == bmdFormat8BitYUV)
submitted = SubmitUyvy8Frame(inputFrame, bytes);
else
mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed);
if (!submitted)
{
mSubmitMisses.fetch_add(1, std::memory_order_relaxed);
bool expected = false;
if (mLoggedSubmitMiss.compare_exchange_strong(expected, true, std::memory_order_relaxed))
TryLog(LogLevel::Warning, "decklink-input", "DeckLink input frame could not be submitted to InputFrameMailbox.");
}
mCapturedFrames.fetch_add(1, std::memory_order_relaxed);
bool expectedFirstFrame = false;
if (mLoggedFirstFrame.compare_exchange_strong(expectedFirstFrame, true, std::memory_order_relaxed))
{
TryLog(
LogLevel::Log,
"decklink-input",
std::string("First DeckLink ") + (mCapturePixelFormat == bmdFormat8BitBGRA ? "BGRA8" : "UYVY8 raw") + " input frame submitted to InputFrameMailbox.");
}
inputFrameBuffer->EndAccess(bmdBufferAccessRead);
}
void DeckLinkInput::HandleFormatChanged()
{
mUnsupportedFrames.fetch_add(1, std::memory_order_relaxed);
TryLog(LogLevel::Warning, "decklink-input", "DeckLink input format changed; input edge does not auto-switch formats yet.");
}
bool DeckLinkInput::DiscoverInput(const DeckLinkInputConfig& config, std::string& error)
{
CComPtr<IDeckLinkIterator> iterator;
HRESULT result = CoCreateInstance(CLSID_CDeckLinkIterator, nullptr, CLSCTX_ALL, IID_IDeckLinkIterator, reinterpret_cast<void**>(&iterator));
if (FAILED(result))
{
error = "DeckLink input discovery failed. Blackmagic DeckLink drivers may not be installed.";
return false;
}
CComPtr<IDeckLink> deckLink;
while (iterator->Next(&deckLink) == S_OK)
{
CComPtr<IDeckLinkInput> candidateInput;
if (deckLink->QueryInterface(IID_IDeckLinkInput, reinterpret_cast<void**>(&candidateInput)) == S_OK && candidateInput != nullptr)
{
CComPtr<IDeckLinkDisplayMode> displayMode;
if (FindInputDisplayMode(candidateInput, config.videoFormat.displayMode, &displayMode) &&
SupportsInputFormat(candidateInput, config.videoFormat.displayMode, bmdFormat8BitBGRA))
{
mInput = candidateInput;
mCapturePixelFormat = bmdFormat8BitBGRA;
Log("decklink-input", "DeckLink input device selected for BGRA8 capture.");
return true;
}
if (displayMode != nullptr &&
SupportsInputFormat(candidateInput, config.videoFormat.displayMode, bmdFormat8BitYUV))
{
mInput = candidateInput;
mCapturePixelFormat = bmdFormat8BitYUV;
Log("decklink-input", "DeckLink input device selected for UYVY8 raw capture with render-thread GPU decode.");
return true;
}
}
deckLink.Release();
}
error = "No DeckLink input device supports BGRA8 or UYVY8 capture for " + config.videoFormat.displayName + ".";
return false;
}
bool DeckLinkInput::SupportsInputFormat(IDeckLinkInput* input, BMDDisplayMode displayMode, BMDPixelFormat pixelFormat) const
{
if (input == nullptr)
return false;
BOOL supported = FALSE;
BMDDisplayMode actualMode = bmdModeUnknown;
const HRESULT result = input->DoesSupportVideoMode(
bmdVideoConnectionUnspecified,
displayMode,
pixelFormat,
bmdNoVideoInputConversion,
bmdSupportedVideoModeDefault,
&actualMode,
&supported);
return result == S_OK && supported != FALSE;
}
bool DeckLinkInput::SubmitBgra8Frame(IDeckLinkVideoInputFrame* inputFrame, const void* bytes)
{
if (inputFrame == nullptr || bytes == nullptr)
return false;
const uint64_t frameIndex = mCapturedFrames.load(std::memory_order_relaxed);
mConvertMilliseconds.store(0.0, std::memory_order_relaxed);
const auto submitStart = std::chrono::steady_clock::now();
const bool submitted = mMailbox.SubmitFrame(bytes, static_cast<unsigned>(inputFrame->GetRowBytes()), frameIndex);
const auto submitEnd = std::chrono::steady_clock::now();
mSubmitMilliseconds.store(
std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(submitEnd - submitStart).count(),
std::memory_order_relaxed);
return submitted;
}
bool DeckLinkInput::SubmitUyvy8Frame(IDeckLinkVideoInputFrame* inputFrame, const void* bytes)
{
if (inputFrame == nullptr || bytes == nullptr)
return false;
const unsigned width = static_cast<unsigned>(inputFrame->GetWidth());
const unsigned height = static_cast<unsigned>(inputFrame->GetHeight());
const long sourceRowBytes = inputFrame->GetRowBytes();
if (width == 0 || height == 0 || sourceRowBytes < static_cast<long>(width * 2u))
return false;
mConvertMilliseconds.store(0.0, std::memory_order_relaxed);
const uint64_t frameIndex = mCapturedFrames.load(std::memory_order_relaxed);
const auto submitStart = std::chrono::steady_clock::now();
const bool submitted = mMailbox.SubmitFrame(bytes, static_cast<unsigned>(sourceRowBytes), frameIndex);
const auto submitEnd = std::chrono::steady_clock::now();
mSubmitMilliseconds.store(
std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(submitEnd - submitStart).count(),
std::memory_order_relaxed);
return submitted;
}
const char* DeckLinkInput::CaptureFormatName() const
{
if (mInput == nullptr)
return "none";
if (mCapturePixelFormat == bmdFormat8BitBGRA)
return "BGRA8";
if (mCapturePixelFormat == bmdFormat8BitYUV)
return "UYVY8";
return "unsupported";
}
}

View File

@@ -0,0 +1,96 @@
#pragma once
#include "../frames/InputFrameMailbox.h"
#include "DeckLinkAPI_h.h"
#include "DeckLinkDisplayMode.h"
#include <atlbase.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
namespace RenderCadenceCompositor
{
struct DeckLinkInputConfig
{
VideoFormat videoFormat;
};
struct DeckLinkInputMetrics
{
uint64_t capturedFrames = 0;
uint64_t noInputSourceFrames = 0;
uint64_t unsupportedFrames = 0;
uint64_t submitMisses = 0;
double convertMilliseconds = 0.0;
double submitMilliseconds = 0.0;
const char* captureFormat = "none";
};
class DeckLinkInput;
class DeckLinkInputCallback final : public IDeckLinkInputCallback
{
public:
explicit DeckLinkInputCallback(DeckLinkInput& owner);
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID* ppv) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame* videoFrame, IDeckLinkAudioInputPacket* audioPacket) override;
HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(BMDVideoInputFormatChangedEvents notificationEvents, IDeckLinkDisplayMode* newDisplayMode, BMDDetectedVideoInputFormatFlags detectedSignalFlags) override;
private:
DeckLinkInput& mOwner;
std::atomic<ULONG> mRefCount{ 1 };
};
class DeckLinkInput
{
public:
DeckLinkInput(InputFrameMailbox& mailbox);
DeckLinkInput(const DeckLinkInput&) = delete;
DeckLinkInput& operator=(const DeckLinkInput&) = delete;
~DeckLinkInput();
bool Initialize(const DeckLinkInputConfig& config, std::string& error);
bool Start(std::string& error);
void Stop();
void ReleaseResources();
bool IsInitialized() const { return mInput != nullptr; }
bool IsRunning() const { return mRunning.load(std::memory_order_acquire); }
VideoIOPixelFormat CapturePixelFormat() const;
DeckLinkInputMetrics Metrics() const;
void HandleFrameArrived(IDeckLinkVideoInputFrame* inputFrame);
void HandleFormatChanged();
private:
bool DiscoverInput(const DeckLinkInputConfig& config, std::string& error);
bool SupportsInputFormat(IDeckLinkInput* input, BMDDisplayMode displayMode, BMDPixelFormat pixelFormat) const;
bool SubmitBgra8Frame(IDeckLinkVideoInputFrame* inputFrame, const void* bytes);
bool SubmitUyvy8Frame(IDeckLinkVideoInputFrame* inputFrame, const void* bytes);
const char* CaptureFormatName() const;
InputFrameMailbox& mMailbox;
DeckLinkInputConfig mConfig;
BMDPixelFormat mCapturePixelFormat = bmdFormat8BitBGRA;
CComPtr<IDeckLinkInput> mInput;
CComPtr<DeckLinkInputCallback> mCallback;
std::atomic<bool> mRunning{ false };
std::atomic<uint64_t> mCapturedFrames{ 0 };
std::atomic<uint64_t> mNoInputSourceFrames{ 0 };
std::atomic<uint64_t> mUnsupportedFrames{ 0 };
std::atomic<uint64_t> mSubmitMisses{ 0 };
std::atomic<double> mConvertMilliseconds{ 0.0 };
std::atomic<double> mSubmitMilliseconds{ 0.0 };
std::atomic<bool> mLoggedFirstFrame{ false };
std::atomic<bool> mLoggedNoInputSource{ false };
std::atomic<bool> mLoggedUnsupportedFrame{ false };
std::atomic<bool> mLoggedSubmitMiss{ false };
};
}

View File

@@ -0,0 +1,87 @@
#pragma once
#include "DeckLinkInput.h"
#include <atomic>
#include <chrono>
#include <string>
#include <thread>
namespace RenderCadenceCompositor
{
struct DeckLinkInputThreadConfig
{
std::chrono::milliseconds idleSleep = std::chrono::milliseconds(100);
};
class DeckLinkInputThread
{
public:
DeckLinkInputThread(DeckLinkInput& input, DeckLinkInputThreadConfig config = DeckLinkInputThreadConfig()) :
mInput(input),
mConfig(config)
{
}
DeckLinkInputThread(const DeckLinkInputThread&) = delete;
DeckLinkInputThread& operator=(const DeckLinkInputThread&) = delete;
~DeckLinkInputThread()
{
Stop();
}
bool Start(std::string& error)
{
if (mThread.joinable())
return true;
mStartSucceeded.store(false, std::memory_order_release);
mStartCompleted.store(false, std::memory_order_release);
mStopping.store(false, std::memory_order_release);
mThread = std::thread([this]() { ThreadMain(); });
while (!mStartCompleted.load(std::memory_order_acquire))
std::this_thread::sleep_for(std::chrono::milliseconds(1));
if (mStartSucceeded.load(std::memory_order_acquire))
return true;
error = mStartError;
Stop();
return false;
}
void Stop()
{
mStopping.store(true, std::memory_order_release);
if (mThread.joinable())
mThread.join();
}
private:
void ThreadMain()
{
std::string error;
if (!mInput.Start(error))
{
mStartError = error;
mStartCompleted.store(true, std::memory_order_release);
return;
}
mStartSucceeded.store(true, std::memory_order_release);
mStartCompleted.store(true, std::memory_order_release);
while (!mStopping.load(std::memory_order_acquire))
std::this_thread::sleep_for(mConfig.idleSleep);
mInput.Stop();
}
DeckLinkInput& mInput;
DeckLinkInputThreadConfig mConfig;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mStartCompleted{ false };
std::atomic<bool> mStartSucceeded{ false };
std::string mStartError;
};
}

View File

@@ -0,0 +1,112 @@
#include "DeckLinkOutput.h"
#include "VideoIOFormat.h"
namespace RenderCadenceCompositor
{
DeckLinkOutput::~DeckLinkOutput()
{
ReleaseResources();
}
bool DeckLinkOutput::Initialize(const DeckLinkOutputConfig& config, CompletionCallback completionCallback, std::string& error)
{
mConfig = config;
mCompletionCallback = completionCallback;
VideoFormatSelection formats;
formats.output = config.outputVideoMode;
if (!mSession.DiscoverDevicesAndModes(formats, error))
return false;
if (!mSession.SelectPreferredFormats(formats, config.outputAlphaRequired, error))
return false;
if (!mSession.ConfigureOutput(
[this](const VideoIOCompletion& completion) { HandleCompletion(completion); },
formats.output,
config.externalKeyingEnabled,
error))
{
return false;
}
if (!mSession.PrepareOutputSchedule())
{
error = "DeckLink output schedule preparation failed.";
return false;
}
return true;
}
bool DeckLinkOutput::StartScheduledPlayback(std::string& error)
{
if (mSession.StartScheduledPlayback())
return true;
error = "DeckLink scheduled playback failed to start.";
return false;
}
bool DeckLinkOutput::ScheduleFrame(const VideoIOOutputFrame& frame)
{
return mSession.ScheduleOutputFrame(frame);
}
void DeckLinkOutput::Stop()
{
mSession.Stop();
}
void DeckLinkOutput::ReleaseResources()
{
mSession.ReleaseResources();
}
const VideoIOState& DeckLinkOutput::State() const
{
return mSession.State();
}
DeckLinkOutputMetrics DeckLinkOutput::Metrics() const
{
DeckLinkOutputMetrics metrics;
metrics.completions = mCompletions.load();
metrics.displayedLate = mDisplayedLate.load();
metrics.dropped = mDropped.load();
metrics.flushed = mFlushed.load();
const VideoIOState& state = mSession.State();
metrics.scheduleFailures = state.deckLinkScheduleFailureCount;
metrics.actualBufferedFramesAvailable = state.actualDeckLinkBufferedFramesAvailable;
metrics.actualBufferedFrames = state.actualDeckLinkBufferedFrames;
metrics.scheduleCallMilliseconds = state.deckLinkScheduleCallMilliseconds;
metrics.scheduleLeadAvailable = state.deckLinkScheduleLeadAvailable;
metrics.playbackStreamTime = state.deckLinkPlaybackStreamTime;
metrics.playbackFrameIndex = state.deckLinkPlaybackFrameIndex;
metrics.nextScheduleFrameIndex = state.deckLinkNextScheduleFrameIndex;
metrics.scheduleLeadFrames = state.deckLinkScheduleLeadFrames;
metrics.scheduleRealignmentCount = state.deckLinkScheduleRealignmentCount;
return metrics;
}
void DeckLinkOutput::HandleCompletion(const VideoIOCompletion& completion)
{
++mCompletions;
switch (completion.result)
{
case VideoIOCompletionResult::DisplayedLate:
++mDisplayedLate;
break;
case VideoIOCompletionResult::Dropped:
++mDropped;
break;
case VideoIOCompletionResult::Flushed:
++mFlushed;
break;
case VideoIOCompletionResult::Completed:
case VideoIOCompletionResult::Unknown:
default:
break;
}
if (mCompletionCallback)
mCompletionCallback(completion);
}
}

View File

@@ -0,0 +1,69 @@
#pragma once
#include "DeckLinkDisplayMode.h"
#include "DeckLinkSession.h"
#include "VideoIOTypes.h"
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
namespace RenderCadenceCompositor
{
struct DeckLinkOutputConfig
{
VideoFormat outputVideoMode;
bool externalKeyingEnabled = false;
bool outputAlphaRequired = false;
};
struct DeckLinkOutputMetrics
{
uint64_t completions = 0;
uint64_t displayedLate = 0;
uint64_t dropped = 0;
uint64_t flushed = 0;
uint64_t scheduleFailures = 0;
bool actualBufferedFramesAvailable = false;
uint64_t actualBufferedFrames = 0;
double scheduleCallMilliseconds = 0.0;
bool scheduleLeadAvailable = false;
int64_t playbackStreamTime = 0;
uint64_t playbackFrameIndex = 0;
uint64_t nextScheduleFrameIndex = 0;
int64_t scheduleLeadFrames = 0;
uint64_t scheduleRealignmentCount = 0;
};
class DeckLinkOutput
{
public:
using CompletionCallback = std::function<void(const VideoIOCompletion&)>;
DeckLinkOutput() = default;
DeckLinkOutput(const DeckLinkOutput&) = delete;
DeckLinkOutput& operator=(const DeckLinkOutput&) = delete;
~DeckLinkOutput();
bool Initialize(const DeckLinkOutputConfig& config, CompletionCallback completionCallback, std::string& error);
bool StartScheduledPlayback(std::string& error);
bool ScheduleFrame(const VideoIOOutputFrame& frame);
void Stop();
void ReleaseResources();
const VideoIOState& State() const;
DeckLinkOutputMetrics Metrics() const;
private:
void HandleCompletion(const VideoIOCompletion& completion);
DeckLinkSession mSession;
DeckLinkOutputConfig mConfig;
CompletionCallback mCompletionCallback;
std::atomic<uint64_t> mCompletions{ 0 };
std::atomic<uint64_t> mDisplayedLate{ 0 };
std::atomic<uint64_t> mDropped{ 0 };
std::atomic<uint64_t> mFlushed{ 0 };
};
}

View File

@@ -0,0 +1,127 @@
#pragma once
#include "../frames/SystemFrameTypes.h"
#include "DeckLinkOutput.h"
#include "VideoIOTypes.h"
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <thread>
namespace RenderCadenceCompositor
{
struct DeckLinkOutputThreadConfig
{
std::size_t targetBufferedFrames = 4;
std::chrono::milliseconds idleSleep = std::chrono::milliseconds(1);
};
struct DeckLinkOutputThreadMetrics
{
uint64_t scheduledFrames = 0;
uint64_t completedPollMisses = 0;
uint64_t scheduleFailures = 0;
};
template <typename SystemFrameExchange>
class DeckLinkOutputThread
{
public:
DeckLinkOutputThread(DeckLinkOutput& output, SystemFrameExchange& exchange, DeckLinkOutputThreadConfig config = DeckLinkOutputThreadConfig()) :
mOutput(output),
mExchange(exchange),
mConfig(config)
{
}
DeckLinkOutputThread(const DeckLinkOutputThread&) = delete;
DeckLinkOutputThread& operator=(const DeckLinkOutputThread&) = delete;
~DeckLinkOutputThread()
{
Stop();
}
bool Start()
{
if (mRunning)
return true;
mStopping = false;
mThread = std::thread([this]() { ThreadMain(); });
mRunning = true;
return true;
}
void Stop()
{
mStopping = true;
if (mThread.joinable())
mThread.join();
mRunning = false;
}
DeckLinkOutputThreadMetrics Metrics() const
{
DeckLinkOutputThreadMetrics metrics;
metrics.scheduledFrames = mScheduledFrames.load();
metrics.completedPollMisses = mCompletedPollMisses.load();
metrics.scheduleFailures = mScheduleFailures.load();
return metrics;
}
private:
void ThreadMain()
{
while (!mStopping)
{
const auto exchangeMetrics = mExchange.Metrics();
const auto outputMetrics = mOutput.Metrics();
const std::size_t bufferedFrames = outputMetrics.actualBufferedFramesAvailable
? static_cast<std::size_t>(outputMetrics.actualBufferedFrames)
: exchangeMetrics.scheduledCount;
if (bufferedFrames >= mConfig.targetBufferedFrames)
{
std::this_thread::sleep_for(mConfig.idleSleep);
continue;
}
SystemFrame frame;
if (!mExchange.ConsumeCompletedForSchedule(frame))
{
++mCompletedPollMisses;
std::this_thread::sleep_for(mConfig.idleSleep);
continue;
}
VideoIOOutputFrame outputFrame;
outputFrame.bytes = frame.bytes;
outputFrame.nativeBuffer = frame.bytes;
outputFrame.rowBytes = frame.rowBytes;
outputFrame.width = frame.width;
outputFrame.height = frame.height;
outputFrame.pixelFormat = frame.pixelFormat;
if (!mOutput.ScheduleFrame(outputFrame))
{
++mScheduleFailures;
mExchange.ReleaseScheduledByBytes(frame.bytes);
std::this_thread::sleep_for(mConfig.idleSleep);
continue;
}
++mScheduledFrames;
}
}
DeckLinkOutput& mOutput;
SystemFrameExchange& mExchange;
DeckLinkOutputThreadConfig mConfig;
std::thread mThread;
std::atomic<bool> mStopping{ false };
std::atomic<bool> mRunning{ false };
std::atomic<uint64_t> mScheduledFrames{ 0 };
std::atomic<uint64_t> mCompletedPollMisses{ 0 };
std::atomic<uint64_t> mScheduleFailures{ 0 };
};
}

View File

@@ -0,0 +1,540 @@
# Current System Architecture
This document describes how the application currently works.
It replaces the phase-by-phase design trail as the best entry point for understanding the repo. The older phase documents remain useful history, but they mix implementation notes, experiments, and target designs. This document is organized by current runtime behavior and subsystem ownership instead.
The active plan for tightening render-thread ownership is:
- [Render Thread Ownership Plan](RENDER_THREAD_OWNERSHIP_PLAN.md)
The plan for building a fresh modular app around the proven probe architecture is:
- [RenderCadenceCompositor README](../apps/RenderCadenceCompositor/README.md)
- [Render Cadence Golden Rules](RENDER_CADENCE_GOLDEN_RULES.md)
`NEW_RENDER_CADENCE_APP_PLAN.md` remains as historical planning context, but the README and golden rules are the current contract for the new cadence-first app.
## Application Shape
The app is a live OpenGL compositor with DeckLink input/output, runtime control services, persistent layer-stack state, live state overlays, health telemetry, and a small internal event model.
At runtime the major subsystems are:
- `OpenGLComposite`
- `RuntimeStore`
- `RuntimeCoordinator`
- `RuntimeSnapshotProvider`
- `RuntimeServices`
- `RuntimeUpdateController`
- `RenderEngine`
- `VideoBackend`
- `DeckLinkSession`
- `HealthTelemetry`
- `RuntimeEventDispatcher`
- `PersistenceWriter`
The key architectural rule is:
- runtime/control subsystems decide what state should exist
- render subsystems decide how to draw that state
- video subsystems decide how frames move to and from hardware
- telemetry observes behavior without becoming a control plane
## Process Startup
The Win32 app creates the window, chooses a pixel format, creates an OpenGL context, initializes COM, and constructs `OpenGLComposite`.
`OpenGLComposite` owns the high-level assembly of the runtime:
- runtime store
- runtime coordinator
- runtime services
- runtime update controller
- render engine
- video backend
Startup proceeds broadly as:
1. COM and OpenGL are initialized by the Win32 app.
2. `OpenGLComposite::InitDeckLink()` discovers/configures DeckLink and runtime state.
3. Runtime services are started.
4. Shader programs and GL resources are initialized.
5. The render thread is started.
6. The video backend starts output preroll and playback.
The normal VS Code debug launch currently sets:
```text
VST_DISABLE_INPUT_CAPTURE=1
```
That disables DeckLink input capture for output-timing isolation while keeping the output path active.
## Runtime State
### `RuntimeStore`
`RuntimeStore` owns durable runtime data and file-backed state.
It owns:
- runtime host configuration
- stored layer stack data
- persisted parameter values
- stack presets
- shader package catalog metadata
- runtime state presentation data
- persistence requests
It does not own render-thread resources, DeckLink timing, control ingress, or mutation policy.
### `CommittedLiveState`
`CommittedLiveState` owns current session/operator layer state that is live but not necessarily persisted as the durable base state.
It gives the renderer and snapshot builder a named read model for current committed layer state.
### `RuntimeCoordinator`
`RuntimeCoordinator` is the mutation policy boundary.
It validates and applies runtime mutations, classifies whether changes are persisted/committed/transient, emits persistence requests, and produces render reset/reload decisions.
It keeps mutation decisions out of:
- the render engine
- control services
- video backend
- telemetry
### `RuntimeSnapshotProvider`
`RuntimeSnapshotProvider` publishes render-facing snapshots.
It owns the currently published render snapshot and gives the render path a stable read boundary. Rendering does not read mutable store objects directly.
## Live State And Layering
The current render state is built from named layers of state:
- persisted layer/package/default state from the runtime store
- committed live/session state
- transient live overlays from OSC/control input
- render-local state owned by the renderer
`RuntimeStateLayerModel` names these categories. `RenderStateComposer` and `RuntimeLiveState` combine live values into render-facing state.
`RenderFrameInput` and `RenderFrameState` are the frame contract:
- `RenderFrameInput` describes what kind of frame is being built
- `RenderFrameState` describes the resolved state used to draw that frame
The renderer should not ask global state systems which snapshot or layer state to use midway through drawing.
## Control And Events
### `RuntimeServices`
`RuntimeServices` owns runtime-facing services such as OSC/control integration and service lifecycle.
It connects control ingress to the coordinator and live-state bridge.
### `ControlServices`
`ControlServices` handles OSC/control ingress, buffering, and polling/wake behavior.
It does not own runtime mutation policy. It normalizes ingress and asks the coordinator/runtime services to apply changes.
### `RuntimeEventDispatcher`
The app uses typed runtime events for internal coordination and observation.
Events are used for:
- runtime state broadcast requests
- shader build lifecycle
- backend state changes
- input/output frame observations
- timing samples
- health and queue observations
Events say what happened. Commands/request methods still exist where a caller needs an immediate success/failure answer.
## Persistence
Persistence is handled by `PersistenceWriter`.
Runtime mutations can enqueue persistence requests without blocking the render/output path. Shutdown performs a bounded persistence flush.
The store owns durable state; the writer owns background write execution.
## Render System
### `RenderEngine`
`RenderEngine` owns normal runtime OpenGL work.
It starts a dedicated render thread and binds the GL context on that thread. Runtime GL work enters through render-thread requests or render command queues.
The render thread handles:
- output frame rendering
- input frame upload
- preview present
- screenshot capture
- render-local resets
- shader/rebuild application
- temporal history and shader feedback resources
Startup initialization still happens before the render thread starts while the app explicitly owns the context. Normal runtime work is routed through `RenderEngine`.
### Current Render-Thread Limitation
The current render thread is a shared GL executor, not a pure output-only cadence thread.
This means output render can still be delayed by:
- input upload work
- preview present requests
- screenshot capture
- render reset commands
- shader/resource update work
- synchronous render-thread request queue wait
For output-timing diagnosis, input capture can be disabled with:
```text
VST_DISABLE_INPUT_CAPTURE=1
```
When enabled, the backend skips DeckLink input configuration/start and `HasInputSource()` reports false.
### `OpenGLRenderPipeline`
`OpenGLRenderPipeline` draws the frame and performs output packing/readback.
The current output path:
1. binds the composite framebuffer
2. calls the render effect callback
3. blits/composes into the output framebuffer
4. packs the output for the configured pixel format
5. flushes GL
6. reads output into the provided system-memory output frame
7. records render/readback timing
For BGRA8 output, the pipeline uses a BGRA-compatible pack framebuffer and async PBO readback by default.
## Video Backend
### `VideoBackend`
`VideoBackend` owns app-level video device lifecycle, output production, system-memory frame slots, and backend playout health.
It owns:
- backend lifecycle state
- output production worker
- output completion worker
- system-memory output frame pool
- ready/completed output queue
- render cadence controller
- playout policy
- output frame scheduling into `VideoIODevice`
- backend timing and queue telemetry
It does not own GL drawing. It asks `OpenGLVideoIOBridge` / `RenderEngine` to render into system-memory output frames.
### Lifecycle
The current backend lifecycle includes:
- discovery
- configuring
- configured
- prerolling
- running
- degraded
- stopping
- stopped
- failed
Startup now separates output schedule preparation from scheduled playback:
1. prepare the DeckLink output schedule
2. start output completion worker
3. start output producer worker
4. warm up rendered system-memory preroll frames
5. optionally start input streams
6. start DeckLink scheduled playback
### Output Production
The output producer is cadence-driven.
`RenderCadenceController` tracks the selected output frame duration and decides when the producer should render another frame.
The render producer attempts to render one output frame per selected output tick. It does not speed up just because DeckLink is empty.
If render/GPU work is late enough, the cadence controller can skip late ticks according to policy.
### System-Memory Frame Pool
`SystemOutputFramePool` owns reusable system-memory output slots.
Slots have four states:
- `Free`
- `Rendering`
- `Completed`
- `Scheduled`
In the current legacy app, completed-but-unscheduled frames are treated as a latest-N cache. The newer `RenderCadenceCompositor` uses a bounded FIFO completed reserve instead; see its README for the cadence-first contract.
Scheduled frames are protected until DeckLink reports completion.
### Output Queue
`RenderOutputQueue` holds completed unscheduled output frames waiting to be scheduled.
In the legacy app it is bounded and latest-N:
- pushing beyond capacity releases/drops the oldest ready frame
- `DropOldestFrame()` is used when the frame pool needs to recycle old completed work
### Scheduling
`VideoBackend::ScheduleReadyOutputFramesToTarget()` schedules completed system-memory frames up to the configured preroll/scheduled target.
DeckLink scheduling is capped by the current app-owned scheduled count. Real DeckLink buffered-frame telemetry is also recorded.
### Completion Handling
DeckLink completion callbacks do not render.
The callback path reports completion into `VideoBackend`, which processes completions on a backend worker. Completion processing:
- releases the system-memory slot by buffer pointer
- records pacing
- accounts for late/drop/flushed/completed result
- records telemetry
- wakes the output producer
## DeckLink Integration
### `DeckLinkSession`
`DeckLinkSession` is the DeckLink implementation of `VideoIODevice`.
It owns:
- DeckLink discovery
- input/output mode selection
- DeckLink input/output interfaces
- keyer configuration
- capture and playout delegates
- schedule-time generation through `VideoPlayoutScheduler`
- DeckLink frame scheduling
- actual buffered-frame telemetry
For output, system-memory frames are scheduled through DeckLink `CreateVideoFrameWithBuffer()`.
When a system-memory frame is scheduled, `DeckLinkSession` records a map from the DeckLink frame object back to the app-owned system-memory buffer pointer. On completion, the buffer pointer is returned so `VideoBackend` can release the matching slot.
### Actual DeckLink Buffer Telemetry
`DeckLinkSession` calls `GetBufferedVideoFrameCount()` after schedule/completion where available.
Telemetry separates:
- actual DeckLink buffered frames
- app-owned scheduled system-memory slots
- synthetic schedule/completion counters
- late/drop/flushed completion results
## Output Timing Experiments And Current Finding
The repo includes `DeckLinkRenderCadenceProbe`, a small standalone test app under:
```text
apps/DeckLinkRenderCadenceProbe
```
The probe does not use the main runtime, shader system, preview path, input upload path, or shared render engine. It uses:
- one OpenGL render thread with its own hidden GL context
- simple BGRA8 motion rendering
- async PBO readback
- legacy latest-N system-memory frame slots; bounded FIFO completed reserve in `RenderCadenceCompositor`
- a playout thread that feeds DeckLink
- real rendered warmup before scheduled playback
The first hardware result was smooth at roughly 59.94/60 fps with:
- `renderFps` near 59.9
- `scheduleFps` near 59.9
- DeckLink actual buffered frames stable at 4
- no late frames
- no dropped frames
- no PBO misses
- no completed-frame drops
That proves the clean architecture can work on the test machine. Remaining main-app timing issues are therefore likely integration/ownership issues in the main app rather than a fundamental DeckLink/OpenGL/BGRA8 limitation.
The highest-value current suspects are:
- input upload sharing the output render thread
- shared render-thread task queue contention
- preview/screenshot work
- runtime/render-state work on the output path
## Health Telemetry
`HealthTelemetry` owns app-visible health and timing observations.
It records:
- signal/input status
- performance/render timing
- event queue timing
- backend lifecycle/playout state
- output render queue wait
- output render/readback timing
- system-memory frame counts
- actual DeckLink buffer depth
- late/drop/flushed/completed frame counters
- schedule-call timing/failure counts
Several hot-path telemetry calls use try-lock variants so observation does not become a major timing dependency.
Runtime state presentation exposes telemetry through the runtime JSON/open API surface.
## Preview And Screenshot
Preview is best-effort.
`OpenGLComposite::paintGL()` skips preview when the backend reports output pressure. Preview presentation is requested through the render thread.
Screenshot capture is also a render-thread request. It reads pixels from the output framebuffer and writes PNG asynchronously after capture.
Both preview and screenshot share GL execution with output render, so they are secondary to output timing.
## Output Readback Modes
The output readback path supports environment-selected modes:
```text
VST_OUTPUT_READBACK_MODE=async_pbo
VST_OUTPUT_READBACK_MODE=sync
VST_OUTPUT_READBACK_MODE=cached_only
```
Default behavior is `async_pbo`.
Experiment findings:
- direct synchronous readback was slower on the sampled machine
- cached-only recovered timing but is visually invalid for live motion
- BGRA8 pack framebuffer plus async PBO removed the earlier large readback stall
## Current Debug/Experiment Launches
VS Code launch configurations include:
- `Debug LoopThroughWithOpenGLCompositing`
- `Debug LoopThroughWithOpenGLCompositing - sync readback experiment`
- `Debug LoopThroughWithOpenGLCompositing - cached output experiment`
- `Debug DeckLinkRenderCadenceProbe`
The default main-app debug launch currently disables input capture with `VST_DISABLE_INPUT_CAPTURE=1` so output timing can be tested without input upload interference.
## Current Ownership Summary
| Area | Current Owner |
| --- | --- |
| Durable runtime config/state | `RuntimeStore` |
| Current committed live layer state | `CommittedLiveState` |
| Mutation validation/policy | `RuntimeCoordinator` |
| Render snapshot publication | `RuntimeSnapshotProvider` |
| OSC/control ingress | `RuntimeServices` / `ControlServices` |
| Internal event dispatch | `RuntimeEventDispatcher` |
| Background persistence writes | `PersistenceWriter` |
| GL context and normal GL work | `RenderEngine` render thread |
| Render-pass execution and output readback | `OpenGLRenderPipeline` |
| Device lifecycle and output production | `VideoBackend` |
| DeckLink API integration | `DeckLinkSession` |
| Operational health/timing | `HealthTelemetry` |
## Current Runtime Flow Summary
### Control Mutation
```text
OSC/API/control input
-> RuntimeServices / ControlServices
-> RuntimeCoordinator
-> RuntimeStore / CommittedLiveState / RuntimeLiveState
-> RuntimeSnapshotProvider publication or live overlay update
-> RuntimeEventDispatcher observations
```
### Output Render
```text
VideoBackend output producer
-> RenderCadenceController tick
-> SystemOutputFramePool acquire rendering slot
-> OpenGLVideoIOBridge::RenderScheduledFrame
-> RenderEngine::RequestOutputFrame
-> render thread
-> OpenGLRenderPipeline::RenderFrame
-> system-memory output slot
-> RenderOutputQueue completed frame
```
### DeckLink Playout
```text
RenderOutputQueue completed frame
-> VideoBackend schedules to target
-> DeckLinkSession::ScheduleOutputFrame
-> CreateVideoFrameWithBuffer
-> ScheduleVideoFrame
-> DeckLink playback
-> completion callback
-> VideoBackend completion worker
-> release scheduled system-memory slot
```
### Input Capture
When input capture is enabled:
```text
DeckLink input callback
-> VideoBackend::HandleInputFrame
-> OpenGLVideoIOBridge::UploadInputFrame
-> RenderEngine::QueueInputFrame
-> render thread upload
```
When `VST_DISABLE_INPUT_CAPTURE=1`, this flow is skipped.
## Known Current Constraints
- The main app render thread still handles multiple kinds of GL work.
- Output render still uses a synchronous request/response call into the render thread.
- Input upload can contend with output render when input capture is enabled.
- Preview and screenshot share the render thread.
- Phase/experiment documents still exist as historical notes, but this document is the current architecture summary.
## Practical Rules
- Keep one owner for each kind of state.
- Keep GL work on the render thread.
- Keep DeckLink completion callbacks passive.
- In the legacy app, treat completed unscheduled output frames as latest-N cache entries; in `RenderCadenceCompositor`, preserve completed frames as a bounded FIFO reserve.
- Protect scheduled output frames until DeckLink completion.
- Keep output timing more important than preview/screenshot.
- Measure timing by domain instead of adding fallback branches blindly.

View File

@@ -0,0 +1,414 @@
# DeckLink / OpenGL Lessons Learned
This document summarizes the practical lessons from the Phase 3-7.7 refactor work, especially the DeckLink playout timing experiments.
It is intentionally broader than the phase design docs. The goal is to preserve what we now know about the system so future architecture choices start from evidence instead of rediscovering the same constraints.
## High-Level Lesson
The application is not just a renderer with a video output attached.
It is a real-time playout system with several independent clocks:
- the selected output cadence, for example 59.94 fps
- the GPU render/readback timeline
- the DeckLink scheduled playback clock
- the Windows thread scheduler
- the input capture callback cadence
- the preview/window message loop
- the runtime/control update cadence
Stable playback depends on assigning one owner to each timing domain and keeping those domains loosely coupled.
## What Worked
### Named State Contracts Helped
`RenderFrameInput` and `RenderFrameState` made the render path easier to reason about.
Before that, frame rendering depended on scattered choices about snapshots, cache state, layer state, input source state, and runtime service state. Naming the frame contract made it possible to move logic out of `RenderEngine` and toward explicit frame construction.
Lesson:
- keep frame inputs explicit
- keep render-frame state immutable for the duration of a frame
- avoid making the renderer ask global systems which state it should use mid-frame
### Render-Thread Ownership Helped
Moving GL work behind a render-thread boundary reduced wrong-thread GL access risk and made ownership clearer.
The current render thread is still shared by output render, input upload, preview, screenshot, resize, and reset work, so it is not yet a pure output cadence thread. But the ownership direction is right.
Lesson:
- GL context ownership should be explicit
- public methods should enqueue or request work
- render-thread methods should own GL bodies
- synchronous calls should be reserved for places that genuinely need a result
### Background Persistence Was Worth It
Moving persistence away from hot render/control paths reduced incidental latency risk and made state writes easier to reason about.
Lesson:
- runtime/control persistence should not sit on output render timing
- shutdown flushing is fine, steady-state blocking is not
### Lifecycle State Was Worth It
The backend lifecycle model gave us better failure and shutdown vocabulary.
This became important once startup stopped being a single `Start()` call and became:
- prepare output schedule
- start render cadence
- warm up real frames
- start input streams
- start scheduled playback
Lesson:
- playout startup needs phases
- degradation should be explicit
- shutdown order should be deliberate and testable
## What Did Not Work
### Completion-Driven Rendering Was Too Fragile
Rendering on or near DeckLink completion can average the target frame rate, but it leaves no headroom.
When the callback asks for a frame just-in-time, any small delay in render, readback, scheduling, or Windows wake timing becomes visible as a buffer dip or stutter.
Lesson:
- DeckLink completion should release scheduled resources and wake scheduling
- it should not render
- it should not decide visual fallback policy in steady state
### Black Fallback Hid The Real Timing Problem
Scheduling black on app-ready underrun made the pipeline appear to keep moving while producing visible black flicker.
It also made diagnosis harder because DeckLink could have scheduled frames while the app visibly failed.
Lesson:
- black is a startup/error/degraded-state policy, not normal steady-state recovery
- steady-state underruns should be measured as timing failures
### Synthetic Schedule Lead Was Misleading
The synthetic scheduled/completed index could report a large buffer while DeckLink still showed low actual device buffer depth.
Real DeckLink `GetBufferedVideoFrameCount()` telemetry was necessary to separate:
- app-owned scheduled slots
- synthetic schedule lead
- actual hardware/device buffer depth
Lesson:
- measure actual device buffer depth
- keep synthetic counters only as diagnostics
- do not infer device health from internal stream indexes alone
### Schedule Cursor Recovery Must Be Conservative
The DeckLink schedule cursor should normally advance as a continuous stream timeline. Continuously realigning the next scheduled stream time to the sampled playback cursor can create its own timing fault: output may look like low FPS even when render and scheduling counters average 59.94/60 fps.
What worked better:
- use the exact DeckLink frame duration for the render cadence
- keep healthy scheduling on a continuous stream cursor
- measure schedule lead from DeckLink playback time versus the next schedule time
- realign only after real pressure, such as a late/drop report or dangerously low measured lead
- re-arm proactive realignment only after lead has recovered
Lesson:
- schedule recovery is an output-edge safety valve, not a per-frame timing policy
- if recovery increments continuously, the recovery path has become the problem
- include schedule lead and realignment count in telemetry/logs so drift is visible before guessing
### More Buffer Is Not Automatically Smoother
Increasing DeckLink scheduled frames sometimes made the reported device buffer look healthier while visible motion still stuttered.
The problem was not only "how many frames are scheduled"; it was also whether the scheduled frames represented a stable render cadence.
Lesson:
- buffer depth absorbs jitter, but it cannot fix bad cadence ownership
- a full buffer of poorly timed or repeated frames can still look wrong
### Speed-Up Catch-Up Was The Wrong Instinct
Letting the producer sprint to refill the buffer created new timing artifacts.
The render side should behave like a stable game/render loop: render at the selected cadence, record lateness, and only skip ticks when render/GPU work itself overruns.
Lesson:
- the render thread should not render faster because DeckLink is empty
- buffer drain is a failure signal, not a sprint signal
- warmup should fill buffers before playback starts
## GPU Readback Lessons
### The Original Readback Path Was The Major Collapse
Early Phase 7.5 telemetry showed `glReadPixels(..., nullptr)` into the PBO costing roughly 8-14 ms on representative samples. That was enough to collapse ready depth and cause long freezes.
Direct synchronous readback was worse on the sampled machine.
Cached-output mode, while visually invalid for live output, immediately recovered timing. That proved ongoing GPU-to-CPU transfer was the major cost in that version of the path.
Lesson:
- isolate readback cost from render cost
- use intentionally invalid cached-output experiments when diagnosing throughput
- do not assume async PBO is actually cheap on every format/driver path
### BGRA8 Packing Changed The Problem
Changing the output path so readback matched the DeckLink BGRA8 format made `asyncQueueReadPixelsMs` drop dramatically in sampled runs.
Long pauses disappeared and the remaining issue became short stutters/cadence gaps.
Lesson:
- output/readback format matters
- avoid format conversions on the readback path when possible
- BGRA8 is a good current format target for experiments
- v210/YUV packing can be deferred until cadence is stable
### DeckLink SDK Fast Transfer Was Not Available On The Test GPU
The SDK OpenGL fast-transfer path depends on hardware/extension support that was not present on the RTX 4060 Ti test machine:
- NVIDIA DVP path was gated around Quadro-style support
- `GL_AMD_pinned_memory` was not exposed
Lesson:
- SDK fast-transfer samples are useful references but not a universal fix
- unsupported fast-transfer code should not be central to the architecture
- the default path must work with ordinary consumer GPUs
## DeckLink Lessons
### DeckLink Wants Scheduled System-Memory Frames
Using `CreateVideoFrameWithBuffer()` lets DeckLink schedule frames backed by our system-memory slots.
That is the right ownership model for this app:
- render/readback writes into a slot
- DeckLink schedules a frame that references that slot
- the slot is protected until DeckLink completion
Lesson:
- system-memory slots are the contract between render and playout
- scheduled slots must not be recycled early
- completed-but-unscheduled slots should form a bounded FIFO reserve for playout
### Startup Needs Real Preroll
Starting scheduled playback before real rendered frames exist creates avoidable startup fragility.
The better startup shape is:
- prepare the DeckLink schedule
- start render cadence
- render warmup frames at normal cadence
- schedule those frames as preroll
- start DeckLink scheduled playback
Lesson:
- do not use black preroll as the normal startup path
- do not render faster during warmup
- if warmup cannot fill in a bounded time, fail/degrade visibly
## Buffering Lessons
### There Are Two Different Buffers
The app has at least two important frame stores:
- system-memory completed FIFO reserve frames
- DeckLink scheduled/device buffer
They have different ownership rules.
Completed-but-unscheduled frames should be a bounded FIFO reserve for playout. If that reserve overflows, dropping the oldest completed frame is an app-side reserve policy and should be counted separately from DeckLink dropped frames.
Scheduled frames are not disposable because DeckLink may still read them.
Lesson:
- completed frames waiting for playout are a bounded FIFO reserve
- scheduled frames are owned by DeckLink until completion
- keep metrics for both
### Consume-Before-Render Is The Wrong Model For Completed Frames
If the render cadence waits for completed frames to be consumed, DeckLink timing can indirectly slow the renderer.
That couples the clocks again.
Lesson:
- render cadence should keep rendering at selected cadence
- render acquire should not evict completed frames that are waiting for playout
- if the completed reserve overflows, drop/count the oldest unscheduled completed frame
- only scheduled/in-flight saturation should prevent rendering to a safe slot
## Render Thread Lessons
### The Current Render Thread Is Still Shared
The GL render thread currently handles:
- output rendering
- input upload
- preview present
- screenshot capture
- render reset commands
- shader/resource operations
Output render can therefore be delayed by queued or inline work.
Lesson:
- "one GL thread" is not the same as "one output cadence thread"
- output render should become the highest-priority GL operation
- non-output GL work needs budgets, coalescing, or deferral
### Input Upload Is A Suspect Timing Coupling
Output render currently processes input upload work immediately before rendering the output frame.
That keeps input fresh but can steal time from the exact frame we are trying to render on cadence.
Lesson:
- measure input upload count and time immediately before output render
- test policies such as `one_before_output` or `skip_before_output`
- prefer latest-input semantics over draining every pending upload
### CPU Input Conversion Can Be Worse Than Input Copy
When DeckLink input only exposed UYVY8 on the test machine, an initial CPU UYVY-to-BGRA conversion in the input callback measured around a full-frame budget on sampled runs and reduced input cadence dramatically.
Moving the input edge to raw UYVY8 capture changed the ownership:
- DeckLink callback copies raw supported input bytes into `InputFrameMailbox`
- the mailbox keeps latest-frame semantics and uses a contiguous copy when row strides match
- the render thread uploads/decodes UYVY8 into the shader-visible `gVideoInput` texture
- runtime shaders continue to see decoded input, not packed capture bytes
Lesson:
- keep input callbacks as capture/copy edges
- keep GL decode/upload in the render-owned path
- measure input copy, upload, and decode separately
- do not hide expensive format conversion inside the DeckLink callback
### Preview And Screenshot Must Stay Secondary
Preview is useful, but DeckLink output is the real-time path.
Screenshot and preview share GL resources and can block or queue work on the same render thread.
Lesson:
- preview should be skipped when output is under pressure
- screenshot capture should be treated as disruptive unless proven otherwise
- forced preview/screenshot should be visible in telemetry
## Telemetry Lessons
The useful telemetry has been the telemetry that separates domains:
- output render queue wait
- render/draw time
- readback queue time
- readback fence/map/copy time
- app ready/completed queue depth
- system-memory free/rendering/completed/scheduled counts
- actual DeckLink buffered-frame count
- DeckLink schedule-call time/failures
- late/drop completion counts
Lesson:
- averages are not enough
- timing spikes matter more than steady low values
- count ownership states, not just queue depth
- keep experiment logs short and evidence-based
## Current Architectural Direction
The current direction is still sound:
```text
Render cadence loop
renders at selected output cadence
writes completed system-memory frames into a bounded FIFO reserve
never sprints to refill DeckLink
Frame store
owns free / rendering / completed / scheduled slots
recycles unscheduled completed frames when needed
protects scheduled frames until completion
DeckLink playout scheduler
consumes completed frames
tops up actual device buffer
never renders
Completion callback
releases scheduled slots
records completion result
wakes scheduler
```
## Rewrite Lesson
A full restart is not obviously the right next move.
The current repo now contains:
- working runtime/control architecture
- useful phase docs
- non-GL tests around key state machines
- real telemetry
- a clearer understanding of DeckLink and OpenGL timing
The better next step is likely a contained "V2 spine" inside the current app:
- harden the render cadence loop
- harden the frame store
- separate DeckLink scheduling
- demote preview/screenshot/input upload below output cadence
- delete old compatibility branches as they become unnecessary
A full rewrite becomes attractive only if the current GL ownership model cannot be made deterministic without excessive surgery, or if the project switches rendering API.
## Practical Rules Going Forward
- One timing authority per domain.
- Render cadence is time-driven, not completion-driven.
- DeckLink scheduling is device-buffer-driven, not render-driven.
- Completion callbacks release and report; they do not render.
- System-memory completed frames are a bounded FIFO reserve.
- Scheduled frames are protected until DeckLink completion.
- Startup uses real rendered warmup/preroll.
- Black fallback is degraded/error behavior, not steady-state behavior.
- Output render has priority over preview, screenshot, and bulk input upload.
- Measure before adding recovery branches.

View File

@@ -0,0 +1,580 @@
# New Render Cadence App Plan
Status: historical implementation plan. `apps/RenderCadenceCompositor` now exists; use [apps/RenderCadenceCompositor/README.md](../apps/RenderCadenceCompositor/README.md) and [Render Cadence Golden Rules](RENDER_CADENCE_GOLDEN_RULES.md) as the current implementation contract.
This plan describes a new application folder that rebuilds the output path from the proven `DeckLinkRenderCadenceProbe` architecture, but as a maintainable app foundation rather than a monolithic probe file.
The first goal is not to port the current compositor feature set. The first goal is to reproduce the probe's smooth 59.94/60 fps DeckLink output with clean module boundaries, tests where possible, and a structure that can later accept the shader/runtime/control systems without compromising timing.
## Working Name
Suggested folder:
```text
apps/RenderCadenceCompositor
```
Suggested executable:
```text
RenderCadenceCompositor
```
The existing app remains intact:
```text
apps/LoopThroughWithOpenGLCompositing
```
The probe remains the control sample:
```text
apps/DeckLinkRenderCadenceProbe
```
## Design Principle
The app is built around one spine:
```text
Render cadence thread
-> owns GL context
-> renders at selected frame cadence
-> performs async BGRA8 readback
-> publishes completed system-memory frames
System frame exchange
-> owns Free / Rendering / Completed / Scheduled slots
-> bounded FIFO reserve for completed unscheduled frames
-> protects scheduled frames until DeckLink completion
DeckLink output thread
-> consumes completed frames
-> schedules to target buffer depth
-> releases scheduled frames on completion
-> never renders
```
Everything else must fit around that spine.
## Non-Negotiable Rules
- The render thread owns its GL context from initialization to shutdown.
- The render thread is driven by selected render cadence, not DeckLink demand.
- DeckLink scheduling never calls render code.
- Completion callbacks never render.
- No synchronous render request exists in the output path.
- Preview, screenshot, input upload, shader rebuild, and runtime control cannot run ahead of a due output frame.
- Completed unscheduled frames are a bounded FIFO reserve; overflow drops are counted separately from DeckLink drops.
- Scheduled frames are protected until DeckLink completion.
- Startup warms up real rendered frames before scheduled playback starts.
## Borrow From The Probe
Keep these behaviors from `DeckLinkRenderCadenceProbe`:
- hidden OpenGL context owned by the render thread
- simple render loop with `nextRenderTime`
- BGRA8 render target
- PBO ring readback
- non-blocking fence polling with zero timeout
- system-memory slots with `Free`, `Rendering`, `Completed`, `Scheduled`
- preserve completed frames waiting for playout; drop/count the oldest completed frame only if the bounded reserve overflows
- DeckLink playout thread only schedules completed frames
- warmup completed frames before `StartScheduledPlayback()`
- one-line-per-second timing telemetry
## Do Not Borrow Directly
The probe is deliberately compact. Do not carry over these probe limitations into the new app:
- one huge `.cpp` file
- hard-coded output mode as permanent behavior
- render pattern, frame store, PBO logic, DeckLink playout, COM setup, and telemetry mixed together
- no reusable interfaces
- no unit-testable non-GL core
## Proposed Folder Structure
```text
apps/RenderCadenceCompositor/
README.md
RenderCadenceCompositor.cpp
app/
RenderCadenceApp.cpp
RenderCadenceApp.h
AppConfig.cpp
AppConfig.h
AppConfigProvider.cpp
AppConfigProvider.h
control/
HttpControlServer.cpp
HttpControlServer.h
RuntimeStateJson.h
platform/
ComInit.cpp
ComInit.h
HiddenGlWindow.cpp
HiddenGlWindow.h
Win32Console.cpp
Win32Console.h
render/
RenderThread.cpp
RenderThread.h
RenderCadenceClock.cpp
RenderCadenceClock.h
SimpleMotionRenderer.cpp
SimpleMotionRenderer.h
Bgra8ReadbackPipeline.cpp
Bgra8ReadbackPipeline.h
PboReadbackRing.cpp
PboReadbackRing.h
frames/
SystemFrameExchange.cpp
SystemFrameExchange.h
SystemFrameTypes.h
video/
DeckLinkOutput.cpp
DeckLinkOutput.h
DeckLinkOutputThread.cpp
DeckLinkOutputThread.h
telemetry/
CadenceTelemetry.cpp
CadenceTelemetry.h
CadenceTelemetryJson.h
TelemetryHealthMonitor.h
logging/
Logger.cpp
Logger.h
json/
JsonWriter.cpp
JsonWriter.h
```
The new app can reuse selected existing source files from the current app at first:
- `videoio/decklink/DeckLinkSession.*`
- `videoio/decklink/DeckLinkDisplayMode.*`
- `videoio/decklink/DeckLinkVideoIOFormat.*`
- `videoio/decklink/DeckLinkFrameTransfer.*`
- `videoio/VideoIOFormat.*`
- `videoio/VideoIOTypes.h`
- `videoio/VideoPlayoutScheduler.*`
- `gl/renderer/GLExtensions.*`
Longer term, shared code should move into common libraries, but the first version can link these files directly to avoid a big build-system refactor.
## Module Responsibilities
### `RenderCadenceApp`
Owns top-level startup/shutdown sequencing.
Responsibilities:
- initialize COM
- discover/select DeckLink output
- create frame exchange
- start render thread
- wait for completed-frame warmup
- start DeckLink output thread
- wait for scheduled buffer warmup
- start DeckLink scheduled playback
- start telemetry printer
- stop in reverse order
It should not contain OpenGL drawing code, frame slot policy, or DeckLink scheduling loops.
### `AppConfig`
Owns runtime settings for the initial app.
Initial settings:
- output mode preference
- output width/height validation
- frame buffer capacity
- PBO depth
- warmup completed-frame count
- target DeckLink scheduled depth
- telemetry interval
Initial values should match the successful probe:
```text
systemFrameSlots = 12
pboDepth = 6
warmupFrames = 4
targetDeckLinkBufferedFrames = 4
pixelFormat = BGRA8
```
### `HiddenGlWindow`
Owns hidden Win32 window, device context, and OpenGL context creation.
Responsibilities:
- create hidden window with `CS_OWNDC`
- choose/set pixel format
- create `HGLRC`
- expose `MakeCurrent()` and `ClearCurrent()`
- destroy context/window safely
Only `RenderThread` should call `MakeCurrent()` after startup.
### `RenderThread`
Owns the render loop and GL context for its full lifetime.
Responsibilities:
- create/bind hidden GL context
- resolve GL extensions
- initialize renderer/readback pipeline
- run cadence loop
- render one frame when due
- queue PBO readback
- consume completed PBOs into `SystemFrameExchange`
- record telemetry
- destroy GL resources on the render thread
It must not:
- wait for DeckLink
- schedule DeckLink frames
- block on a system frame slot if only completed unscheduled frames can be dropped
- accept arbitrary GL tasks ahead of output frames
### `RenderCadenceClock`
Small, testable cadence helper.
Responsibilities:
- track target frame duration
- return whether a render is due
- compute sleep duration
- detect overrun/skipped ticks
- never speed up to fill buffers
This should be unit tested without GL.
### `SimpleMotionRenderer`
First renderer only.
Responsibilities:
- render obvious smooth motion and color changes
- produce BGRA8-compatible framebuffer content
- make dropped/repeated frames visually obvious
This intentionally avoids shader-package/runtime complexity.
### `Bgra8ReadbackPipeline`
Owns output framebuffer and BGRA8 readback orchestration.
Responsibilities:
- configure render target dimensions
- render into an RGBA8/BGRA-compatible texture
- coordinate `PboReadbackRing`
- publish completed frames into `SystemFrameExchange`
### `PboReadbackRing`
Owns PBO/fence state.
Responsibilities:
- queue readback into the next free PBO slot
- poll completed fences with zero timeout
- map/copy completed PBOs into provided system-memory slots
- count PBO misses
- clean up fences/PBOs on render thread
This is GL-backed, but the state model should be small and easy to reason about.
### `SystemFrameExchange`
The central handoff between render and video.
Responsibilities:
- own system-memory frame buffers
- track slot states: `Free`, `Rendering`, `Completed`, `Scheduled`
- provide `AcquireForRender()`
- provide `PublishCompleted()`
- provide `ConsumeCompletedForSchedule()`
- provide `ReleaseScheduledByBytes()`
- drop oldest completed unscheduled frame when render needs a slot
- expose metrics
This should be unit tested heavily.
### `DeckLinkOutput`
Thin wrapper around `DeckLinkSession` for output-only use.
Responsibilities:
- discover/select output mode
- configure output callback
- prepare output schedule
- schedule app-owned system-memory frames
- start scheduled playback
- stop/release resources
- expose actual DeckLink buffered count
No input support in the first version.
### `DeckLinkOutputThread`
Owns playout scheduling loop.
Responsibilities:
- keep scheduled depth near target
- consume completed frames from `SystemFrameExchange`
- schedule them through `DeckLinkOutput`
- release frame if scheduling fails
- sleep briefly when scheduled buffer is full or no completed frame exists
It must not render.
### `CadenceTelemetry`
Owns counters, not policy.
Initial counters:
- rendered frames
- completed readback frames
- scheduled frames
- completion count
- completed-frame drops
- acquire misses
- schedule underruns
- PBO queue misses
- DeckLink late count
- DeckLink dropped count
- free/rendering/completed/scheduled slot counts
- actual DeckLink buffered frames
### `TelemetryHealthMonitor`
Samples cadence telemetry once per interval and logs only health events.
Normal telemetry is available through the HTTP state endpoint. The console should not receive a healthy once-per-second cadence line.
Health events:
- warning when DeckLink late/dropped-frame counters increase
- warning when schedule failures increase
- error when app/DeckLink output buffering is starved
## Startup Sequence
Target first-version startup:
```text
main
-> load AppConfig through AppConfigProvider
-> initialize COM
-> create SystemFrameExchange
-> start RenderThread
-> wait for completed frame warmup
-> optionally discover/select/configure DeckLink output
-> if DeckLink is available:
-> start DeckLinkOutputThread
-> wait for scheduled depth warmup
-> DeckLinkOutput start scheduled playback
-> if DeckLink is unavailable:
-> continue without video output
-> start TelemetryHealthMonitor
-> start HttpControlServer
-> wait for Enter
```
Shutdown:
```text
stop HttpControlServer
stop TelemetryHealthMonitor
stop DeckLinkOutputThread
DeckLinkOutput stop playback
stop RenderThread
DeckLinkOutput release resources
release COM
```
## First Milestone: Modular Probe Equivalent
This is the only goal for the initial implementation.
Feature set:
- console app
- output-only DeckLink
- no input
- hidden GL context
- simple motion renderer
- BGRA8 only
- PBO async readback
- bounded FIFO system-memory frame exchange
- warmup before playback
- one-line telemetry
Acceptance:
- visible DeckLink output is smooth
- `renderFps` near selected cadence
- `scheduleFps` near selected cadence
- scheduled count/decklink buffered count stable around 4
- no continuous late/drop count
- no continuous PBO misses
- behavior matches or exceeds `DeckLinkRenderCadenceProbe`
## Second Milestone: Testable Core
Before porting compositor features, add tests for non-GL/non-DeckLink pieces.
Test targets:
- `SystemFrameExchangeTests`
- `RenderCadenceClockTests`
- `CadenceTelemetryTests`
Important cases:
- slot lifecycle transitions
- scheduled slots are protected
- completed unscheduled frames can be dropped
- stale handles/generations are rejected
- cadence does not speed up to refill buffers
- cadence records overrun/skipped ticks
## Third Milestone: Replace Simple Renderer With Render Interface
Add an interface around frame rendering:
```text
IRenderScene
-> InitializeGl()
-> RenderFrame(frameIndex, time)
-> ShutdownGl()
```
The first implementation remains `SimpleMotionRenderer`.
This creates the insertion point for shader-package rendering later without changing timing/scheduling.
## Fourth Milestone: Begin Porting Current App Features
Port only after the modular probe equivalent is stable.
Suggested order:
1. shader package compile/load
2. render pass/layer stack drawing
3. runtime snapshot input to renderer
4. live state overlays
5. control services
6. persistence/runtime store
7. preview from system-memory frames
8. screenshot from system-memory frames
9. input capture via CPU latest-frame mailbox
Each port must preserve the rule that the render thread cadence is primary.
## What Not To Port Early
Do not port these until the output spine is proven:
- DeckLink input
- preview GL presentation
- screenshot GL readback
- HTTP/OSC control services
- shader hot reload
- persistence
- runtime state JSON/open API
- complex telemetry/event dispatch
These are useful, but they are exactly the kinds of features that can accidentally reintroduce timing coupling.
## Build Plan
Initial CMake can follow the probe pattern:
```cmake
set(RENDER_CADENCE_APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/apps/RenderCadenceCompositor")
add_executable(RenderCadenceCompositor
# selected shared DeckLink/video/gl support files
# new modular app files
)
```
Later, shared source should be split into libraries:
```text
video_shader_decklink
video_shader_videoio
video_shader_gl_support
render_cadence_core
```
Avoid doing that library split before the first modular app works.
## VS Code Launch
Add a separate launch profile:
```text
Debug RenderCadenceCompositor
```
Run it as a console app so telemetry remains visible.
## Documentation
Add:
```text
apps/RenderCadenceCompositor/README.md
```
The README should record:
- intended architecture
- build/run instructions
- expected telemetry
- test result notes
- differences from the old app
- differences from the probe
## Success Criteria Before Porting More Features
Do not start feature porting until the new app can run with:
- stable smooth DeckLink output
- stable target scheduled depth
- stable actual DeckLink buffered count
- no regular visible freezes
- no steady PBO misses
- no steadily increasing late/dropped completions
- focus/minimize changes do not affect output cadence
- clean shutdown without hangs
This gives us a clean foundation. Once this is true, every feature added later has to prove it does not damage the spine.

View File

@@ -1,715 +0,0 @@
# Phase 1 Design: Subsystem Boundaries and Target Architecture
This document expands Phase 1 of [ARCHITECTURE_RESILIENCE_REVIEW.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/ARCHITECTURE_RESILIENCE_REVIEW.md) into a concrete target design. Its purpose is to define the long-term subsystem split before later phases introduce a full event model and move rendering onto a sole-owner render thread.
The main goal of Phase 1 is not to immediately rewrite the app. It is to establish clear ownership boundaries so later refactors all move toward the same architecture instead of solving local problems in conflicting ways.
## Status
Phase 1 has two different meanings in this repo, and they should not be collapsed:
- Phase 1 design package: complete.
- Phase 1 runtime implementation foothold: complete.
The completed design package includes the agreed subsystem names, responsibilities, dependency rules, state categories, and current-to-target migration map. The runtime code now has concrete subsystem folders, collaborators, read models, and tests for those boundaries, and the compiled runtime path no longer depends on `RuntimeHost`. That is different from saying every target boundary is fully extracted across the whole app: later roadmap phases are still responsible for the event model, sole-owner render thread, explicit live-state layering, background persistence, backend state machine, and fuller telemetry.
## Why Phase 1 Exists
At the start of this phase the app worked, but too many responsibilities converged in a few places:
- `RuntimeHost` owned persistence, live layer state, shader package access, status reporting, and mutation entrypoints.
- `OpenGLComposite` coordinates runtime setup, render state retrieval, shader rebuild handling, transient OSC overlay behavior, and video backend integration.
- DeckLink callback-driven playout still reaches directly into render-facing work.
- Background services rely on polling and shared mutable state more than explicit subsystem contracts.
Those are exactly the kinds of overlaps that make timing issues, state regressions, and recovery edge cases harder to solve cleanly.
Phase 1 creates a map for where each responsibility should eventually live.
## Design Goals
The target architecture should optimize for:
- live timing isolation
- explicit state ownership
- predictable recovery behavior
- clear boundaries between persistent state and transient live state
- easier testing of non-GL and non-hardware logic
- fewer cross-thread shared mutable objects
- a playout model that can evolve toward producer/consumer scheduling
## Non-Goals
Phase 1 does not itself require:
- replacing every direct call with events immediately
- moving all rendering to a new thread yet
- redesigning the shader contract again
- changing DeckLink behavior in place
- removing all existing classes before replacements exist
This phase is the target design and the dependency rules. Later phases perform the actual extraction.
## Current Pressure Points
The following current code paths are the strongest evidence for the split proposed here:
- `RuntimeHost` was both store and live authority:
- `RuntimeHost.h`
- `RuntimeHost.cpp`
- `OpenGLComposite` is both app orchestrator and render/runtime coordinator:
- [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/composite/OpenGLComposite.cpp:106)
- [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/composite/OpenGLComposite.cpp:283)
- `RuntimeServices` mixes service orchestration with polling and deferred state work:
- [RuntimeServices.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h:46)
- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:194)
- Playout is still callback-coupled to render-facing work:
- [OpenGLVideoIOBridge.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/pipeline/OpenGLVideoIOBridge.cpp:68)
## Target Subsystems
The long-term architecture should converge on seven primary subsystems:
1. `RuntimeStore`
2. `RuntimeCoordinator`
3. `RuntimeSnapshotProvider`
4. `ControlServices`
5. `RenderEngine`
6. `VideoBackend`
7. `HealthTelemetry`
The split below is intentionally sharper than the current code. The point is to make ownership obvious.
Subsystem-specific design notes that elaborate these boundaries live under [docs/subsystems](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems).
## Phase 1 Document Set
This document is the parent note for the Phase 1 subsystem package. The bundle index and subsystem notes live here:
- [Subsystem Design Index](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/README.md)
- [RuntimeStore.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/RuntimeStore.md)
- [RuntimeCoordinator.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/RuntimeCoordinator.md)
- [RuntimeSnapshotProvider.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/RuntimeSnapshotProvider.md)
- [ControlServices.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/ControlServices.md)
- [RenderEngine.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/RenderEngine.md)
- [VideoBackend.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/VideoBackend.md)
- [HealthTelemetry.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/subsystems/HealthTelemetry.md)
## Current Implementation Foothold
The codebase now has a Phase 1 runtime implementation foothold in place:
- `RuntimeStore`
- [RuntimeStore.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/store/RuntimeStore.h)
- [RuntimeStore.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/store/RuntimeStore.cpp)
- `RuntimeConfigStore`
- [RuntimeConfigStore.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/store/RuntimeConfigStore.h)
- [RuntimeConfigStore.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/store/RuntimeConfigStore.cpp)
- `ShaderPackageCatalog`
- [ShaderPackageCatalog.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/store/ShaderPackageCatalog.h)
- [ShaderPackageCatalog.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/store/ShaderPackageCatalog.cpp)
- `RuntimeCoordinator`
- [RuntimeCoordinator.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.h)
- [RuntimeCoordinator.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/coordination/RuntimeCoordinator.cpp)
- `RuntimeSnapshotProvider`
- [RuntimeSnapshotProvider.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/snapshot/RuntimeSnapshotProvider.h)
- [RuntimeSnapshotProvider.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/snapshot/RuntimeSnapshotProvider.cpp)
- `RenderSnapshotBuilder`
- [RenderSnapshotBuilder.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/snapshot/RenderSnapshotBuilder.h)
- [RenderSnapshotBuilder.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/snapshot/RenderSnapshotBuilder.cpp)
- `ControlServices`
- [ControlServices.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.h)
- [ControlServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServices.cpp)
- `HealthTelemetry`
- [HealthTelemetry.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.h)
- [HealthTelemetry.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/runtime/telemetry/HealthTelemetry.cpp)
- `RenderEngine`
- [RenderEngine.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h)
- [RenderEngine.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp)
- `VideoBackend`
- [VideoBackend.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.h)
- [VideoBackend.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/videoio/VideoBackend.cpp)
The runtime seams are now concrete code boundaries. Some app-level flows still delegate through compatibility helpers, `OpenGLComposite`, `DeckLinkSession`, and the existing bridge/pipeline classes, but runtime responsibilities have moved behind named collaborators:
- UI/runtime control calls in `OpenGLCompositeRuntimeControls.cpp` now route through `RuntimeCoordinator`
- runtime startup now initializes path resolution and config loading through `RuntimeConfigStore`, with shader package scan and lookup delegated to `ShaderPackageCatalog`
- runtime/UI state JSON composition now routes through `RuntimeStatePresenter` and `RuntimeStateJson` instead of living in `RuntimeHost` or `RuntimeStore`
- regular stored layer mutations and stack preset save/load now route through `RuntimeStore` into `LayerStackStore` instead of `RuntimeHost` public APIs
- persisted OSC-by-control-key commits now route through `RuntimeCoordinator` before applying store changes
- mutation and reload policy now routes through `RuntimeCoordinator`
- parameter target resolution, value normalization, trigger classification, and move no-op classification now live under `RuntimeCoordinator`
- render-state and shader-build reads in `OpenGLComposite.cpp`, `OpenGLShaderPrograms.cpp`, and `ShaderBuildQueue.cpp` now route through `RuntimeSnapshotProvider`
- `RuntimeSnapshotProvider` now depends on `RenderSnapshotBuilder` rather than on `RuntimeStore` friendship or shared `RuntimeHost` access
- render-state assembly, cached parameter refresh, dynamic frame-field application, and render snapshot versions now live in `RenderSnapshotBuilder` instead of `RuntimeStore`
- `RuntimeSnapshotProvider` now publishes versioned render snapshot objects and serves matching consumers from the last published snapshot
- service ingress and polling coordination now route through `ControlServices`
- `ControlServices` now queues coordinator results for OSC commit and file-poll outcomes instead of directly deciding runtime/store policy
- timing and status writes now route through `HealthTelemetry`
- `HealthTelemetry` now owns the live signal, video-I/O, and performance snapshots directly instead of `RuntimeHost` keeping those backing fields
- render-side frame advancement and render-performance reporting now flow through `RuntimeSnapshotProvider` and `HealthTelemetry` instead of directly through `RuntimeHost`
- `RuntimeStore` now owns its durable/session backing fields directly instead of wrapping a compatibility `RuntimeHost` object
- `RuntimeConfigStore` now owns runtime config parsing, path resolution, configured ports/formats, runtime roots, and shader compiler paths instead of leaving those responsibilities inside `RuntimeStore`
- `ShaderPackageCatalog` now owns shader package scanning, package status/order/lookup, and package asset/source change comparison instead of leaving those responsibilities inside `RuntimeStore`
- `LayerStackStore` now owns durable layer state, layer CRUD/reorder, parameter persistence, and stack preset value serialization/load instead of leaving those responsibilities inside `RuntimeStore`
- `RuntimeStatePresenter` and `RuntimeStateJson` now own runtime-state JSON assembly and layer-stack presentation serialization instead of leaving those responsibilities inside storage classes
- `RuntimeCoordinator` now uses explicit `RuntimeStore` query APIs/read models instead of friendship or direct store-internal access
- live OSC overlay state and smoothing/commit decisions now live under `RenderEngine` instead of `OpenGLComposite`
- coordinator result application, shader-build requests, ready-build application, and runtime-state broadcasts now route through `RuntimeUpdateController` instead of being interpreted directly by `OpenGLComposite`
- `OpenGLComposite` now owns a `RenderEngine` seam for renderer, pipeline, render-pass, and shader-program responsibilities
- `OpenGLComposite` now owns a `VideoBackend` seam for device/session ownership and callback wiring
- `OpenGLVideoIOBridge` now acts as an explicit compatibility adapter between `VideoBackend` and `RenderEngine`, instead of `OpenGLComposite` directly owning both sides
- `RuntimeSubsystemTests` now cover the new runtime seams around layer-stack storage, preset round-trips, mutation classification, and runtime-state JSON serialization
That means Phase 2 can focus on eventing and coordination mechanics rather than inventing the runtime boundary vocabulary.
Later-phase extraction work includes:
- moving persistence to an asynchronous writer in a later phase
- replacing polling/shared-object coordination with the planned internal event model
- making the render thread the sole GL owner
- formalizing committed-live versus transient-overlay layering
- making backend lifecycle and telemetry richer and more explicit
## Subsystem Responsibilities
### `RuntimeStore`
`RuntimeStore` owns persisted and operator-authored state.
It is the source of truth for:
- runtime config loaded from disk
- persisted layer stack structure
- persisted parameter values
- stack preset serialization/deserialization
- shader/package metadata that must survive across renders
It should not be responsible for:
- render-thread timing
- GL resource lifetime
- live transient overlays
- hardware callback coordination
- UI/websocket broadcasting policy
Design rules:
- disk I/O belongs here or in its dedicated writer helper
- values here are authoritative for saved state
- writes may be debounced later, but the data model itself belongs here
### `RuntimeCoordinator`
`RuntimeCoordinator` is the mutation and policy layer.
It is responsible for:
- receiving valid mutation requests from controls, services, or automation
- validating requested changes against shader definitions and config rules
- resolving how persisted state, committed live state, and transient overlays should interact
- requesting snapshot publication when state changes affect render
- requesting persistence when stored state changes
It should not be responsible for:
- direct disk serialization details
- direct GL work
- hardware device lifecycle
- polling loops
Design rules:
- all non-render mutations should eventually flow through this layer
- this layer decides whether a change is persisted, transient, or both
- this layer owns state policy, not device policy
### `RuntimeSnapshotProvider`
`RuntimeSnapshotProvider` publishes render-facing snapshots.
It is responsible for:
- building immutable or near-immutable render snapshots
- translating runtime state into render-ready structures
- publishing versioned snapshots
- serving the render side without large mutable shared locks
It should not be responsible for:
- deciding whether a mutation is allowed
- directly applying UI/OSC requests
- persistence
- shader compilation orchestration
Design rules:
- render consumes snapshots, not live mutable store objects
- snapshots should be cheap to read and explicit about version changes
- dynamic frame-only values may still be attached later, but the snapshot shape should stay stable
### `ControlServices`
`ControlServices` is the ingress boundary for non-render control sources.
It is responsible for:
- OSC receive and route resolution
- REST/websocket/control UI ingress
- file-watch or reload request ingress
- translating external inputs into typed internal actions/events
- low-cost buffering/coalescing where appropriate
It should not be responsible for:
- persistence decisions
- render snapshot building
- hardware playout policy
- direct long-lived state ownership beyond ingress-specific queues
Design rules:
- external inputs enter here and are normalized before they touch core state
- service-specific timing concerns stay here unless they affect whole-app policy
- no service should directly mutate render-facing state structures
### `RenderEngine`
`RenderEngine` is the owner of live rendering behavior.
It is responsible for:
- sole ownership of GL work in the target architecture
- shader program lifecycle once compilation outputs are available
- texture upload scheduling
- render-pass execution
- temporal history and shader feedback resources
- transient render-only overlays
- preview production as a subordinate output
- output-frame production for the video backend
It should not be responsible for:
- persistence
- user-facing control normalization
- hardware discovery/configuration
- high-level runtime mutation policy
Design rules:
- render consumes snapshots plus render-local transient state
- render-local state is allowed if it stays render-local
- preview must be treated as best-effort relative to playout
### `VideoBackend`
`VideoBackend` owns input/output device lifecycle and playout policy.
It is responsible for:
- input device configuration and callbacks
- output device configuration and callbacks
- frame scheduling policy
- buffer-pool ownership
- playout headroom policy
- input signal status
- backend state transitions and recovery logic
It should not be responsible for:
- composing frames
- owning GL contexts long-term
- validating shader parameter changes
- persistence
Design rules:
- this subsystem is the consumer of rendered output frames, not the owner of frame composition policy
- it should evolve toward producer/consumer playout rather than callback-driven rendering
- backend state should be explicit and reportable
### `HealthTelemetry`
`HealthTelemetry` owns structured operational visibility.
It is responsible for:
- logging
- warning/error counters
- timing traces
- subsystem health state
- degraded-mode reporting
- operator-visible health summaries
It should not be responsible for:
- deciding core app behavior
- owning render or backend state
- persistence policy
Design rules:
- all major subsystems publish health information here
- health visibility should outlive UI connection state
- modal dialogs should not be the main operational surface
## Target Dependency Rules
The architecture should follow these rules as closely as possible.
Allowed dependency directions:
- `ControlServices -> RuntimeCoordinator`
- `RuntimeCoordinator -> RuntimeStore`
- `RuntimeCoordinator -> RuntimeSnapshotProvider`
- `RuntimeCoordinator -> HealthTelemetry`
- `RuntimeSnapshotProvider -> RenderSnapshotBuilder`
- `RenderSnapshotBuilder -> RuntimeStore`
- `RenderEngine -> RuntimeSnapshotProvider`
- `RenderEngine -> HealthTelemetry`
- `VideoBackend -> RenderEngine`
- `VideoBackend -> HealthTelemetry`
Conditionally allowed during migration:
- `ControlServices -> HealthTelemetry`
- `ControlServices -> RuntimeStore` only through temporary compatibility shims
Not allowed in the target design:
- `RenderEngine -> RuntimeStore`
- `RenderEngine -> ControlServices`
- `VideoBackend -> RuntimeStore`
- `ControlServices -> RenderEngine` for direct mutation
- `RuntimeStore -> RenderEngine`
- `HealthTelemetry -> any subsystem` for control flow
The key principle is:
- store owns durable data
- coordinator owns mutation policy
- snapshot provider owns render-facing state publication
- render owns live GPU execution
- backend owns device timing
- telemetry observes all of them
## State Ownership Model
The app has several different kinds of state, and Phase 1 should name them explicitly.
### Persisted State
Owned by `RuntimeStore`.
Examples:
- layer stack structure
- selected shader ids
- saved parameter values
- runtime host config
- stack presets
### Committed Live State
Owned logically by `RuntimeCoordinator`, stored in the store or a live-state companion depending on future implementation.
Examples:
- current operator-selected parameter values
- current bypass state
- current selected shader for each layer
This is state that should normally survive until explicitly changed and can be persisted if policy says so.
### Transient Live Overlay State
Owned by the subsystem that consumes it, not by the persisted store.
Examples:
- active OSC overlay targets while automation is flowing
- shader feedback buffers
- temporal history textures
- queued input frames
- in-flight preview state
- playout queue state
This is where many current issues come from. The design rule is:
- transient state may influence output
- transient state should not masquerade as persisted truth
### Health and Timing State
Owned by `HealthTelemetry`.
Examples:
- frame pacing stats
- render timing
- late/dropped frame counters
- queue depths
- warning states
## Target Runtime Flow
This section describes the intended long-term flow once later phases are in place.
### Control Mutation Flow
1. OSC/UI/file-watch input enters `ControlServices`.
2. `ControlServices` normalizes it into an internal action or event.
3. `RuntimeCoordinator` validates and classifies the action.
4. If the action changes durable state, `RuntimeStore` is updated.
5. If the action changes render-facing state, `RuntimeSnapshotProvider` publishes a new snapshot.
6. If the action requires persistence, a persistence request is queued.
7. Health/timing observations are emitted separately.
### Render Flow
1. `RenderEngine` consumes the latest published snapshot.
2. `RenderEngine` combines that snapshot with render-local transient state.
3. `RenderEngine` performs uploads, pass execution, feedback/history maintenance, and output production.
4. `RenderEngine` produces:
- preview-ready output
- video-backend-ready output frames
- render timing and warning signals
### Video Output Flow
Target long-term flow:
1. `RenderEngine` produces completed output frames ahead of demand.
2. `VideoBackend` consumes those frames from a bounded queue or ring buffer.
3. Device callbacks only drive dequeue/schedule/accounting behavior.
4. `HealthTelemetry` records queue depth, lateness, underruns, and recovery events.
### Reload / Shader Rebuild Flow
1. file-watch or manual reload enters through `ControlServices`
2. `RuntimeCoordinator` classifies the reload request
3. `RuntimeStore` and shader/package metadata are refreshed if needed
4. `RuntimeSnapshotProvider` republishes affected snapshot state
5. `RenderEngine` rebuilds render-local resources from the new snapshot/build outputs
The important boundary here is that reload is not "a render concern that also touches persistence." It is a coordinated runtime concern with a render-local execution phase.
## Suggested Public Interfaces
These are not final class signatures, but they show the shape the architecture should move toward.
### `RuntimeStore`
Core responsibilities:
- `LoadConfig()`
- `LoadPersistentState()`
- `BuildPersistentStateSnapshot(...)`
- `RequestPersistence(...)`
- `GetStoredLayerStack()`
- `SetStoredLayerStack(...)`
- `GetStackPresetNames()`
- `SaveStackPreset(...)`
- `LoadStackPreset(...)`
### `RuntimeCoordinator`
Core responsibilities:
- `ApplyControlMutation(...)`
- `ApplyAutomationTarget(...)`
- `ResetLayer(...)`
- `RequestReload(...)`
- `CommitOverlayState(...)`
- `PublishSnapshotIfNeeded()`
- `RequestPersistenceIfNeeded()`
### `RuntimeSnapshotProvider`
Core responsibilities:
- `BuildSnapshot(...)`
- `GetLatestSnapshot()`
- `GetSnapshotVersion()`
- `PublishSnapshot(...)`
### `ControlServices`
Core responsibilities:
- `StartOscIngress(...)`
- `StartWebControlIngress(...)`
- `StartFileWatchIngress(...)`
- `EnqueueControlAction(...)`
- `DrainServiceEvents(...)`
### `RenderEngine`
Core responsibilities:
- `StartRenderLoop(...)`
- `ConsumeSnapshot(...)`
- `EnqueueInputFrame(...)`
- `ProduceOutputFrame(...)`
- `ResetRenderLocalState(...)`
- `HandleRebuildOutputs(...)`
### `VideoBackend`
Core responsibilities:
- `ConfigureInput(...)`
- `ConfigureOutput(...)`
- `StartPlayout(...)`
- `StopPlayout(...)`
- `ConsumeRenderedFrame(...)`
- `ReportBackendState(...)`
### `HealthTelemetry`
Core responsibilities:
- `RecordTimingSample(...)`
- `RecordCounterDelta(...)`
- `RaiseWarning(...)`
- `ClearWarning(...)`
- `AppendLogEntry(...)`
- `BuildHealthSnapshot()`
## Mapping From Current Code to Target Subsystems
This is not a one-to-one rename plan. It is a responsibility migration map.
### Previous `RuntimeHost`
Should eventually split across:
- `RuntimeStore`
- `RuntimeCoordinator`
- `RuntimeSnapshotProvider`
- parts of `HealthTelemetry`
Likely examples:
- config loading/path resolution -> `RuntimeConfigStore`
- persistent state saving -> `RuntimeStore`
- layer stack mutation validation -> `RuntimeCoordinator`
- render state building/versioning -> `RenderSnapshotBuilder`
- render snapshot publication/cache -> `RuntimeSnapshotProvider`
- timing/status setters -> `HealthTelemetry`
### Current `RuntimeServices`
Should eventually become mostly:
- `ControlServices`
- a small service-hosting shell
Likely examples:
- OSC ingress/coalescing -> `ControlServices`
- file-watch ingress -> `ControlServices`
- deferred service coordination now done by polling -> split between `ControlServices` and event-driven coordinator calls
### Current `OpenGLComposite`
Should eventually split across:
- application bootstrap shell
- `RenderEngine`
- orchestration glue that wires subsystems together
Likely examples:
- render-pass facing code -> `RenderEngine`
- app/service/backend bootstrap -> composition root
- runtime mutation API surface -> coordinator-facing adapter, not render owner
### Current `OpenGLVideoIOBridge` and `DeckLinkSession`
Should eventually align more clearly under:
- `VideoBackend`
- `RenderEngine`
Likely examples:
- device callback and scheduling policy -> `VideoBackend`
- GL upload/readback/render work -> `RenderEngine`
## Architectural Guardrails
As later phases begin, these rules should be treated as guardrails.
### 1. No new cross-cutting runtime object should be introduced
If a new feature needs durable state, place it conceptually under `RuntimeStore`.
If it needs render-local transient state, place it conceptually under `RenderEngine`.
If it needs timing/status counters, place it conceptually under `HealthTelemetry`.
### 2. Render-local state should stay render-local
Do not push shader feedback, temporal history, preview caches, or playout queues back into the store just to make them easy to reach from other systems.
### 3. Device callbacks should not become a dumping ground for app work
Callback threads should converge toward signaling and queue management, not core rendering, persistence, or control mutation.
### 4. Persistence should not be used as a control synchronization mechanism
Saving state is not how subsystems discover changes. Published snapshots and explicit events should handle that.
### 5. Health reporting should observe, not coordinate
Telemetry systems may record warnings and degraded states, but they should not become the hidden control plane for the app.
## Migration Strategy
Phase 1 is a design phase, but it should support incremental migration.
Recommended order after this document:
1. Introduce names and interfaces before moving logic.
2. Create compatibility adapters around the subsystem facades rather than forcing a flag day.
3. Move read-only render snapshot publication out before moving all mutation logic.
4. Move service ingress boundaries out before removing the old polling shell.
5. Isolate timing/health setters from the core store as early as practical.
This keeps progress measurable while reducing rewrite risk.
## Suggested Deliverables for Completing Phase 1
Phase 1 can reasonably be considered complete once the project has:
- this subsystem-boundary design document
- agreed subsystem names and responsibilities
- agreed allowed dependency directions
- explicit state categories: persisted, committed live, transient overlay, health/timing
- a current-to-target responsibility map for runtime services, `OpenGLComposite`, and backend/render bridge code
- a decision that later phases will build against this target rather than inventing new boundaries ad hoc
By that definition, Phase 1 is complete for runtime: the design package is complete, `RuntimeHost` is retired from the compiled runtime path, runtime seams are represented in code, and runtime subsystem tests cover the new boundaries. App-wide ownership work continues in later phases.
## Open Questions For Later Phases
These do not block Phase 1, but they should remain visible.
- Should shader package registry ownership live entirely in `RuntimeStore`, or should compile-ready derived registry data move into the snapshot provider?
- Should committed live state be stored directly in `RuntimeStore`, or split into store plus live-session state owned by the coordinator?
- How much of shader build orchestration belongs to `RenderEngine` versus a separate build service?
- At what phase should preview become fully decoupled from playout cadence?
- Should persistence become its own `PersistenceWriter` subsystem in Phase 6, or remain an implementation detail under `RuntimeStore`?
## Short Version
Phase 1 should establish one simple rule for the rest of the refactor:
- durable state lives in the store
- mutation policy lives in the coordinator
- render-facing state is published as snapshots
- external control sources enter through services
- GL work belongs to render
- hardware pacing belongs to the backend
- health visibility belongs to telemetry
If later phases keep to that rule, the architecture will become materially more resilient without needing another round of foundational boundary changes.

View File

@@ -1,660 +0,0 @@
# Phase 2 Design: Internal Event Model
This document expands Phase 2 of [ARCHITECTURE_RESILIENCE_REVIEW.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/ARCHITECTURE_RESILIENCE_REVIEW.md) into a concrete design target.
Phase 1 established the subsystem vocabulary and moved the runtime path behind clearer collaborators. Phase 2 should now give those subsystems a safer way to coordinate than direct cross-calls, shared mutable result queues, and coarse polling loops.
## Status
- Phase 2 design package: accepted.
- Phase 2 implementation: substantially complete for the coordination substrate.
- Current alignment: the typed event substrate, app-owned dispatcher, coalesced app pump, reload bridge events, production bridges, and event behavior tests are in place. Remaining items are narrow follow-ups rather than foundation work.
The current repo now has concrete Phase 2 implementation footholds:
- `RuntimeEventType`, typed payload structs, `RuntimeEvent`, `RuntimeEventQueue`, `RuntimeEventDispatcher`, and `RuntimeEventCoalescingQueue` define the event substrate.
- `OpenGLComposite` owns one app-level `RuntimeEventDispatcher` and passes it into `RuntimeServices`, `RuntimeCoordinator`, `RuntimeUpdateController`, `RuntimeSnapshotProvider`, `ShaderBuildQueue`, and `VideoBackend`.
- `ControlServices` publishes typed OSC and runtime-state broadcast events and uses condition-variable wakeups with a fallback compatibility timer.
- `RuntimeCoordinator` publishes accepted, rejected, state-changed, persistence, reload, shader-build, and compile-status follow-up events.
- `RuntimeUpdateController` subscribes to event families for broadcast, shader build, compile status, render reset, and dispatcher health observations.
- `RuntimeSnapshotProvider` publishes render snapshot request/published events.
- `ShaderBuildQueue` and `RuntimeUpdateController` publish shader build lifecycle events with generation matching.
- `VideoBackend` publishes backend observation events and timing samples.
- `HealthTelemetry` receives dispatcher metrics directly and the event vocabulary now includes health observation events.
- Tests cover event type stability, payload mapping, FIFO dispatch, coalescing infrastructure, app-level coalesced broadcast/build behavior, handler failures, mutation follow-up behavior, reload bridge behavior, and shader-build generation behavior.
The implementation is now established in the repo. The remaining Phase 2 follow-up work is small: add completion/failure observations where useful and keep the runtime-store poll fallback explicitly transitional until a later file-watch implementation replaces it.
## Why Phase 2 Exists
The resilience review originally called out three timing and ownership problems that an event model could directly improve:
- background service timing relied on coarse sleeps and polling
- control, reload, persistence, and render-update work traveled through mixed shared state and result queues
- later render/backend refactors need a stable coordination model before they move more work across threads
The goal is not to make the app fully asynchronous in one pass. It is to introduce typed internal events so each subsystem can publish what happened without knowing who will react or how many downstream effects are needed.
## Goals
Phase 2 should establish:
- a small typed event vocabulary for control, runtime, render, backend, persistence, and health coordination
- one app-owned event pump or dispatcher that can route events deterministically
- bounded queues with clear ownership and no unbounded background growth
- wakeup-driven service coordination where practical, replacing coarse polling as the default shape
- explicit event-to-command boundaries so events do not become hidden global mutation APIs
- tests for event ordering, coalescing, rejection, and dispatch side effects
## Non-Goals
Phase 2 should not require:
- a dedicated render thread yet
- a full actor system
- lock-free queues everywhere
- background persistence implementation
- a complete DeckLink state machine
- final live-state layering
- replacing every direct call in one change
Those are later phases. Phase 2 provides the coordination substrate they can build on.
## Current Coordination Shape
The current runtime is much cleaner than before Phase 1, and Phase 2 has moved the main coordination model toward typed publication and app-owned dispatch:
- `ControlServices` publishes OSC value, OSC commit, and runtime-state broadcast events.
- `ControlServices::PollLoop(...)` is wakeup-driven for queued OSC commit work, with a bounded fallback timer for compatibility polling.
- `RuntimeCoordinator` still returns `RuntimeCoordinatorResult` for synchronous callers, but also publishes accepted/rejected/follow-up events.
- `RuntimeUpdateController` subscribes to event families and applies many effects from events rather than only from drained result objects.
- shader-build request, readiness, failure, and application are represented by typed events.
- render snapshot publication and backend observations are represented by typed events.
- dispatcher queue metrics and handler failures feed telemetry and health observation events.
There is still transitional bridge-state:
- `ControlServices` still exposes completed OSC commit notifications for render overlay settlement.
- `RuntimeEventCoalescingQueue` is now wired into the app-owned dispatcher for latest-value event types.
- `FileChangeDetected` and `ManualReloadRequested` are now published as reload ingress bridge events before coordinator reload follow-ups.
- runtime-state broadcast completion/failure events are still a target, not current behavior.
That means Phase 2 is complete enough as the coordination substrate for later phases. The remaining items are refinement work and should not block moving to render ownership, live-state layering, or persistence work.
## Event Model Principles
### Events say what happened
Events should describe facts:
- `OscValueReceived`
- `RuntimeMutationAccepted`
- `RuntimeMutationRejected`
- `ShaderReloadRequested`
- `ShaderBuildPrepared`
- `ShaderBuildFailed`
- `RenderSnapshotPublished`
- `RuntimeStateBroadcastRequested`
They should not be vague commands like "do everything needed now."
### Commands request intent
Some work is still naturally command-shaped:
- "apply this parameter mutation"
- "request shader reload"
- "save this stack preset"
- "start backend output"
Commands enter an owner subsystem. Events leave a subsystem after the owner has accepted, rejected, or completed work.
### One owner mutates each state category
Events must not become a way to bypass Phase 1 ownership:
- `RuntimeCoordinator` remains the owner of mutation policy.
- `RuntimeStore` remains the owner of durable state.
- `RuntimeSnapshotProvider` remains the owner of render snapshot publication.
- `RenderEngine` remains the owner of render-local transient state.
- `VideoBackend` remains the owner of device lifecycle and pacing.
- `HealthTelemetry` observes and reports, but does not coordinate behavior.
### Event handlers should be small
Handlers should translate events into owner calls or follow-up events. They should not accumulate hidden long-lived state unless that state belongs to the handler's subsystem.
### Queues must be bounded or coalesced
High-rate control traffic can arrive faster than the app should process every individual sample. Phase 2 should preserve the useful current behavior of coalescing OSC updates by route, but make the coalescing policy explicit.
## Event Families
### Control Events
Produced by `ControlServices`.
Examples:
- `OscValueReceived`
- `OscValueCoalesced`
- `OscCommitRequested`
- `HttpControlMutationRequested`
- `WebSocketClientConnected`
- `RuntimeStateBroadcastRequested`
- `FileChangeDetected`
- `ManualReloadRequested`
Primary consumers:
- `RuntimeCoordinator`
- `HealthTelemetry`
- later, a persistence writer or diagnostics publisher
### Runtime Events
Produced by `RuntimeCoordinator`, `RuntimeStore`, and snapshot publication code.
Examples:
- `RuntimeMutationAccepted`
- `RuntimeMutationRejected`
- `RuntimeStateChanged`
- `RuntimePersistenceRequested`
- `RuntimeReloadRequested`
- `ShaderPackagesChanged`
- `RenderSnapshotPublishRequested`
- `RuntimeStatePresentationChanged`
Primary consumers:
- `RuntimeSnapshotProvider`
- `RenderEngine`
- `ControlServices`
- `HealthTelemetry`
- later, `PersistenceWriter`
### Shader Build Events
Produced by shader build orchestration and render-side build application.
Examples:
- `ShaderBuildRequested`
- `ShaderBuildPrepared`
- `ShaderBuildApplied`
- `ShaderBuildFailed`
- `CompileStatusChanged`
Primary consumers:
- `RenderEngine`
- `RuntimeCoordinator`
- `ControlServices`
- `HealthTelemetry`
### Render Events
Produced by `RenderEngine` and `RuntimeSnapshotProvider`.
Examples:
- `RenderSnapshotPublished`
- `RenderResetRequested`
- `RenderResetApplied`
- `OscOverlayApplied`
- `OscOverlaySettled`
- `FrameRendered`
- `PreviewFrameAvailable`
Primary consumers:
- `RenderEngine`
- `ControlServices`
- `VideoBackend`
- `HealthTelemetry`
### Backend Events
Produced by `VideoBackend` and backend adapters.
Examples:
- `InputSignalChanged`
- `InputFrameArrived`
- `OutputFrameScheduled`
- `OutputFrameCompleted`
- `OutputLateFrameDetected`
- `OutputDroppedFrameDetected`
- `BackendStateChanged`
Primary consumers:
- `RenderEngine`
- `HealthTelemetry`
- later, backend lifecycle state machine handlers
### Health Events
Produced by all major subsystems.
Examples:
- `SubsystemWarningRaised`
- `SubsystemWarningCleared`
- `SubsystemRecovered`
- `TimingSampleRecorded`
- `QueueDepthChanged`
Primary consumer:
- `HealthTelemetry`
Health events should be observational. They should not be required for core behavior to proceed.
## Event Envelope
A practical initial event envelope can stay simple:
```cpp
enum class RuntimeEventType
{
OscCommitRequested,
RuntimeMutationAccepted,
RuntimeMutationRejected,
RuntimeReloadRequested,
ShaderBuildRequested,
ShaderBuildPrepared,
ShaderBuildFailed,
RenderSnapshotPublishRequested,
RenderSnapshotPublished,
RuntimeStateBroadcastRequested,
BackendStateChanged,
SubsystemWarningRaised
};
struct RuntimeEvent
{
RuntimeEventType type;
uint64_t sequence = 0;
std::chrono::steady_clock::time_point createdAt;
std::string source;
std::variant<
OscCommitRequestedEvent,
RuntimeMutationEvent,
ShaderBuildEvent,
RenderSnapshotEvent,
BackendEvent,
HealthEvent> payload;
};
```
The exact C++ names can change. The key design requirements are:
- event type is explicit
- event order is observable
- source subsystem is recorded
- payload is typed, not a bag of optional strings
- timestamps exist for queue-age telemetry
- failures are events too, not just debug strings
## Event Bus Shape
Phase 2 does not need a large framework. A small app-owned dispatcher is enough.
Suggested components:
- `RuntimeEventDispatcher`
- owns queues
- assigns sequence numbers
- exposes `Publish(...)`
- exposes `DispatchPending(...)`
- event handlers
- narrow handler interface or function callback
- registered by subsystem/composition root
- `RuntimeEventQueue`
- bounded FIFO for ordinary events
- `RuntimeEventCoalescingQueue`
- bounded keyed latest-value queue for flows such as high-rate OSC, broadcast requests, file/reload bursts, and queue-depth telemetry
- queue and dispatch metrics
- queue depth
- oldest event age
- dropped/coalesced counts
Initial implementation is single-process and mostly single-dispatch-thread. The important part is that event publication and event handling are explicit.
### Dispatcher Ownership Decision
The first concrete implementation uses one app-owned `RuntimeEventDispatcher`.
Ownership:
- `OpenGLComposite` owns the dispatcher as part of the current composition root.
References:
- `RuntimeServices` receives the dispatcher and passes it to `ControlServices`.
- `RuntimeCoordinator` receives the dispatcher so coordinator outcomes can become explicit events.
- `RuntimeUpdateController` receives the dispatcher so it can become the first effect/apply handler.
- `RuntimeSnapshotProvider`, `ShaderBuildQueue`, and `VideoBackend` receive the dispatcher so snapshot, shader lifecycle, and backend observation events are visible.
This is intentionally a composition-root dependency, not a new subsystem dependency. Subsystems should not construct their own dispatchers, and future tests should use `RuntimeEventTestHarness` rather than creating ad hoc event plumbing.
The dispatcher should move out of `OpenGLComposite` only if a later application-shell/composition-root object replaces `OpenGLComposite` as the owner of subsystem wiring.
## Queue Policy
Not every event deserves the same queue semantics.
### FIFO Events
Use FIFO for events where every item matters:
- mutation accepted/rejected
- shader build completed/failed
- backend state changed
- warning raised/cleared
### Coalesced Events
Use coalescing for high-rate latest-value flows:
- OSC parameter target updates by route
- runtime-state broadcast requests
- file-change reload requests during a burst
- queue-depth telemetry
Coalesced events should record how many updates were collapsed so telemetry can show pressure.
### Synchronous Boundaries
Some calls may remain synchronous during Phase 2:
- UI/API mutation calls that need an immediate success/error response
- startup configuration failures
- shutdown ordering
- tests
The rule is that synchronous calls should still publish events for accepted/rejected/completed work, so the rest of the app does not need to infer side effects from the call path.
## Event Bridge Policy
This section is the implementation rulebook for converting existing direct calls and result queues into events. Future Phase 2 lanes should use this table unless they deliberately update the policy here first.
### Bridge Categories
| Bridge category | Use when | Queue shape | Handler expectation |
| --- | --- | --- | --- |
| `fifo-fact` | every occurrence matters and must be observed in order | bounded FIFO | handler consumes each event exactly once |
| `coalesced-latest` | only the latest value per key matters | bounded coalescing queue | handler consumes the latest event and telemetry records collapsed count |
| `sync-command-with-event` | caller needs an immediate success/error result | direct owner call plus follow-up event publication | handler must not be required for the caller's response |
| `observation-only` | event is telemetry/diagnostic and must not drive core behavior | FIFO or coalesced depending on rate | handler failure must never block app behavior |
| `compatibility-poll` | source cannot yet publish an event directly | temporary poll adapter publishes typed events | poll interval is wakeup-driven with a fallback timer until a later file-watch implementation replaces it |
### Current Bridge Decisions
| Current flow | Phase 2 bridge | Event(s) | Current status |
| --- | --- | --- | --- |
| OSC latest-value updates | `ControlServices` ingress bridge | `OscValueReceived`, optional `OscValueCoalesced` | Event publication exists; source-side pending map and app-level dispatcher coalescing both provide latest-value behavior. |
| OSC commit after settle | `ControlServices -> RuntimeCoordinator` bridge | `OscCommitRequested`, then `RuntimeMutationAccepted` or `RuntimeMutationRejected` | Event publication exists. Coordinator follow-up work now reaches the app path through events rather than a service-result queue. |
| HTTP/UI mutation needing response | direct call into `RuntimeCoordinator` | `RuntimeMutationAccepted` or `RuntimeMutationRejected` after the synchronous response path | Implemented as `sync-command-with-event`; synchronous response remains supported. |
| runtime-state broadcast request | presentation/broadcast bridge | `RuntimeStatePresentationChanged`, `RuntimeStateBroadcastRequested` | Request event exists, is handled, and is coalesced by the app dispatcher. Completion/failure events remain follow-ups. |
| manual reload button | control ingress bridge | `ManualReloadRequested`, then `RuntimeReloadRequested` | Ingress and follow-up events exist and are covered by tests. |
| file watcher changes | file-watch bridge | `FileChangeDetected`, then `RuntimeReloadRequested` | Poll fallback remains, but detected changes now publish ingress and follow-up events and are covered by tests. |
| runtime store poll fallback | compatibility poll adapter | `FileChangeDetected`, `RuntimeReloadRequested`, or warning/compile-status event | Still present by design as a transitional bridge with a condition-variable fallback timer. Detected changes publish ingress and follow-up events. |
| shader build request | runtime/render bridge | `ShaderBuildRequested` | Event publication, handler, and app dispatcher coalescing exist. |
| shader build ready/failure/apply | shader build lifecycle bridge | `ShaderBuildPrepared`, `ShaderBuildFailed`, `ShaderBuildApplied`, `CompileStatusChanged` | Implemented with generation matching. |
| render snapshot publication | snapshot bridge | `RenderSnapshotPublishRequested`, `RenderSnapshotPublished` | Implemented. Publish requests are coalesced by output dimensions in the app dispatcher. |
| render reset request/application | render bridge | `RenderResetRequested`, `RenderResetApplied` | Request handling exists; applied event coverage can be expanded in later render work. |
| input signal changes | backend observation bridge | `InputSignalChanged` | Implemented as backend observation publication. |
| output late/dropped/completed frames | backend timing bridge | `OutputFrameCompleted`, `OutputLateFrameDetected`, `OutputDroppedFrameDetected` | Implemented at the vocabulary and backend publication level. High-rate policy may be refined during backend lifecycle work. |
| warnings and recovery | telemetry bridge | `SubsystemWarningRaised`, `SubsystemWarningCleared`, `SubsystemRecovered` | Vocabulary exists; direct telemetry writes still coexist with event observations. |
| queue depth/timing samples | telemetry metrics bridge | `QueueDepthChanged`, `TimingSampleRecorded` | Implemented for dispatcher/backend observations and coalesced by metric key in the app dispatcher. |
### Bridge Rules
- A bridge may translate an old direct call into an owner command, but it must publish the accepted/rejected/completed event that describes the outcome.
- A bridge must not mutate state owned by another subsystem just because it handles that subsystem's event.
- A coalesced event must have a stable key in code and a documented policy here.
- A FIFO event should be cheap enough that retaining every occurrence is useful. If not, turn it into a coalesced metric before putting it on a hot path.
- A synchronous bridge must treat event publication as a side effect of the owner decision, not as the mechanism that produces the direct caller's response.
- A compatibility poll adapter should be named as temporary in code so it does not become the new long-term coordination model.
- Handler failure should be reported through telemetry and dispatch metrics. It should not throw back across subsystem boundaries.
### First Integration Recommendation
The safest first behavior-changing bridge is `RuntimeStateBroadcastRequested`.
It is low risk because:
- it is already a side effect of many coordinator outcomes
- duplicate requests are naturally coalescable
- the handler can call the existing `ControlServices::BroadcastState()` path
- success can be verified through existing UI behavior and event tests
After that, the next bridge should be `ShaderBuildRequested`, because it already behaves like a queued side effect and has clear follow-up events.
## Target Flow Examples
### OSC Parameter Update
1. `OscServer` decodes a packet.
2. `ControlServices` publishes or coalesces `OscValueReceived`.
3. The dispatcher routes the event to the render-overlay path or coordinator policy, depending on whether the value is transient or committing.
4. `RuntimeCoordinator` publishes `RuntimeMutationAccepted` or `RuntimeMutationRejected` for committed changes.
5. Accepted committed changes publish `RenderSnapshotPublishRequested` and `RuntimePersistenceRequested` as needed.
6. `ControlServices` receives `RuntimeStateBroadcastRequested` or a presentation-changed event and broadcasts at its own cadence.
### File Reload
1. File-watch or manual reload produces `FileChangeDetected` or `ManualReloadRequested`.
2. `ControlServices` coalesces reload bursts into one `RuntimeReloadRequested`.
3. `RuntimeCoordinator` classifies the reload.
4. Package/store refresh produces `ShaderPackagesChanged` if package metadata changed.
5. Coordinator publishes `ShaderBuildRequested`.
6. Shader build completion publishes `ShaderBuildPrepared` or `ShaderBuildFailed`.
7. Render applies the ready build and publishes `ShaderBuildApplied`.
### Runtime State Broadcast
1. A mutation or reload publishes `RuntimeStatePresentationChanged`.
2. `ControlServices` coalesces this into a broadcast request.
3. The broadcast path asks `RuntimeStatePresenter` for the current presentation read model.
4. `HealthTelemetry` records broadcast count, failures, and queue age.
### Backend Signal Change
1. Backend adapter detects input signal change.
2. `VideoBackend` publishes `InputSignalChanged`.
3. `HealthTelemetry` records the new signal status.
4. Later phases may let the backend lifecycle state machine react to the same event.
## Migration Plan
### Step 1. Add Event Types And A Minimal Dispatcher
Status: complete.
Introduce:
- `RuntimeEvent`
- `RuntimeEventType`
- typed payload structs for the smallest useful event family
- `RuntimeEventBus` or equivalent dispatcher
Start with events that do not change behavior:
- `RuntimeStateBroadcastRequested`
- `ShaderBuildRequested`
- `RuntimeMutationRejected`
- simple health/log observations
### Step 2. Convert `RuntimeUpdateController` Into An Event Handler
Status: complete for the Phase 2 target, with synchronous API helpers retained.
`RuntimeUpdateController` is already close to an event effect applier. Phase 2 should narrow it into a handler for:
- coordinator outcome events
- shader build readiness events
- snapshot publication requests
- broadcast requests
The class should stop being the place that polls every source of work.
Current note: `RuntimeUpdateController` now subscribes to the dispatcher and handles broadcast, reload, shader build, compile status, render reset, and health observation paths. It still accepts synchronous `RuntimeCoordinatorResult` values for UI/API calls that need immediate success or error responses.
### Step 3. Replace `ControlServices::PollLoop` Sleep With Wakeups
Status: complete for OSC commit wakeups; runtime-store compatibility polling remains explicitly transitional.
Keep coalescing, but replace the fixed `25 x Sleep(10)` cadence with:
- a condition variable or waitable event
- wakeups when OSC commit work arrives
- wakeups when file/reload work arrives
- a fallback timer only for compatibility polling that cannot yet be evented
This is the most direct Phase 2 timing win.
Current note: `ControlServices` now uses a condition variable and fallback timer. The fallback exists for runtime-store polling until a later file-watch implementation can replace scanning as the change source. Detected reload/file changes publish typed ingress and follow-up events.
### Step 4. Route Shader Build Lifecycle Through Events
Status: mostly complete.
Turn the current request/apply/failure/success path into explicit events:
- `ShaderBuildRequested`
- `ShaderBuildPrepared`
- `ShaderBuildFailed`
- `ShaderBuildApplied`
- `CompileStatusChanged`
This should preserve the current off-frame-path compile behavior while making readiness visible.
Current note: request, prepared, failed, applied, and compile-status events exist. Generation-aware consumption is covered by tests. Request events are coalesced by build dimensions and preserve-feedback policy in the app dispatcher.
### Step 5. Route Runtime Broadcasts Through Events
Status: partially complete.
Replace direct "broadcast now" decisions with:
- `RuntimeStatePresentationChanged`
- `RuntimeStateBroadcastRequested`
- `RuntimeStateBroadcastCompleted`
- `RuntimeStateBroadcastFailed`
This keeps UI delivery in `ControlServices` while keeping presentation ownership in the runtime presentation layer.
Current note: `RuntimeStateBroadcastRequested` exists, is coalesced by the app dispatcher, and is handled. Broadcast completion/failure events have not been added yet.
### Step 6. Add Event Metrics
Status: mostly complete for dispatcher metrics; broader health-event observation continues.
Before using the event system for hotter paths, add metrics:
- event queue depth
- oldest event age
- event dispatch duration
- coalesced event count
- dropped event count
- handler failure count
These should feed `HealthTelemetry`.
Current note: queue depth, oldest-event age, dispatch duration, dropped count, coalesced count, and handler failure counts feed telemetry. Queue/timing events are also published and coalesced by metric key.
## Dependency Rules
Allowed:
- producers publish events to the bus
- the composition root registers handlers
- handlers call owner subsystem APIs
- `HealthTelemetry` observes event metrics and failures
Avoid:
- subsystems subscribing directly to each other in constructors
- event handlers mutating state outside their owner subsystem
- using one global event payload with many nullable fields
- making render hot paths block on the event bus
- requiring health/telemetry event delivery for core behavior
The dispatcher is coordination infrastructure, not a new domain owner.
## Testing Strategy
Phase 2 should add tests that do not require GL, DeckLink, or network sockets.
Implemented tests:
- FIFO events dispatch in sequence order
- coalesced events keep the latest payload and count collapsed updates
- rejected mutations publish rejection events without downstream snapshot/build events
- accepted parameter mutations publish the expected follow-up event set
- handler failures are reported as health/log events
- queue depth and oldest-event-age metrics update predictably
- typed payload mapping covers persistence, render snapshot, backend, timing, queue-depth, and late/dropped output-frame events
- shader build generation matching applies only the expected prepared build
Remaining useful tests before deeper file-watch work:
- file reload bursts collapse into one reload request across a real poll burst
- broadcast completion/failure events are observable once those payloads exist
The existing `RuntimeEventTypeTests` target is now the main pure event behavior harness. `RuntimeEventTestHarness` should remain the shared test helper so future lanes do not invent their own dispatcher plumbing.
## Phase 2 Exit Criteria
Phase 2 can be considered complete once the project can say:
- [x] there is a typed internal event envelope and dispatcher
- [x] `OpenGLComposite` owns the dispatcher as the current composition root
- [x] `ControlServices` emits typed events for OSC commits and broadcast requests
- [x] reload/file-change work publishes typed ingress and follow-up events
- [x] `RuntimeCoordinator` publishes explicit accepted/rejected/follow-up events
- [x] callers no longer need broad compatibility result queues for normal runtime side effects
- [x] `RuntimeUpdateController` handles event-driven broadcast, shader build, compile status, render reset, and health observation paths
- [x] `RuntimeUpdateController` no longer needs compatibility result draining for ordinary service work
- [x] shader build request/readiness/failure/application is represented as events
- [x] shader build requests are coalesced by dimensions and preserve-feedback policy in the app path
- [x] render snapshot publication is represented as request/published events
- [x] render snapshot publish requests are coalesced in the app path where needed
- [x] backend observations publish typed events
- [x] event queues expose depth, age, dropped, coalescing, and failure metrics
- [x] production event paths use coalescing for broadcast requests, shader-build requests, and high-rate metrics
- [x] coarse sleep polling is no longer the default coordination model for OSC commit service work
- [x] runtime-store/file-change compatibility polling is explicitly contained and publishes event-first reload bridge events when changes are detected
Phase 2 closure note:
- The checklist above is complete for the internal event model substrate.
- Broadcast completion/failure events and real file-watch burst tests are useful follow-ups, but they are no longer foundation blockers.
- `RuntimeCoordinatorResult` may remain as a synchronous return type for command APIs; the Phase 2 requirement is that accepted/rejected/follow-up behavior is also published as typed events, which is now true.
## Open Questions For Implementation
- Resolved: the first dispatcher is single-process, app-owned, and pumped through the current app/update path.
- Resolved: event payloads use typed structs carried by `std::variant`.
- Resolved: persistence requests are represented in Phase 2 even though background persistence lands later.
- Resolved: backend callback events are introduced now as observation-only events.
- Still open: should high-rate OSC transient overlay events enter the app dispatcher, or should they remain source-local until the live-state layering phase?
- Resolved for Phase 2: `RuntimeCoordinatorResult` can survive as a synchronous helper for command APIs, as long as event publication remains the coordination path for downstream effects.
- Resolved: app-level coalescing lives inside `RuntimeEventDispatcher`; source-specific bridges can still coalesce before publication when they own useful domain-specific collapse policy.
## Short Version
Phase 2 should give the app a typed nervous system.
- external inputs become typed events
- owner subsystems still make decisions
- decisions publish explicit outcomes
- follow-up work is routed by handlers, not inferred from scattered call paths
- high-rate work is bounded or coalesced
- timing and queue pressure become observable
If this boundary holds, later render-thread, persistence, backend, and telemetry work can move independently without returning to shared-object polling as the default coordination model.

View File

@@ -1,383 +0,0 @@
# Phase 3 Design: Live State And Service Coordination
This document expands Phase 3 of [ARCHITECTURE_RESILIENCE_REVIEW.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/ARCHITECTURE_RESILIENCE_REVIEW.md) into a concrete design target.
Phase 1 split runtime responsibilities into named subsystems. Phase 2 added the typed internal event model those subsystems can coordinate through. Phase 3 should now finish the service-facing and live-state cleanup needed before the app attempts sole-owner GL rendering.
## Status
- Phase 3 design package: accepted.
- Phase 3 implementation: exit criteria satisfied for the current architecture.
- Current alignment: the repo now has the live-state/composer building blocks, a service bridge, and a named frame-state handoff. `OpenGLComposite::renderEffect()` still remains the app-level frame entrypoint, but the service drain, layer-state resolution, and OSC commit handoff now sit behind named helpers and frame-state data.
Current footholds:
- `RuntimeStore` is split into durable state collaborators: `RuntimeConfigStore`, `LayerStackStore`, `ShaderPackageCatalog`, `RenderSnapshotBuilder`, presentation read models, and `HealthTelemetry`.
- `RuntimeCoordinator` owns mutation validation/classification and publishes accepted/rejected/follow-up events.
- `RuntimeSnapshotProvider` publishes render snapshots from `RenderSnapshotBuilder`.
- `RuntimeLiveState` owns transient OSC overlay bookkeeping and commit-settlement policy.
- `RenderStateComposer` exists as the first pure composition boundary for combining base layer state with live overlays.
- `RenderFrameInput` / `RenderFrameState` now provide a named frame-facing handoff model for preparing layer state and render inputs before drawing.
- `RenderFrameStateResolver` now owns snapshot cache selection, parameter refresh decisions, and final frame-state resolution before drawing.
- `RenderEngine` owns GL/render resources and delegates frame-state preparation to the resolver.
- `ControlServices` owns OSC ingress, pending OSC updates, completed OSC commit notifications, and service start/stop.
- `RuntimeServiceLiveBridge` translates service OSC queues into render live-state updates and queues settled overlay commit requests.
- `RuntimeEventDispatcher` now routes accepted mutations, reloads, snapshots, shader build events, backend observations, and health observations.
The current architecture is much better than the original `RuntimeHost` shape. Phase 4 has since moved normal runtime GL work onto the `RenderEngine` render thread, so the remaining render-facing risk is no longer shared context ownership; it is the later producer/consumer playout work needed to keep DeckLink callbacks from synchronously waiting on output production.
## Why Phase 3 Exists
The resilience review says render-thread isolation should come after state access and control coordination are no longer centered on a large mutable runtime object. Phase 2 gives us the event substrate; Phase 3 should make the data flowing into render explicit enough that Phase 4 can make the render thread the sole GL owner without dragging service coordination and state reconciliation with it.
The main problems Phase 3 addressed:
- transient OSC overlay state and persisted committed state needed a named reconciliation boundary
- `RenderEngine` needed to move final frame-state selection and value composition out of drawing code
- service-side queues for pending OSC updates and completed OSC commits needed a bridge outside `OpenGLComposite`
- Phase 6 has since moved runtime-state persistence requests onto a debounced background writer
- `RuntimeUpdateController` still exists partly as compatibility glue between synchronous coordinator results and event-driven effects
## Goals
Phase 3 should establish:
- an explicit live-state model separating persisted state, committed runtime state, and transient automation overlay
- service-facing event bridges for OSC overlay updates and overlay commit completions
- a narrower `OpenGLComposite::renderEffect()` that renders a prepared read model instead of orchestrating runtime/service state
- a clear owner for final render-layer state resolution before it reaches GL drawing
- a contained persistence request model that later Phase 6 connected to the background writer
- tests for live-state composition, overlay settlement, and service-to-runtime event behavior without GL or DeckLink
## Non-Goals
Phase 3 should not require:
- a dedicated render thread
- moving all GL calls off the current callback path
- a background persistence writer implementation
- a final DeckLink lifecycle state machine
- replacing every direct synchronous command API
- a final cue/preset/timeline system
Those are later phases. Phase 3 is about making state and service coordination clean enough for those later phases.
## Current Coordination Shape
`OpenGLComposite::renderEffect()` is now the app-level frame entrypoint, but it is intentionally narrow:
1. pumps `RuntimeUpdateController::ProcessRuntimeWork()`
2. builds a `RenderFrameInput`
3. renders the frame through `RuntimeServiceLiveBridge`, `RenderFrameStateResolver`, and `RenderEngine`
The bridge now owns service queue draining, live automation settlement, committed/live state selection, and OSC commit handoff. `RenderFrameStateResolver` owns snapshot cache selection, parameter refresh decisions, and dynamic render-field refresh before handing a prepared frame state to `RenderEngine`.
## Target State Model
Phase 3 should formalize three state categories:
| State category | Owner | Lifetime | Render role |
| --- | --- | --- | --- |
| Persisted layer state | `LayerStackStore` behind `RuntimeStore` | saved durable state | base layer stack and saved parameter values |
| Committed runtime state | `RuntimeCoordinator` / snapshot publication | accepted operator/UI/OSC commits | stable render snapshot selected for rendering |
| Transient automation overlay | new live-state collaborator or narrowed render-side owner | high-rate OSC automation between commits | temporary per-route override blended into final values |
Render should eventually consume:
```text
final render state = published snapshot + committed live selection + transient overlay
```
The important change is not the exact formula name. The important change is that final render-state composition has one named owner and can be tested without GL.
## Phase 3 Collaborators
### `RuntimeLiveState`
Small runtime collaborator for transient automation state.
Responsibilities:
- keep transient OSC overlay values keyed by route
- track overlay generation and pending commit generation
- apply overlay commit completions
- decide when an overlay value has settled enough to request a commit
- build a `LiveStateOverlaySnapshot` for final render-state composition
Non-responsibilities:
- persistent state mutation
- shader package lookup
- GL resources
- OSC socket ownership
### `RenderStateComposer`
Pure or mostly pure collaborator for frame value composition.
Responsibilities:
- combine published render snapshots with live overlay state
- apply smoothing/time-based automation policy
- return final `RuntimeRenderState` values plus any commit requests
- stay testable without OpenGL
Non-responsibilities:
- drawing
- service queue draining
- disk persistence
- OSC packet parsing
### `RuntimeServiceLiveBridge`
`RuntimeServiceLiveBridge` is the current source-local bridge between services, live state, and render-state preparation.
Responsibilities:
- translate service-side OSC ingress into typed events or live-state commands
- publish overlay applied/settled events where useful
- route overlay commit requests to `RuntimeCoordinator`
- keep `OpenGLComposite` out of service queue draining
Non-responsibilities:
- final GL rendering
- persistent store mutation outside coordinator APIs
## Event Bridge Targets
| Current flow | Phase 3 bridge target | Notes |
| --- | --- | --- |
| pending OSC updates drained by `OpenGLComposite` | `OscValueReceived` -> live-state overlay update handler | Phase 2 already has the event type; Phase 3 decides whether transient overlay updates enter the app dispatcher or a source-local bridge. |
| render asks for overlay commit requests | `OscOverlaySettled` or direct coordinator command plus event publication | Commit request creation should leave `renderEffect()` and live near the live-state owner. |
| completed OSC commits drained by `OpenGLComposite` | `RuntimeMutationAccepted` / completion event -> live-state commit completion | Completed commit routing should be event-driven or owned by live-state service bridge. |
| `RenderFrameStateResolver::Resolve(...)` | `RenderStateComposer::BuildFrameState(...)` | Keep final state composition testable without GL. |
| direct persistence writes from store mutations | `RuntimePersistenceRequested` as the durable write trigger | Phase 6 later connected this request boundary to the background writer. |
| runtime-state broadcast side effects | `RuntimeStateBroadcastRequested` plus optional completed/failed observations | Keep broadcast delivery in services and presentation ownership in runtime presentation. |
## Runtime Store Scope In Phase 3
`RuntimeStore` is already much smaller than the original host, but Phase 3 should keep narrowing it toward durable state and read-model publishing.
Target responsibilities:
- initialize runtime config and persistent state
- expose durable layer/package/config read models
- own saved layer stack and preset serialization while exposing snapshots for the background writer
- publish or support immutable render/presentation snapshots
Avoid adding:
- transient OSC overlay state
- frame-local render composition decisions
- service queue coordination
- background worker policy
## Runtime Coordinator Scope In Phase 3
`RuntimeCoordinator` should remain the command/mutation policy owner.
Keep:
- validation/classification
- accepted/rejected mutation publication
- reload/build/persistence follow-up events
- synchronous command results for UI/API callers that need immediate success or error
Narrow:
- any behavior that looks like render-frame state composition
- any direct service queue interpretation
- any persistence timing policy beyond publishing `RuntimePersistenceRequested`
## Render Engine Scope In Phase 3
`RenderEngine` should move closer to being a GL/render-local owner.
Keep:
- GL resources
- shader programs
- render passes
- preview/output rendering
- temporal history and feedback resources
Move or narrow:
- transient OSC overlay bookkeeping
- final layer-state composition from snapshot plus overlay
- creation of commit requests from smoothed overlay values
Some transient render-only state may remain in `RenderEngine` if it truly belongs to GL or temporal resources. But value composition should be separable from drawing.
## OpenGLComposite Scope In Phase 3
`OpenGLComposite` should remain the current composition root, but not the runtime-service coordinator.
Target:
- wire collaborators
- own app-level lifecycle
- initialize GL/backend/runtime services
- call narrow render/update entrypoints
Avoid:
- draining OSC queues directly
- converting service DTOs into render DTOs
- deciding final layer-state composition
- coordinating commit completion settlement
## Persistence Position
Phase 3 did not implement the background writer, but it prepared the request boundary that Phase 6 now uses.
Target behavior by Phase 3 exit:
- state mutations publish `RuntimePersistenceRequested`
- persistence can be observed and tested as an event side effect
- disk writes are not inferred by callers; later Phase 6 routes accepted durable mutations through `RuntimePersistenceRequested` and the background writer
- callers outside the store/coordinator should not infer disk writes from mutation categories
This kept Phase 6 smaller: the background snapshot writer consumes persistence requests and stored-state snapshots rather than rediscovering mutation policy.
## Migration Plan
### Step 1. Name The Live State Boundary
Introduce `RuntimeLiveState`, `RenderStateComposer`, or an equivalent pair of classes.
Start by moving pure data operations out of frame rendering without changing behavior.
Status: complete for Phase 3. `runtime/live/RuntimeLiveState` and `runtime/live/RenderStateComposer` exist, are included in the build, and have a focused `RuntimeLiveStateTests` target.
### Step 2. Move OSC Overlay Bookkeeping Behind The Boundary
Move these responsibilities out of the current frame orchestration:
- overlay updates by route
- commit completion tracking
- generation matching
- settle/commit request creation
The first implementation can still be called synchronously from the current render path. The important part is that the behavior has a named owner and tests.
Status: complete for Phase 3. `RenderEngine` still exposes compatibility methods used by the service bridge, but it delegates overlay updates, commit completions, smoothing, generation matching, and commit-request creation to `RuntimeLiveState`/`RenderStateComposer`.
### Step 3. Bridge Service Queues To Events Or Live-State Commands
Replace `OpenGLComposite::renderEffect()` queue draining with a bridge that publishes or applies:
- `OscValueReceived`
- `OscOverlayApplied`
- `OscOverlaySettled`
- overlay commit completion observations
This is where the remaining Phase 2 open question about transient OSC overlay event scope should be resolved for the current architecture.
Status: complete for Phase 3. `RuntimeServiceLiveBridge` now drains pending OSC updates and completed OSC commits, applies them to render live state, and queues settled commit requests. It remains a source-local bridge by design until later live-state layering decides whether transient automation should enter the app-level dispatcher.
### Step 4. Narrow `OpenGLComposite::renderEffect()`
Target shape:
```cpp
void OpenGLComposite::renderEffect()
{
mRuntimeUpdateController->ProcessRuntimeWork();
const RenderFrameInput frameInput = BuildRenderFrameInput();
RenderFrame(frameInput);
}
```
The exact names can change. The goal is that render effect no longer manually drains services, settles overlay commits, and resolves layer values.
Status: complete for Phase 3. `OpenGLComposite::renderEffect()` now processes runtime work, builds `RenderFrameInput`, and calls a narrow frame-render helper. Service draining, state resolution, and commit handoff sit behind `RuntimeServiceLiveBridge::PrepareLiveRenderFrameState(...)`, `RenderFrameStateResolver`, and `RenderFrameState`.
### Step 5. Add Persistence Boundary Tests
Add behavior tests for:
- accepted persisted mutations publish `RuntimePersistenceRequested`
- transient OSC commits do not force immediate persistence
- preset load/save persistence requests remain explicit
- rejected mutations do not publish persistence work
Status: complete for Phase 3. `RuntimeSubsystemTests` and `RuntimeEventTypeTests` cover accepted mutation persistence requests, rejected mutations, and transient OSC overlay behavior that does not request persistence.
### Step 6. Update Docs And Phase 4 Readiness
Before calling Phase 3 complete, update:
- subsystem docs for new live-state/composer collaborators
- architecture review checklist
- Phase 4 assumptions about render thread input state
Status: complete. The Phase 4 design note started from the `RenderFrameInput` / `RenderFrameState` contract and has now completed the shared-GL ownership migration.
## Testing Strategy
Phase 3 tests should avoid GL, DeckLink, and sockets.
Recommended tests:
- final layer-state composition applies snapshot values when no overlay exists
- transient overlay overrides the matching parameter by route
- smoothing moves toward target values over time
- overlay settle creates one commit request per route/generation
- completed commits clear pending overlay commit state
- stale commit completions are ignored by generation
- accepted mutations publish persistence requests where expected
- rejected mutations do not publish persistence or render follow-ups
- `OpenGLComposite` no longer needs to drain service result queues for runtime effects
Existing useful homes:
- `RuntimeSubsystemTests` for pure state/composer behavior
- `RuntimeEventTypeTests` for event bridge behavior
- `RuntimeLiveStateTests` for the new live-state/composer boundary
## Parallel Work Lanes
The current groundwork is intended to let these lanes proceed in parallel with low overlap:
| Lane | Primary files | Goal |
| --- | --- | --- |
| A. Live-state behavior | `runtime/live/RuntimeLiveState.*`, `tests/RuntimeLiveStateTests.cpp` | Implemented for Phase 3: stale completion, smoothing, trigger behavior, and overlay settle policy are covered by focused tests. |
| B. Render-state composition | `runtime/live/RenderStateComposer.*`, `gl/frame/RenderFrameStateResolver.*`, `gl/RenderEngine.*` | Implemented for Phase 3: value composition and frame-state selection sit outside GL drawing while GL calls remain in `RenderEngine`. |
| C. Service bridge | `control/RuntimeServices.*`, `control/RuntimeServiceLiveBridge.*`, `control/ControlServices.*` | Implemented for Phase 3: `OpenGLComposite::renderEffect()` no longer drains OSC update/completion queues directly. |
| D. App-frame orchestration | `gl/composite/OpenGLComposite.*`, `gl/frame/RuntimeUpdateController.*` | Implemented for Phase 3: render-effect glue is a narrow runtime-work, frame-input, render-frame sequence. |
| E. Persistence boundary | `runtime/coordination/RuntimeCoordinator.*`, `runtime/store/*`, event tests | Implemented for Phase 3: persistence request publication is explicit. Phase 6 later wired those requests to the background writer. |
## Phase 3 Exit Criteria
Phase 3 can be considered complete once the project can say:
- [x] final render-state composition has named owners outside `OpenGLComposite` (`RenderStateComposer` covers live value composition; `RenderFrameStateResolver` covers snapshot/cache selection and frame-state resolution)
- [x] transient OSC overlay state has a named owner and tests
- [x] overlay commit requests and completions no longer require `OpenGLComposite` to drain service queues directly
- [x] `RenderEngine` is closer to GL/render resource ownership and less responsible for value composition
- [x] `RuntimeStore` remains durable-state focused and does not gain live overlay responsibilities
- [x] persistence requests are explicit event outcomes for persisted mutations
- [x] Phase 4 can define a render-thread input contract around immutable or near-immutable frame state
## Open Questions
- Should transient OSC overlay values enter the app-level event dispatcher, or should they use a dedicated source-local latest-value bridge until live-state layering is finalized?
- Should the new live-state owner live under `runtime/`, `gl/`, or a new `renderstate/` boundary?
- Should smoothing policy be owned by live state, render-state composition, or render settings?
- Should overlay commit completion be represented as a new typed event, or derived from existing accepted mutation events with route/generation metadata?
- Should preset save remain synchronous after Phase 6, or eventually move behind a completion-based async request?
## Short Version
Phase 3 should make the app's live state boring and explicit.
- persisted state stays in the store
- accepted command policy stays in the coordinator
- transient automation gets a named owner
- final render-state composition becomes testable without GL
- `OpenGLComposite` stops manually reconciling service queues and layer values
Once that is true, Phase 4 can make the render thread the sole GL owner without also having to invent a clean state model at the same time.

View File

@@ -1,408 +0,0 @@
# Phase 4 Design: Render Thread Ownership
This document expands Phase 4 of [ARCHITECTURE_RESILIENCE_REVIEW.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/ARCHITECTURE_RESILIENCE_REVIEW.md) into a concrete design target.
Phase 1 named the subsystems. Phase 2 added the typed event substrate. Phase 3 made render-facing live state explicit through `RuntimeLiveState`, `RenderStateComposer`, `RenderFrameInput`, `RenderFrameState`, `RenderFrameStateResolver`, and `RuntimeServiceLiveBridge`. Phase 4 can now focus on the core timing-risk boundary: making one render thread the only owner of OpenGL work.
## Status
- Phase 4 design package: implemented.
- Phase 4 implementation: complete for GL ownership. `RenderEngine` starts a dedicated render thread, owns the GL context during normal runtime work, and exposes queue/request entrypoints for input upload, output render, preview presentation, screenshot capture, shader rebuild application, and render-local resets.
- Current alignment: normal runtime GL work is routed through the render thread after startup. Startup initialization still runs before the render thread starts while the app explicitly owns the context, and shutdown now stops DeckLink/backend work before destroying render-thread GL resources and deleting the context.
Current GL ownership footholds:
- `RenderEngine` owns GL resources, a dedicated render thread, synchronous request/response for output frames, a small render command mailbox, named render-thread helper methods, and wrong-thread diagnostics for those helpers.
- `RenderFrameInput` / `RenderFrameState` provide the frame-state contract that a render thread can consume.
- `RenderFrameStateResolver` prepares the render-facing layer state before drawing.
- `OpenGLVideoIOBridge` calls `RenderEngine::QueueInputFrame(...)` from the input path and `RenderEngine::RequestOutputFrame(...)` from the output path.
- `OpenGLComposite::paintGL(...)`, screenshot capture, input upload, and output rendering enter render work through explicit `RenderEngine` requests. After `OpenGLComposite::Start()` starts the render thread, those requests do not bind the GL context on the caller thread.
## Why Phase 4 Exists
The resilience review identifies shared GL ownership as the main remaining timing and failure-isolation risk. Today the shared context lock protects correctness, but it does not isolate timing:
- input callbacks can attempt texture upload
- output callbacks can trigger frame rendering and readback
- preview paint can enter the same GL context
- screenshot capture can enter the same GL context
- the DeckLink completion path is still too close to render work
That means brief input, preview, readback, or callback stalls can still collide on the most timing-sensitive path.
Phase 4 should turn GL from a shared resource guarded by a lock into a resource owned by one thread with explicit queues and handoff points.
## Goals
Phase 4 should establish:
- one render thread as the sole long-lived owner of the GL context
- non-render threads enqueue work instead of binding the GL context
- input upload requests are accepted and executed by the render thread
- output frame rendering is requested or scheduled through render-owned work
- preview and screenshot requests become render-thread commands or consumers
- `RenderFrameInput` / `RenderFrameState` become the stable data contract for frame production
- GL context entrypoints are reduced to render-thread-only code paths
- tests for queue semantics and request coalescing without requiring DeckLink hardware, plus explicit lifecycle ordering in code
## Non-Goals
Phase 4 should not require:
- the final producer/consumer playout queue for DeckLink
- the final DeckLink lifecycle state machine
- replacing the async readback policy
- implementing background persistence
- completing Phase 5's deeper live-state layering
- replacing every UI or backend API at once
Those are later phases or follow-on work. Phase 4 is about making GL ownership deterministic first.
## Current GL Entry Points
The current code paths that matter most are:
| Entry point | Current behavior | Phase 4 direction |
| --- | --- | --- |
| `RenderEngine::QueueInputFrame(...)` | copies the latest input frame into the render mailbox and returns without waiting for GL | render thread uploads latest input without callback-owned GL |
| `RenderEngine::RequestOutputFrame(...)` | synchronous output request; after render-thread startup it queues output render work and waits for render-thread completion with timeout/failure reporting | render thread executes output frame production |
| `RenderEngine::TryPresentPreview(...)` | best-effort request; callers queue preview presentation and return | render thread consumes latest completed frame for preview |
| `RenderEngine::RequestScreenshotCapture(...)` | queues screenshot capture and async disk write completion | screenshot capture is a render-thread command |
| `OpenGLVideoIOBridge::UploadInputFrame(...)` | copies the latest input frame into the render mailbox and returns without waiting for GL | render thread uploads the latest queued input frame |
| `OpenGLVideoIOBridge::RenderScheduledFrame(...)` | requests render-thread output production and reports success/failure to the backend | consume render-produced output without callback-owned GL |
## Target Ownership Model
### Render Thread
The render thread should own:
- `wglMakeCurrent(...)` for the rendering context
- all GL resource creation/destruction
- input texture upload
- pass execution
- output pack conversion
- async readback buffers and fences
- preview presentation or preview frame publication
- screenshot readback
- temporal history and feedback resources
### Other Threads
Other threads may:
- enqueue input frames or replace the latest input frame
- publish control/runtime/backend events
- request shader build application
- request render-local resets
- request screenshots
- consume ready output frames or receive completion notifications
Other threads should not:
- call GL directly
- bind or unbind the render context
- wait on GL fences directly
- mutate render-local resource state
## Proposed Collaborators
### `RenderThread`
Owns the OS thread, wakeup primitive, lifecycle, and render-loop execution.
Responsibilities:
- start and stop the render thread
- bind the GL context for the thread lifetime or render-loop lifetime
- drain render commands
- execute frame production work
- publish lifecycle and failure observations
Non-responsibilities:
- runtime mutation policy
- DeckLink scheduling policy
- durable persistence
### `RenderCommandQueue`
Small bounded queue or command mailbox for render-thread work.
Current implementation:
- `RenderCommandQueue` exists as a pure C++ mailbox helper.
- Preview present and screenshot capture requests use latest-value coalescing.
- Input upload requests use latest-value coalescing with owned frame bytes copied at enqueue time.
- Output frame requests use FIFO semantics so scheduled output demand is not collapsed.
- Render-local reset requests coalesce to the strongest pending reset scope.
- Output frame requests use synchronous request/response through the render thread as the remaining transitional playout bridge.
Possible commands:
- `UploadInputFrame`
- `RenderOutputFrame`
- `PrepareFrameState`
- `ApplyShaderBuild`
- `ResetTemporalHistory`
- `ResetShaderFeedback`
- `PresentPreview`
- `CaptureScreenshot`
- `Stop`
High-rate commands should be coalesced where appropriate. Input frames should likely be latest-value rather than unbounded FIFO.
### `RenderFrameCoordinator`
Optional helper that combines Phase 3's frame contract with render-thread execution.
Responsibilities:
- build or receive `RenderFrameInput`
- call `RuntimeServiceLiveBridge` and `RenderFrameStateResolver`
- hand `RenderFrameState` to `RenderEngine`
This can begin as a thin helper. The important part is that it keeps frame-state preparation explicit when `renderEffect()` stops being called directly from the callback path.
### `RenderOutputMailbox`
Optional transitional bridge for output frames.
Responsibilities:
- hold the latest completed output frame or a small bounded set
- let backend code consume output without owning GL
- report underrun/stale-frame reuse observations
This may be a Phase 4 late step or a Phase 7 playout-policy step. Phase 4 should at least avoid designing the render thread in a way that blocks it.
## Threading Contract
Phase 4 should make thread ownership visible in APIs.
Candidate naming:
- `RenderEngine::StartRenderThread(...)`
- `RenderEngine::StopRenderThread()`
- `RenderEngine::EnqueueInputFrame(...)`
- `RenderEngine::RequestOutputFrame(...)`
- `RenderEngine::RequestPreviewPresent(...)`
- `RenderEngine::RequestScreenshot(...)`
Render-thread-only methods should be private or clearly named:
- `RenderEngine::UploadInputFrameOnRenderThread(...)`
- `RenderEngine::RenderOutputFrameOnRenderThread(...)`
- `RenderEngine::CaptureOutputFrameRgbaTopDownOnRenderThread(...)`
The public runtime entrypoints now use queue/request language. `RequestOutputFrame(...)` remains synchronous so the existing DeckLink callback path can keep producing an output frame while Phase 7's producer/consumer playout queue is still future work.
## Frame Production Shape
A target render-thread frame should look like:
1. wake for input, output demand, preview demand, shader build, reset, screenshot, or stop
2. drain bounded render commands
3. coalesce to the latest input frame and latest control/live state
4. build `RenderFrameInput`
5. prepare `RenderFrameState`
6. upload accepted input frame
7. render layer stack
8. pack output if needed
9. stage readback or output buffer
10. publish preview/screenshot/output completion as needed
11. record timing and queue metrics
The exact cadence can remain demand-driven initially. The architectural win is that the demand wakes the render thread rather than borrowing GL from the caller.
## Migration Plan
### Step 1. Name Render-Thread-Only Methods
Split existing direct GL methods into public request methods and private render-thread methods without changing behavior much.
Initial target:
- [x] keep current synchronous behavior where callers need a result
- [x] move GL bodies into clearly render-thread-owned helpers for upload, output render, preview presentation, and screenshot readback
- [x] make future queue migration mechanical
### Step 2. Add Render Command Queue
Introduce a small queue/mailbox for render commands.
Start with low-risk commands:
- [x] preview present request
- [x] screenshot request
- [x] render-local reset requests
- [x] input upload request
- [x] output render request
The queue and wakeup behavior still need the dedicated render thread before the callbacks stop borrowing the GL context.
### Step 3. Start A Dedicated Render Thread
Create the render thread and make it own context binding.
- [x] create a dedicated render thread owned by `RenderEngine`
- [x] bind the existing GL context on the render thread for normal runtime work
- [x] stop the render thread before GL context destruction
- [x] keep transitional synchronous request/response for output frames
- [x] remove normal runtime dependence on the shared GL `CRITICAL_SECTION`
- [x] add timeout/failure behavior for render-thread requests
Transitional behavior still allows synchronous request/response for output frames. Render-thread requests now fail fast if they cannot begin within the request timeout, and log over-budget tasks that have already started before waiting for safe completion. The important change is that the caller waits for render-thread completion rather than taking the GL context itself.
### Step 4. Move Input Upload To The Render Thread
Change `OpenGLVideoIOBridge::UploadInputFrame(...)` so it enqueues or replaces the latest input frame.
Policy targets:
- [x] bounded memory
- [x] latest-frame wins under load
- [x] input upload skip count is observable through render command coalescing metrics
- [x] input callback never waits for GL
Current implementation: `OpenGLVideoIOBridge::UploadInputFrame(...)` calls `RenderEngine::QueueInputFrame(...)`, which copies the input bytes into the latest-value render mailbox and schedules one bounded render-thread wakeup to upload the newest pending frame.
### Step 5. Move Output Rendering To The Render Thread
Change `OpenGLVideoIOBridge::RenderScheduledFrame(...)` so it requests render-thread output production or consumes a completed render-thread output.
Transitional option:
- [x] synchronous request/response through the render thread
Better follow-up:
- render ahead into a bounded output queue and let backend callbacks consume ready frames
Current implementation: `OpenGLVideoIOBridge::RenderScheduledFrame(...)` calls `RenderEngine::RequestOutputFrame(...)` and returns whether the render-thread request produced an output frame. `VideoBackend` skips scheduling that frame when render production fails or times out.
### Step 6. Decouple Preview And Screenshot Requests
Preview should become best-effort:
- [x] request preview presentation from the render thread
- [x] skip/coalesce when render is busy or output deadline pressure is high
- [x] record preview skips through render command coalescing metrics
Screenshot should become:
- [x] queued render-thread capture request
- [x] async disk write remains outside render thread
Current implementation: `OpenGLComposite::RequestScreenshot(...)` builds the output path, queues `RenderEngine::RequestScreenshotCapture(...)`, and the render thread captures pixels before handing them to the existing async PNG writer. Preview presentation is a latest-value best-effort render command that is queued behind output render work, even when requested from the render pipeline.
### Step 7. Remove Shared GL Lock From Normal Paths
Once all GL entrypoints are render-thread-owned:
- [x] remove normal dependence on `pMutex` for render correctness
- [x] keep diagnostics that detect wrong-thread render-thread helper calls
- [x] leave only lifecycle context binding where needed
Current implementation: `OpenGLComposite` no longer owns or passes a shared `CRITICAL_SECTION`, and `RenderEngine` no longer has caller-thread GL fallback paths for preview, input upload, output render, or screenshot capture. Runtime callers must go through the render thread; pre-start direct GL fallback is limited to startup initialization while the app explicitly owns the context.
### Shutdown Order
Current shutdown order is explicit in code:
1. `OpenGLComposite::Stop()` stops runtime services so control/OSC work stops entering the runtime.
2. `VideoBackend::Stop()` stops DeckLink streams/playout so input and output callbacks stop requesting render work.
3. `RenderEngine::StopRenderThread()` destroys GL resources on the render thread, signals the render thread to stop, joins it, and unbinds the context on render-thread exit.
4. `WM_DESTROY` deletes `OpenGLComposite`, unbinds the window context, and deletes the GL context.
This order is build-tested, and `RenderCommandQueue` behavior is covered by non-GL unit tests. It still benefits from a real-window/DeckLink shutdown smoke test, but the code path is explicit enough for Phase 4's design exit.
## Testing Strategy
Phase 4 tests should avoid hardware where possible.
Recommended tests:
- render command queue preserves FIFO for non-coalesced commands
- latest-input mailbox drops older frames under load
- shutdown path stops backend callbacks before stopping and joining the render thread
- screenshot request receives one completion or failure
- output render request reports failure if render thread is stopped
- render reset commands coalesce where expected
- wrong-thread render-only diagnostics are present on private render-thread helpers
Existing useful homes:
- `RuntimeEventTypeTests` for new render/backend observations
- `RuntimeSubsystemTests` for pure request/coalescing helpers
- a future `RenderThreadTests` target if render-thread lifecycle is extracted behind a non-GL test seam
Manual verification will still be needed for:
- real DeckLink input/output
- preview interaction
- screenshot capture
- shader reload while rendering
- real window/context shutdown
## Telemetry Added During Phase 4
Phase 4 should add minimal metrics while moving ownership:
- render command queue depth
- input frames accepted, replaced, and dropped
- render-thread wake reason counts
- render-thread frame duration
- output request latency
- preview request skipped count
- screenshot request success/failure count
- wrong-thread GL call diagnostics if practical
Full operational reporting remains Phase 8, but these metrics make the threading migration debuggable.
## Risks
### Deadlock Risk
Synchronous request/response shims can deadlock if the caller is already on the render thread or holds a lock the render thread needs. Phase 4 should keep request waits narrow and add render-thread detection early.
### Latency Risk
Moving work through queues can hide latency. Queue depth and output request latency should be measured from the first migration step.
### Lifetime Risk
Moving context ownership changes startup and shutdown order. The render thread must stop before GL resources or window/context handles are destroyed.
### Callback Pressure Risk
If DeckLink callbacks wait too long for render-thread work, Phase 4 may improve GL ownership but still leave callback timing fragile. A synchronous bridge is acceptable as a transition, but the design should keep the path open for producer/consumer playout.
### Preview Coupling Risk
Preview can remain a hidden budget consumer if it stays in the output frame path. Phase 4 should keep preview explicitly best-effort, even if physical decoupling continues later.
## Phase 4 Exit Criteria
Phase 4 can be considered complete once the project can say:
- [x] one render thread owns the GL context during normal operation
- [x] input callbacks do not bind GL or wait on GL upload
- [x] output callbacks do not bind GL directly
- [x] preview and screenshot requests enter render through explicit render-thread requests
- [x] `RenderFrameInput` / `RenderFrameState` remain the frame-state contract
- [x] normal frame production no longer depends on a shared GL `CRITICAL_SECTION`
- [x] render-thread queue/mailbox behavior has non-GL tests
- [x] shutdown order is explicit and tested or manually verified
## Open Questions
- What exact producer/consumer output queue shape should replace the current synchronous output request in Phase 7?
- Should preview present on the render thread, or should render publish a preview image/texture to a separate presenter?
- Should wrong-thread GL access eventually escalate from debug diagnostics to structured telemetry or assertions?
## Short Version
Phase 4 should make GL ownership boring and deterministic.
One render thread owns the context. Other threads submit work or consume results. Input upload, frame rendering, readback, preview, and screenshot capture all move behind render-thread entrypoints. Output production remains a synchronous request/response bridge for now, but the app no longer relies on callback and UI paths borrowing the GL context under one shared lock.

View File

@@ -1,416 +0,0 @@
# Phase 5 Design: Live State Layering And Composition
This document expands Phase 5 of [ARCHITECTURE_RESILIENCE_REVIEW.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/ARCHITECTURE_RESILIENCE_REVIEW.md) into a concrete design target.
Phase 1 named the subsystems. Phase 2 added the typed event substrate. Phase 3 made render-facing live state explicit through `RuntimeLiveState`, `RenderStateComposer`, `RenderFrameInput`, `RenderFrameState`, `RenderFrameStateResolver`, and `RuntimeServiceLiveBridge`. Phase 4 made one render thread the owner of normal runtime GL work. Phase 5 should now make the live parameter model itself explicit: persisted truth, operator/session truth, and transient automation should be separate layers with one predictable composition rule.
## Status
- Phase 5 design package: complete.
- Phase 5 implementation: complete.
- Current alignment: Phase 3 introduced the first pure composition boundary and transient OSC overlay owner. Phase 5 now has a small `RuntimeStateLayerModel` inventory that names the current state categories, `RenderStateComposer` consumes a `LayeredRenderStateInput` whose fields make base persisted, committed live, and transient automation inputs explicit, `RuntimeLiveState` owns transient-overlay invalidation against current layer/parameter compatibility, settled OSC commits have an explicit session-only persistence policy, and `CommittedLiveState` physically owns current session layer state. `RuntimeStore` still owns file IO, config, package metadata, preset persistence, and persistence requests.
Current live-state footholds:
- `RuntimeStore` owns file IO, config, package metadata, preset persistence, persistent-state serialization, and persistence requests.
- `CommittedLiveState` physically owns the current committed/session layer stack and parameter values.
- `RuntimeCoordinator` owns mutation validation, classification, accepted/rejected event publication, snapshot/reload follow-ups, and the policy switch between committed states and live snapshots.
- `RuntimeSnapshotProvider` publishes render-facing snapshots from committed runtime state.
- `RuntimeLiveState` owns transient OSC overlay bookkeeping, smoothing, generation tracking, and commit-settlement policy.
- `RenderStateComposer` consumes `LayeredRenderStateInput`, chooses committed-live layer states over base-persisted layer states when both are supplied, applies transient automation on top, and returns final per-frame layer states plus settled commit requests.
- `RuntimeServiceLiveBridge` drains OSC ingress/completion queues and applies them to render live state during frame preparation.
- `RuntimeStateLayerModel` names the Phase 5 state categories and classifies current fields as base persisted, committed live, transient automation, render-local, or health/config state.
- `RuntimeCoordinator` can request layer-scoped transient OSC invalidation, while `RuntimeLiveState` prunes overlays that no longer map to the current render-facing layer/parameter definitions.
- `RuntimeCoordinator::CommitOscParameterByControlKey(...)` commits settled OSC values into session state without requesting persistence by default.
- `CommittedLiveState` owns current committed/session layer state and exposes `CommittedLiveStateReadModel` for render snapshot publication.
## Why Phase 5 Exists
The resilience review identifies live OSC overlay and persisted state as separate concepts that still do not have a fully formal model. The app now has better boundaries, but several policies are still implicit:
- whether a value is durable, committed for the current session, or transient automation
- whether an OSC value should merely influence the current frame or eventually commit
- what reload, preset load, layer removal, shader change, and reset should do to transient values
- which layer wins when UI/operator changes race with OSC automation
- which state changes should publish snapshots, request persistence, or only affect render frames
Without a formal layering model, these rules can leak across `RuntimeStore`, `RuntimeCoordinator`, `RuntimeLiveState`, `RenderStateComposer`, and service bridges. Phase 5 should make those rules boring and testable.
## Goals
Phase 5 should establish:
- explicit state layers for persisted, committed/session, and transient automation values
- one named composition contract for final render values
- clear ownership for layer-specific mutation policy
- explicit reset/reload/preset behavior for transient and committed state
- a clean path for OSC automation to remain high-rate without becoming durable state by accident
- tests for layer precedence, lifecycle, invalidation, and commit policy without GL or DeckLink
- documentation that distinguishes render-local temporal/feedback state from parameter/live-state overlays
## Non-Goals
Phase 5 should not require:
- a background persistence writer implementation
- a DeckLink producer/consumer playout queue
- a full cue/timeline/preset performance system
- a new UI state-management framework
- replacing every synchronous coordinator API
- moving temporal history or shader feedback into the runtime state model
Those are later phases or separate feature work. Phase 5 is about parameter and live-value layering.
## Target State Model
Phase 5 should formalize three layers:
| Layer | Owner | Lifetime | Persistence | Render role |
| --- | --- | --- | --- | --- |
| Base persisted state | `RuntimeStore` plus durable serialization/preset IO | survives restart | written to disk | default saved layer stack, shader selections, saved parameter values |
| Committed live state | `CommittedLiveState` with policy owned by `RuntimeCoordinator` | current running session | may request persistence depending on mutation type | operator/UI/current truth until changed again |
| Transient automation overlay | `RuntimeLiveState` or a new automation overlay collaborator | high-rate/short-lived | not persisted directly | temporary OSC/automation target applied over committed truth |
The target composition rule is:
```text
final render state = base persisted state + committed live state + transient automation overlay
```
The actual implementation may continue using render snapshots as the base transport. The important part is that each layer has named ownership, documented lifetime, and tested precedence.
## Current Composition Shape
Today, final frame state is prepared through this path:
1. `OpenGLComposite::renderEffect()` processes runtime work.
2. `OpenGLComposite` builds `RenderFrameInput`.
3. `RuntimeServiceLiveBridge` drains OSC updates and completed commits.
4. `RenderEngine` updates `RuntimeLiveState`.
5. `RenderFrameStateResolver` chooses committed states or live snapshot states.
6. `RenderStateComposer` applies transient overlay values.
7. `RenderEngine::RenderPreparedFrame(...)` consumes `RenderFrameState`.
That is a good Phase 3/4 foundation. Phase 5 should make the hidden assumptions in steps 5 and 6 explicit enough that reset/reload/preset and future UI automation behavior are not scattered across those collaborators.
## Proposed Collaborators
### `RuntimeStateLayerModel`
Optional pure model that names the layers and composition metadata.
Responsibilities:
- represent base, committed, and transient layer state inputs
- define precedence and invalidation categories
- expose a pure composition function or input object
- keep GL, services, persistence, and device callbacks out of the model
Non-responsibilities:
- disk IO
- OSC socket handling
- render-thread scheduling
- shader compilation
This may be a small set of structs rather than a large class. The value is in naming the contract.
### `CommittedLiveState`
Runtime/session collaborator for committed current-session state that has moved out of `RuntimeStore` physical ownership.
Responsibilities:
- hold operator/UI committed values that are true for the current session
- distinguish persistence-required commits from session-only commits
- expose a read model for snapshot publication
- provide reset/load behavior separate from durable storage
Non-responsibilities:
- transient OSC smoothing
- disk writes
- GL resources
Phase 5 now uses this physical split. `RuntimeStore` still wraps it for compatibility and persistence IO, but committed values no longer live directly as store fields.
### `AutomationOverlayState`
Possible evolution of `RuntimeLiveState`.
Responsibilities:
- hold transient automation values keyed by route/layer/parameter identity
- track generation, commit-in-flight, and completion
- apply smoothing and settle policy
- decide whether an overlay is render-only, commit-requesting, stale, or invalidated
Non-responsibilities:
- owning committed truth
- persistent state mutation
- snapshot publication
This can start by renaming or narrowing current `RuntimeLiveState` responsibilities rather than replacing it outright.
### `LayeredStateComposer`
Possible evolution of `RenderStateComposer`.
Responsibilities:
- apply the target precedence rule
- produce final `RuntimeRenderState` values for a frame
- return commit requests or overlay observations when policy says a transient value settled
- keep value composition testable without OpenGL
Non-responsibilities:
- frame rendering
- service queue draining
- storage mutation
## Layering Rules
### Precedence
Default precedence should be:
1. base persisted/snapshot value
2. committed live/session value
3. transient automation overlay
The topmost valid layer wins for discrete values. Numeric/vector values may be smoothed by overlay policy before they win.
### Identity
Layering should use stable render-facing identity:
- layer id for persisted structural identity
- layer key/control key for OSC-facing identity
- parameter id for shader-defined identity
- parameter control key for external-control identity
Current policy treats render-facing layer identity plus parameter/control-key compatibility as authoritative. Incompatible transient overlays are pruned before composition, so stale OSC routes do not migrate onto unrelated controls after layer removal, preset load, shader change, or incompatible reload.
### Invalidations
The following should have explicit behavior:
- layer removed: clear committed and transient state for that layer
- layer shader changed: clear or remap parameter overlays according to compatible control keys
- preset loaded: replace base/committed state and clear incompatible transient overlays
- shader reload with same controls: preserve compatible transient overlays where safe
- manual reset parameters: clear committed overrides and transient overlays for that layer
- no input/source changes: should not affect parameter layers
### Commit Policy
Transient automation may:
- remain render-only
- settle and request a committed mutation
- commit without persistence
- commit with persistence only when the control path explicitly requests it
The policy should be explicit per ingress path or parameter category. Phase 5 does not need a full UI for it, but the default behavior should be documented and tested.
## Event And Snapshot Contract
Phase 5 should clarify which changes publish which effects:
| Change | Snapshot publication | Persistence request | Render reset | Runtime event |
| --- | --- | --- | --- | --- |
| persisted layer stack mutation | yes | yes | maybe | accepted mutation + persistence requested |
| operator live parameter change | yes | maybe | no, unless structural | accepted mutation |
| transient OSC overlay update | no committed snapshot by default | no | no | optional overlay observation |
| overlay settled commit | yes if accepted | usually no for OSC | no | accepted mutation or overlay-settled observation |
| preset load | yes | maybe | temporal/feedback policy dependent | accepted mutation + reload/reset observations |
| shader change/reload | yes after build | maybe | temporal/feedback policy dependent | shader build/reload events |
This table should evolve with implementation, but Phase 5 should prevent transient overlay updates from masquerading as durable committed state.
## Migration Plan
### Step 1. Inventory Current State Layers
Document and/or encode where each current state category lives:
- persisted layer stack and parameter values
- committed current-session parameter values
- runtime compile/reload flags
- transient OSC overlays
- render-local temporal history and feedback state
Initial target:
- [x] identify which fields are durable, committed-live, transient automation, render-local, or health/config
- [x] update subsystem docs where the current ownership is misleading
- [x] add small tests for classification if a pure helper exists
### Step 2. Name The Layered Composition Input
Introduce a named composition input model around the previous `RenderStateCompositionInput`.
Initial target:
- [x] make base/committed/transient inputs visible in type names or field names
- [x] keep `RenderStateComposer` behavior unchanged at first
- [x] add tests that assert precedence with no GL
Possible outcomes:
- [x] add a new `LayeredRenderStateInput`
- [x] no adapter was needed; callers now use the layered input shape directly
### Step 3. Make Reset And Reload Policy Explicit
Move reset/reload transient-state decisions into one policy point.
Initial target:
- [x] layer removal clears matching transient overlays
- [x] shader change clears incompatible overlays
- [x] preset load clears incompatible overlays
- [x] shader reload can preserve compatible overlays when requested
- [x] temporal/feedback resets stay render-local and separate from parameter overlays
This is where Phase 5 should prevent "clear everything" and "preserve everything" from being scattered through unrelated code.
Current implementation:
- `RuntimeCoordinatorResult` carries a named `RuntimeCoordinatorTransientOscInvalidation` request rather than a raw clear-all flag.
- `RuntimeUpdateController` applies layer-scoped invalidation to both render-owned overlay state and queued OSC service state.
- `RuntimeLiveState::PruneIncompatibleOverlays(...)` is the central compatibility policy for current render-facing layer/parameter definitions.
- `RuntimeLiveState::ApplyToLayerStates(...)` prunes incompatible overlays before applying transient values, so shader changes, preset loads, and layer removals stop carrying stale overlays once the current frame state no longer maps them.
### Step 4. Clarify OSC Commit Semantics
Make the transient-to-committed path explicit.
Initial target:
- [x] document and test whether settled OSC commits persist
- [x] ensure stale generation completions are ignored
- [x] ensure one settled route does not clear unrelated overlay state
- [x] publish or preserve useful events for accepted overlay commits
Current Phase 3 behavior is a good base; Phase 5 should make the policy easier to reason about from the code.
Current policy:
- settled OSC commits are `RuntimeCoordinatorOscCommitPersistence::SessionOnly` by default
- accepted settled OSC commits update the committed session value through `RuntimeStore::SetStoredParameterValue(..., persistState = false, ...)`
- accepted settled OSC commits publish runtime mutation/state-change observations, but no `RuntimePersistenceRequested` event
- accepted service-side commit completions publish `OscOverlaySettled`
- stale generation completions are ignored by `RuntimeLiveState::ApplyOscCommitCompletions(...)`
- unrelated routes remain untouched when a different route settles or completes
### Step 5. Separate Committed-Live Concept From Durable Storage
Separate the committed-live concept from durable storage with both a physical owner and a read/model boundary.
Earlier conservative option:
- [x] add a named committed-live read model
- [x] keep persistence decisions in `RuntimeCoordinator`
Stronger option:
- [x] introduce `CommittedLiveState`
- [x] make `RuntimeSnapshotProvider` consume committed live state through a read model
- [x] leave durable writes in `RuntimeStore`
The implementation now has the stronger split while keeping `RuntimeStore` as the compatibility facade for existing callers.
Current implementation:
- `CommittedLiveState` physically owns the current committed/session layer stack.
- `CommittedLiveStateReadModel` carries the current committed/session layer stack and shader package metadata used by snapshot publication.
- `RenderSnapshotReadModel` contains `committedLiveState` rather than exposing layer-stack fields directly.
- `RenderSnapshotBuilder` builds render snapshots and parameter refreshes from committed-live read APIs.
- `RuntimeStore` still owns config, package metadata, disk IO, preset files, and persistent-state serialization, but delegates current-session layer mutations to `CommittedLiveState`.
### Step 6. Update Docs And Exit Criteria
Before calling Phase 5 complete, update:
- [x] architecture review checklist
- [x] `RuntimeCoordinator`, `RuntimeStore`, `RuntimeSnapshotProvider`, `RenderEngine`, and `ControlServices` subsystem docs
- [x] Phase 6 assumptions about persistence inputs
- [x] Phase 7 assumptions about what render/backend state is not part of live parameter layering
## Testing Strategy
Phase 5 tests should avoid GL, DeckLink, sockets, and filesystem writes where possible.
Recommended tests:
- base value is used when no committed or transient value exists
- committed value overrides base value
- transient overlay overrides committed value
- numeric smoothing applies only to transient overlay values
- trigger/bool/discrete overlay behavior is explicit
- layer removal clears matching transient state
- shader change preserves only compatible overlays if policy allows
- preset load clears or replaces committed/transient state according to policy
- settled OSC overlay creates the expected commit request
- settled OSC commit does not request persistence unless policy says so
- stale commit completion does not clear a newer overlay
- render-local temporal/feedback resets do not mutate parameter layers
Existing useful homes:
- `RuntimeLiveStateTests` for overlay generation, smoothing, settle, and invalidation behavior
- `RuntimeSubsystemTests` for coordinator mutation, persistence request, and reset/reload policy
- `RuntimeEventTypeTests` for any new observations or accepted mutation events
- a possible new `RuntimeStateLayeringTests` target if the composition model gets a pure helper
## Risks
### Over-Abstraction Risk
It would be easy to introduce too many state containers. Phase 5 should add names where they clarify behavior, not create an elaborate framework.
### Persistence Confusion Risk
Committed live state and persisted state are related but not identical. If Phase 5 blurs them, Phase 6's background persistence writer will inherit ambiguous inputs.
### Automation Surprise Risk
OSC automation can be high-rate and transient, but users may expect settled values to become "real." The commit policy needs to be explicit enough that UI, OSC, presets, and reloads behave predictably.
### Identity/Compatibility Risk
Shader changes and preset loads can invalidate layer/parameter identities. Phase 5 should prefer conservative clearing over accidental application of an old automation value to the wrong control.
### Render Coupling Risk
Render-local resources such as temporal history, feedback buffers, readback caches, and playout queues are not parameter layers. Keeping them out of this model avoids turning Phase 5 into a render-resource refactor.
## Phase 5 Exit Criteria
Phase 5 can be considered complete once the project can say:
- [x] persisted, committed-live, and transient automation layers are named in code or clear read models
- [x] final render-value precedence is explicit and covered by tests
- [x] `RenderStateComposer` or its replacement consumes a layered input contract
- [x] reset/reload/preset behavior for transient overlays is centralized or clearly delegated
- [x] OSC overlay settle/commit behavior is explicit, including persistence policy
- [x] `RuntimeStore` remains durable-state focused and does not absorb transient automation policy
- [x] render-local temporal/feedback state remains separate from live parameter layering
- [x] subsystem docs and the architecture review reflect the final ownership model
## Open Questions
- Should transient OSC overlay updates become app-level typed events, or stay source-local through `RuntimeServiceLiveBridge`?
- Should overlay commit persistence be global, ingress-specific, or parameter-definition-driven?
- What compatibility rule should apply when shader reload preserves a control key but changes parameter shape?
- Should preset load clear all transient automation, or only automation that no longer maps to the loaded stack?
- Should UI slider drags use the committed-live layer directly, or a short-lived transient layer that commits on release?
## Short Version
Phase 5 should make live values boring and explicit.
Persisted state is durable truth. Committed live state is current-session/operator truth. Transient automation is high-rate overlay truth. Render consumes the composed result, and each layer has clear ownership, lifetime, persistence behavior, and reset/reload rules.

Some files were not shown because too many files have changed in this diff Show More