Added clock time
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 1m32s
CI / Windows Release Package (push) Failing after 2m7s

This commit is contained in:
2026-05-06 12:38:23 +10:00
parent d2cf852eb2
commit 414ef62479
17 changed files with 335 additions and 18 deletions

View File

@@ -18,6 +18,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Verify Visual Studio ATL
shell: powershell
run: |
$atlHeaders = @(Get-ChildItem -Path "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC" -Filter atlbase.h -Recurse -ErrorAction SilentlyContinue)
if ($atlHeaders.Count -eq 0) {
Write-Error "Visual Studio Build Tools is missing ATL. Install the 'C++ ATL for latest v143 build tools (x86 & x64)' component, component ID Microsoft.VisualStudio.Component.VC.ATL, then restart the runner service."
exit 1
}
Write-Host "Found ATL header: $($atlHeaders[0].FullName)"
- name: Configure Debug
shell: powershell
run: |
@@ -92,6 +102,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Verify Visual Studio ATL
shell: powershell
run: |
$atlHeaders = @(Get-ChildItem -Path "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC" -Filter atlbase.h -Recurse -ErrorAction SilentlyContinue)
if ($atlHeaders.Count -eq 0) {
Write-Error "Visual Studio Build Tools is missing ATL. Install the 'C++ ATL for latest v143 build tools (x86 & x64)' component, component ID Microsoft.VisualStudio.Component.VC.ATL, then restart the runner service."
exit 1
}
Write-Host "Found ATL header: $($atlHeaders[0].FullName)"
- name: Build UI
shell: powershell
working-directory: ui

View File

@@ -91,6 +91,8 @@ set(APP_SOURCES
"${APP_DIR}/resource.h"
"${APP_DIR}/runtime/RuntimeHost.cpp"
"${APP_DIR}/runtime/RuntimeHost.h"
"${APP_DIR}/runtime/RuntimeClock.cpp"
"${APP_DIR}/runtime/RuntimeClock.h"
"${APP_DIR}/runtime/RuntimeJson.cpp"
"${APP_DIR}/runtime/RuntimeJson.h"
"${APP_DIR}/runtime/RuntimeParameterUtils.cpp"
@@ -158,6 +160,22 @@ endif()
enable_testing()
add_test(NAME RuntimeJsonTests COMMAND RuntimeJsonTests)
add_executable(RuntimeClockTests
"${APP_DIR}/runtime/RuntimeClock.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeClockTests.cpp"
)
target_include_directories(RuntimeClockTests PRIVATE
"${APP_DIR}"
"${APP_DIR}/runtime"
)
if(MSVC)
target_compile_options(RuntimeClockTests PRIVATE /W3)
endif()
add_test(NAME RuntimeClockTests COMMAND RuntimeClockTests)
add_executable(RuntimeParameterUtilsTests
"${APP_DIR}/runtime/RuntimeJson.cpp"
"${APP_DIR}/runtime/RuntimeParameterUtils.cpp"

View File

@@ -232,6 +232,8 @@ The Windows jobs validate native third-party dependencies before configuring CMa
- `GPUDIRECT_DIR`: path to `Blackmagic DeckLink SDK 16.0/Win/Samples/NVIDIA_GPUDirect`.
- `SLANG_ROOT`: path to the Slang binary release folder containing `bin/slangc.exe`.
The Windows runner also needs the Visual Studio ATL component installed. In Visual Studio Build Tools 2022, add `C++ ATL for latest v143 build tools (x86 & x64)`, component ID `Microsoft.VisualStudio.Component.VC.ATL`.
Example runner paths:
```text
@@ -249,8 +251,7 @@ If neither variable is set, the workflow falls back to the repo-local defaults u
- Genlock.
- Find a better UI library.
- Logs.
- Continue source cleanup/refactoring.
- Continue source cleanup/refactoring. Pass 1 done
- Display the control URL in the Windows app, ideally clickable, without rendering it on the video output.
- Support a separate sound shader `.slang` file in shader packages.
- Add runtime date/time uniforms using UTC and the PC's local offset.
![alt text](image.png)

View File

@@ -122,6 +122,8 @@ struct ShaderContext
float2 inputResolution;
float2 outputResolution;
float time;
float utcTimeSeconds;
float utcOffsetSeconds;
float frameCount;
float mixAmount;
float bypass;
@@ -137,6 +139,8 @@ Fields:
- `inputResolution`: decoded input video resolution in pixels.
- `outputResolution`: shader render resolution in pixels. The current pipeline renders the shader stack at input resolution, then scales the final frame to the configured DeckLink output mode.
- `time`: elapsed runtime time in seconds.
- `utcTimeSeconds`: current UTC time of day from the host PC clock, expressed as seconds since UTC midnight.
- `utcOffsetSeconds`: host PC local UTC offset in seconds. Add this to `utcTimeSeconds` and wrap to `0..86400` to get local time of day.
- `frameCount`: incrementing frame counter.
- `mixAmount`: runtime mix amount.
- `bypass`: `1.0` when the layer is bypassed, otherwise `0.0`.

View File

@@ -209,6 +209,7 @@
<ClCompile Include="DeckLinkAPI_i.c" />
<ClCompile Include="control\RuntimeServices.cpp" />
<ClCompile Include="decklink\DeckLinkSession.cpp" />
<ClCompile Include="runtime\RuntimeClock.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="gl\GLExtensions.h" />
@@ -224,6 +225,7 @@
<ClInclude Include="gl\VideoFrameTransfer.h" />
<ClInclude Include="control\RuntimeServices.h" />
<ClInclude Include="decklink\DeckLinkSession.h" />
<ClInclude Include="runtime\RuntimeClock.h" />
</ItemGroup>
<ItemGroup>
<Image Include="LoopThroughWithOpenGLCompositing.ico" />

View File

@@ -54,6 +54,9 @@
<ClCompile Include="decklink\DeckLinkSession.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="runtime\RuntimeClock.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="gl\GLExtensions.h">
@@ -95,6 +98,9 @@
<ClInclude Include="decklink\DeckLinkSession.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="runtime\RuntimeClock.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<Image Include="LoopThroughWithOpenGLCompositing.ico">

View File

@@ -18,6 +18,8 @@ bool GlobalParamsBuffer::Update(const RuntimeRenderState& state, unsigned availa
AppendStd140Float(buffer, static_cast<float>(state.timeSeconds));
AppendStd140Vec2(buffer, static_cast<float>(state.inputWidth), static_cast<float>(state.inputHeight));
AppendStd140Vec2(buffer, static_cast<float>(state.outputWidth), static_cast<float>(state.outputHeight));
AppendStd140Float(buffer, static_cast<float>(state.utcTimeSeconds));
AppendStd140Float(buffer, static_cast<float>(state.utcOffsetSeconds));
AppendStd140Float(buffer, static_cast<float>(state.frameCount));
AppendStd140Float(buffer, static_cast<float>(state.mixAmount));
AppendStd140Float(buffer, static_cast<float>(state.bypass));

View File

@@ -0,0 +1,45 @@
#include "RuntimeClock.h"
#include <chrono>
namespace
{
bool ToUtcTime(std::time_t time, std::tm& utcTime)
{
return gmtime_s(&utcTime, &time) == 0;
}
bool ToLocalTime(std::time_t time, std::tm& localTime)
{
return localtime_s(&localTime, &time) == 0;
}
}
RuntimeClockSnapshot GetRuntimeClockSnapshot()
{
return MakeRuntimeClockSnapshot(std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()));
}
RuntimeClockSnapshot MakeRuntimeClockSnapshot(std::time_t now)
{
RuntimeClockSnapshot snapshot;
std::tm utcTime = {};
if (!ToUtcTime(now, utcTime))
return snapshot;
snapshot.utcTimeSeconds =
static_cast<double>(utcTime.tm_hour * 3600 + utcTime.tm_min * 60 + utcTime.tm_sec);
std::tm localTime = {};
if (!ToLocalTime(now, localTime))
return snapshot;
utcTime.tm_isdst = localTime.tm_isdst;
const std::time_t localAsTime = std::mktime(&localTime);
const std::time_t utcAsLocalTime = std::mktime(&utcTime);
if (localAsTime != static_cast<std::time_t>(-1) && utcAsLocalTime != static_cast<std::time_t>(-1))
snapshot.utcOffsetSeconds = std::difftime(localAsTime, utcAsLocalTime);
return snapshot;
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include <ctime>
struct RuntimeClockSnapshot
{
double utcTimeSeconds = 0.0;
double utcOffsetSeconds = 0.0;
};
RuntimeClockSnapshot GetRuntimeClockSnapshot();
RuntimeClockSnapshot MakeRuntimeClockSnapshot(std::time_t now);

View File

@@ -1,5 +1,7 @@
#include "stdafx.h"
#include "RuntimeHost.h"
#include "RuntimeClock.h"
#include "RuntimeParameterUtils.h"
#include "ShaderCompiler.h"
#include "ShaderPackageRegistry.h"
@@ -1321,6 +1323,7 @@ bool RuntimeHost::TryGetLayerRenderStates(unsigned outputWidth, unsigned outputH
void RuntimeHost::BuildLayerRenderStatesLocked(unsigned outputWidth, unsigned outputHeight, std::vector<RuntimeRenderState>& states) const
{
const RuntimeClockSnapshot clock = GetRuntimeClockSnapshot();
for (const LayerPersistentState& layer : mPersistentState.layers)
{
auto shaderIt = mPackagesById.find(layer.shaderId);
@@ -1331,6 +1334,8 @@ void RuntimeHost::BuildLayerRenderStatesLocked(unsigned outputWidth, unsigned ou
state.layerId = layer.id;
state.shaderId = layer.shaderId;
state.timeSeconds = std::chrono::duration_cast<std::chrono::duration<double>>(std::chrono::steady_clock::now() - mStartTime).count();
state.utcTimeSeconds = clock.utcTimeSeconds;
state.utcOffsetSeconds = clock.utcOffsetSeconds;
state.frameCount = static_cast<double>(mFrameCounter);
state.mixAmount = 1.0;
state.bypass = layer.bypass ? 1.0 : 0.0;

View File

@@ -102,6 +102,8 @@ struct RuntimeRenderState
std::vector<ShaderTextureAsset> textureAssets;
std::vector<ShaderFontAsset> fontAssets;
double timeSeconds = 0.0;
double utcTimeSeconds = 0.0;
double utcOffsetSeconds = 0.0;
double frameCount = 0.0;
double mixAmount = 1.0;
double bypass = 0.0;

View File

@@ -11,6 +11,8 @@ struct ShaderContext
float2 inputResolution;
float2 outputResolution;
float time;
float utcTimeSeconds;
float utcOffsetSeconds;
float frameCount;
float mixAmount;
float bypass;
@@ -23,6 +25,8 @@ cbuffer GlobalParams
float gTime;
float2 gInputResolution;
float2 gOutputResolution;
float gUtcTimeSeconds;
float gUtcOffsetSeconds;
float gFrameCount;
float gMixAmount;
float gBypass;
@@ -80,6 +84,8 @@ float4 fragmentMain(FragmentInput input) : SV_Target
context.inputResolution = gInputResolution;
context.outputResolution = gOutputResolution;
context.time = gTime;
context.utcTimeSeconds = gUtcTimeSeconds;
context.utcOffsetSeconds = gUtcOffsetSeconds;
context.frameCount = gFrameCount;
context.mixAmount = gMixAmount;
context.bypass = gBypass;

View File

@@ -0,0 +1,36 @@
{
"id": "utc-clock",
"name": "UTC Clock",
"description": "Shows an analog clock driven by the host PC clock. Uses UTC seconds plus the PC UTC offset exposed to shaders.",
"category": "Utility",
"entryPoint": "shadeVideo",
"parameters": [
{
"id": "showLocalTime",
"label": "Show Local Time",
"type": "bool",
"default": true
},
{
"id": "clockScale",
"label": "Clock Scale",
"type": "float",
"default": 0.7,
"min": 0.25,
"max": 0.95,
"step": 0.01
},
{
"id": "faceColor",
"label": "Face Color",
"type": "color",
"default": [0.03, 0.04, 0.05, 0.82]
},
{
"id": "accentColor",
"label": "Accent Color",
"type": "color",
"default": [0.1, 0.62, 0.86, 1.0]
}
]
}

View File

@@ -0,0 +1,75 @@
float secondsOfDay(float seconds)
{
return seconds - floor(seconds / 86400.0) * 86400.0;
}
float lineMask(float2 p, float angle, float innerRadius, float outerRadius, float width)
{
float2 direction = float2(sin(angle), cos(angle));
float along = dot(p, direction);
float across = length(p - direction * along);
float body = smoothstep(width, width * 0.35, across);
float start = smoothstep(innerRadius - width, innerRadius + width, along);
float end = 1.0 - smoothstep(outerRadius - width, outerRadius + width, along);
return body * start * end;
}
float ringMask(float radius, float target, float width)
{
return smoothstep(width, 0.0, abs(radius - target));
}
float tickMask(float2 p, int tick)
{
float angle = (float(tick) / 60.0) * 6.28318530718;
float2 direction = float2(sin(angle), cos(angle));
float along = dot(p, direction);
float across = length(p - direction * along);
float major = (tick % 5) == 0 ? 1.0 : 0.0;
float inner = lerp(0.82, 0.76, major);
float outer = 0.9;
float width = lerp(0.006, 0.011, major);
float body = smoothstep(width, width * 0.35, across);
float start = smoothstep(inner, inner + 0.025, along);
float end = 1.0 - smoothstep(outer - 0.025, outer, along);
return body * start * end;
}
float4 shadeVideo(ShaderContext context)
{
float2 aspect = float2(context.outputResolution.x / max(context.outputResolution.y, 1.0), 1.0);
float2 p = (context.uv * 2.0 - 1.0) * aspect / max(clockScale, 0.01);
float radius = length(p);
float seconds = showLocalTime
? secondsOfDay(context.utcTimeSeconds + context.utcOffsetSeconds)
: secondsOfDay(context.utcTimeSeconds);
float hour = floor(seconds / 3600.0);
float minute = floor((seconds - hour * 3600.0) / 60.0);
float second = seconds - hour * 3600.0 - minute * 60.0;
float secondAngle = second / 60.0 * 6.28318530718;
float minuteAngle = (minute + second / 60.0) / 60.0 * 6.28318530718;
float hour12 = hour - floor(hour / 12.0) * 12.0;
float hourAngle = (hour12 + minute / 60.0) / 12.0 * 6.28318530718;
float4 color = context.sourceColor;
float face = smoothstep(1.0, 0.97, radius);
color = lerp(color, faceColor, face * faceColor.a);
float ring = ringMask(radius, 0.92, 0.01);
float ticks = 0.0;
for (int i = 0; i < 60; ++i)
ticks = max(ticks, tickMask(p, i));
float hourHand = lineMask(p, hourAngle, -0.05, 0.48, 0.028);
float minuteHand = lineMask(p, minuteAngle, -0.07, 0.72, 0.018);
float secondHand = lineMask(p, secondAngle, -0.12, 0.8, 0.009);
float hub = smoothstep(0.07, 0.0, radius);
float whiteDetail = max(max(ring, ticks), max(hourHand, minuteHand));
color.rgb = lerp(color.rgb, float3(0.92, 0.96, 1.0), whiteDetail);
color.rgb = lerp(color.rgb, accentColor.rgb, max(secondHand, hub) * accentColor.a);
return color;
}

View File

@@ -0,0 +1,50 @@
#include "RuntimeClock.h"
#include <cmath>
#include <iostream>
namespace
{
int gFailures = 0;
void Expect(bool condition, const char* message)
{
if (condition)
return;
std::cerr << "FAIL: " << message << "\n";
++gFailures;
}
void TestUtcSecondsOfDay()
{
const RuntimeClockSnapshot midnight = MakeRuntimeClockSnapshot(0);
Expect(midnight.utcTimeSeconds == 0.0, "Unix epoch starts at UTC midnight");
const RuntimeClockSnapshot midday = MakeRuntimeClockSnapshot(12 * 3600 + 34 * 60 + 56);
Expect(midday.utcTimeSeconds == 45296.0, "UTC time of day is seconds since midnight");
}
void TestOffsetLooksLikeTimezoneOffset()
{
const RuntimeClockSnapshot snapshot = MakeRuntimeClockSnapshot(12 * 3600);
Expect(std::fmod(snapshot.utcOffsetSeconds, 60.0) == 0.0, "UTC offset is minute-aligned");
Expect(snapshot.utcOffsetSeconds >= -14.0 * 3600.0 && snapshot.utcOffsetSeconds <= 14.0 * 3600.0,
"UTC offset is in the normal timezone range");
}
}
int main()
{
TestUtcSecondsOfDay();
TestOffsetLooksLikeTimezoneOffset();
if (gFailures != 0)
{
std::cerr << gFailures << " RuntimeClock test failure(s).\n";
return 1;
}
std::cout << "RuntimeClock tests passed.\n";
return 0;
}

View File

@@ -62,6 +62,8 @@ void TestGlobalParamStylePacking()
AppendStd140Float(buffer, 10.0f); // time
AppendStd140Vec2(buffer, 1920.0f, 1080.0f); // input resolution
AppendStd140Vec2(buffer, 1280.0f, 720.0f); // output resolution
AppendStd140Float(buffer, 45296.0f); // UTC time of day
AppendStd140Float(buffer, 36000.0f); // UTC offset
AppendStd140Float(buffer, 42.0f); // frame count
AppendStd140Float(buffer, 0.5f); // mix
AppendStd140Float(buffer, 1.0f); // bypass
@@ -78,8 +80,10 @@ void TestGlobalParamStylePacking()
Expect(ReadFloat(buffer, 0) == 10.0f, "time is at the start of the block");
Expect(ReadFloat(buffer, 8) == 1920.0f, "first vec2 aligns after scalar padding");
Expect(ReadFloat(buffer, 16) == 1280.0f, "second vec2 follows first vec2");
Expect(ReadInt(buffer, 36) == 3, "history length scalar remains tightly packed");
Expect(ReadFloat(buffer, 48) == 4.0f, "vec2 shader parameter aligns to 8 bytes");
Expect(ReadFloat(buffer, 24) == 45296.0f, "UTC time follows output resolution");
Expect(ReadFloat(buffer, 28) == 36000.0f, "UTC offset follows UTC time");
Expect(ReadInt(buffer, 44) == 3, "history length scalar remains tightly packed");
Expect(ReadFloat(buffer, 56) == 4.0f, "vec2 shader parameter aligns to 8 bytes");
Expect(ReadFloat(buffer, 64) == 0.1f, "color parameter aligns to 16 bytes");
Expect(ReadInt(buffer, 80) == 1, "boolean parameter follows vec4");
Expect(ReadInt(buffer, 84) == 2, "enum parameter follows boolean");

View File

@@ -17,6 +17,8 @@
--app-radius-sm: 4px;
--app-space: 1rem;
--app-container: 980px;
--control-height: 42px;
--button-min-width: 7.25rem;
--app-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--app-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--app-bg);
@@ -96,12 +98,11 @@ h4 {
text-transform: uppercase;
}
button,
input[type="number"],
input[type="text"],
select {
width: 100%;
min-height: 38px;
min-height: var(--control-height);
border: 1px solid var(--app-border);
background: var(--app-surface-2);
color: inherit;
@@ -135,6 +136,11 @@ button:focus-visible {
}
button {
width: auto;
min-width: var(--button-min-width);
min-height: var(--control-height);
padding: 0.65rem 1rem;
border: 1px solid transparent;
border-color: transparent;
background: var(--app-primary);
color: #fff;
@@ -201,7 +207,7 @@ pre {
.panel__header button {
width: auto;
min-width: 9rem;
min-width: var(--button-min-width);
}
.dashboard-grid,
@@ -459,7 +465,7 @@ pre {
}
.stack-panel__reload {
min-width: 8.25rem;
min-width: 8.75rem;
}
.toolbar__group {
@@ -469,10 +475,16 @@ pre {
}
.toolbar__inline {
grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.28fr);
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: 0.5rem;
}
.toolbar__inline button {
width: auto;
min-width: var(--button-min-width);
}
.layer-stack {
display: grid;
gap: 0.75rem;
@@ -532,17 +544,21 @@ pre {
.layer-card__actions {
justify-content: flex-end;
align-self: flex-start;
}
.layer-card__actions button,
.layer-card__subheader button {
width: auto;
min-width: 5.25rem;
min-width: var(--button-min-width);
height: var(--control-height);
}
.icon-button {
width: 38px;
min-width: 38px;
width: var(--control-height);
min-width: var(--control-height);
height: var(--control-height);
min-height: var(--control-height);
padding: 0;
display: inline-flex;
align-items: center;
@@ -585,6 +601,7 @@ pre {
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
border-color: transparent;
background: transparent;
@@ -654,6 +671,7 @@ pre {
}
.shader-picker__trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
@@ -713,6 +731,7 @@ pre {
}
.shader-picker__option {
width: 100%;
display: grid;
gap: 0.25rem;
min-height: 4.25rem;
@@ -1015,8 +1034,7 @@ pre {
}
.dashboard-grid,
.stack-panel__grid,
.toolbar__inline {
.stack-panel__grid {
grid-template-columns: 1fr;
}
@@ -1025,10 +1043,17 @@ pre {
}
.panel__header button,
.toolbar__inline button,
.stack-panel__reload,
.layer-card__actions,
.layer-card__actions button,
.layer-card__actions button {
width: auto;
}
.layer-card__actions {
width: auto;
align-self: flex-end;
justify-content: flex-end;
}
.layer-card__field select {
width: 100%;
}
@@ -1049,8 +1074,12 @@ pre {
grid-template-columns: 1fr;
}
.toolbar__inline {
grid-template-columns: minmax(0, 1fr) auto;
}
.parameter__reset {
width: 100%;
width: 24px;
}
.parameter__swatch {