From ea9f608f557164823f8b8989b50d291505d6569c Mon Sep 17 00:00:00 2001 From: Aiden Date: Sun, 3 May 2026 11:48:49 +1000 Subject: [PATCH] Update contracts --- .gitea/workflows/ci.yml | 42 +- .gitignore | 1 + CMakeLists.txt | 29 ++ README.md | 34 ++ SHADER_CONTRACT.md | 423 +++++++++++++++--- .../RuntimeHost.cpp | 18 +- 6 files changed, 483 insertions(+), 64 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5a04484..6e93355 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: ui-ubuntu: name: React UI Build - runs-on: ubuntu-latest + runs-on: nubuntu-latest steps: - name: Checkout @@ -45,3 +45,43 @@ jobs: - name: Build UI working-directory: ui run: npm run build + + package-windows: + name: Windows Release Package + runs-on: windows-latest + needs: + - native-windows + - ui-ubuntu + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build UI + shell: powershell + working-directory: ui + run: | + npm ci --no-audit --no-fund + npm run build + + - name: Configure Release + shell: powershell + run: cmake --preset vs2022-x64-release + + - name: Build Release + shell: powershell + run: cmake --build --preset build-release + + - name: Install Runtime Package + shell: powershell + run: cmake --install build/vs2022-x64-release --config Release --prefix dist/VideoShader + + - name: Zip Runtime Package + shell: powershell + run: Compress-Archive -Path dist/VideoShader/* -DestinationPath dist/VideoShader.zip -Force + + - name: Upload Runtime Package + uses: actions/upload-artifact@v4 + with: + name: VideoShader-windows-release + path: dist/VideoShader.zip diff --git a/.gitignore b/.gitignore index 65820ab..271cdc6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Build output /build/ +/dist/ /out/ /.vs/ /apps/*/x64/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 4412920..7c57b5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,4 +132,33 @@ add_custom_command(TARGET LoopThroughWithOpenGLCompositing POST_BUILD "$/dvp.dll" ) +install(TARGETS LoopThroughWithOpenGLCompositing + RUNTIME DESTINATION "." +) + +install(FILES "${GPUDIRECT_DIR}/bin/x64/dvp.dll" + DESTINATION "." +) + +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/config/" + DESTINATION "config" +) + +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/shaders/" + DESTINATION "shaders" +) + +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/runtime/templates/" + DESTINATION "runtime/templates" +) + +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/runtime/README.md" + DESTINATION "runtime" +) + +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/ui/dist/" + DESTINATION "ui/dist" + OPTIONAL +) + source_group(TREE "${APP_DIR}" FILES ${APP_SOURCES}) diff --git a/README.md b/README.md index 7435e3e..af3f7cd 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,40 @@ npm run build The native app serves `ui/dist` when it exists, otherwise it falls back to the source UI directory during development. +## Package + +Build the UI, build the native Release target, then install into a portable runtime folder: + +```powershell +cd ui +npm ci +npm run build +cd .. +cmake --preset vs2022-x64-release +cmake --build --preset build-release +cmake --install build/vs2022-x64-release --config Release --prefix dist/VideoShader +``` + +The package folder will contain: + +```text +dist/VideoShader/ + LoopThroughWithOpenGLCompositing.exe + dvp.dll + config/ + shaders/ + ui/dist/ + runtime/templates/ +``` + +You can run `LoopThroughWithOpenGLCompositing.exe` directly from that folder. In packaged mode, the app resolves `config/`, `shaders/`, `ui/dist/`, and `runtime/templates/` relative to the exe folder. In development mode, it still falls back to repo-root discovery. + +Create a zip for distribution: + +```powershell +Compress-Archive -Path dist/VideoShader/* -DestinationPath dist/VideoShader.zip -Force +``` + ## Tests Run native tests: diff --git a/SHADER_CONTRACT.md b/SHADER_CONTRACT.md index 32c096a..fd15464 100644 --- a/SHADER_CONTRACT.md +++ b/SHADER_CONTRACT.md @@ -1,34 +1,294 @@ # Shader Package Contract -Each shader package lives under `shaders//` and includes: +This document explains how to create shaders for the Video Shader runtime. -- `shader.json` -- `shader.slang` +Each shader is a small package under `shaders//`: -## Manifest fields +```text +shaders/my-effect/ + shader.json + shader.slang + optional-texture.png +``` -`shader.json` defines: +The runtime reads `shader.json`, generates a Slang wrapper from `runtime/templates/shader_wrapper.slang.in`, includes your `shader.slang`, compiles the result to GLSL, and exposes the shader in the local control UI. + +## Quick Start + +Create a folder: + +```text +shaders/my-effect/ +``` + +Add `shader.json`: + +```json +{ + "id": "my-effect", + "name": "My Effect", + "description": "A simple starter shader.", + "category": "Custom", + "entryPoint": "shadeVideo", + "parameters": [ + { + "id": "strength", + "label": "Strength", + "type": "float", + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.01 + } + ] +} +``` + +Add `shader.slang`: + +```slang +float4 shadeVideo(ShaderContext context) +{ + float4 color = context.sourceColor; + color.rgb = lerp(color.rgb, 1.0 - color.rgb, strength); + return saturate(color); +} +``` + +With `autoReload` enabled in `config/runtime-host.json`, edits to shader source, manifests, and declared texture assets are picked up automatically. + +## Manifest Fields + +`shader.json` is the runtime-facing description of the shader. + +Required fields: + +- `id`: package ID used by state/presets. Hyphenated names are OK here, for example `my-effect`. +- `name`: display name in the UI. +- `parameters`: array of exposed controls. Use `[]` if there are no user parameters. + +Optional fields: + +- `description`: display/help text for the shader library. +- `category`: UI grouping label. +- `entryPoint`: Slang function to call. Defaults to `shadeVideo`. +- `textures`: texture assets to load and expose as samplers. +- `temporal`: history-buffer requirements. + +Shader-visible identifiers must be valid Slang-style identifiers: -- `id` -- `name` -- `description` -- `category` - `entryPoint` -- `parameters` -- optional `textures` -- optional `temporal` +- parameter `id` +- texture `id` -Supported parameter types: +Use letters, numbers, and underscores only, and start with a letter or underscore. For example, `logoTexture` is valid; `logo-texture` is not valid as a shader-visible texture ID. -- `float` -- `vec2` -- `color` -- `bool` -- `enum` +## Slang Entry Point -## Texture assets +Your shader file must implement the manifest `entryPoint`. -Shaders can optionally declare texture assets: +Default: + +```slang +float4 shadeVideo(ShaderContext context) +{ + return context.sourceColor; +} +``` + +The runtime owns the real fragment shader entry point. Your function is called from the wrapper, and the runtime handles final bypass/mix behavior: + +```slang +return lerp(context.sourceColor, effectedColor, mixValue); +``` + +That means: + +- Return the fully effected color from your function. +- Respect alpha if your shader produces an overlay or sprite. +- The runtime will blend your result with the source according to `mixAmount` and bypass state. + +## ShaderContext + +Your entry point receives: + +```slang +struct ShaderContext +{ + float2 uv; + float4 sourceColor; + float2 inputResolution; + float2 outputResolution; + float time; + float frameCount; + float mixAmount; + float bypass; + int sourceHistoryLength; + int temporalHistoryLength; +}; +``` + +Fields: + +- `uv`: normalized texture coordinates, usually `0..1`. +- `sourceColor`: decoded RGBA source video at `uv`. +- `inputResolution`: decoded input video resolution in pixels. +- `outputResolution`: output/render resolution in pixels. +- `time`: elapsed runtime time in seconds. +- `frameCount`: incrementing frame counter. +- `mixAmount`: runtime mix amount. +- `bypass`: `1.0` when the layer is bypassed, otherwise `0.0`. +- `sourceHistoryLength`: number of usable source-history frames currently available. +- `temporalHistoryLength`: number of usable temporal frames currently available for this layer. + +## Helper Functions + +The wrapper provides: + +```slang +float4 sampleVideo(float2 uv); +float4 sampleSourceHistory(int framesAgo, float2 uv); +float4 sampleTemporalHistory(int framesAgo, float2 uv); +``` + +`sampleVideo` samples the live decoded source video. + +`sampleSourceHistory` samples previous decoded source frames. `framesAgo` is clamped into the available range. If no history is available, it falls back to `sampleVideo`. + +`sampleTemporalHistory` samples previous pre-layer input frames for temporal shaders that request `preLayerInput` history. `framesAgo` is clamped into the available range. If no temporal history is available, it falls back to `sampleVideo`. + +Example: + +```slang +float4 shadeVideo(ShaderContext context) +{ + float4 previous = sampleSourceHistory(1, context.uv); + return lerp(context.sourceColor, previous, 0.35); +} +``` + +## Parameters + +Manifest parameters are exposed to Slang as global values with the same `id`. + +Supported types: + +| Manifest type | Slang type | JSON value | +| --- | --- | --- | +| `float` | `float` | number | +| `vec2` | `float2` | `[x, y]` | +| `color` | `float4` | `[r, g, b, a]` | +| `bool` | `bool` | `true` or `false` | +| `enum` | `int` | selected option index | + +Float example: + +```json +{ + "id": "brightness", + "label": "Brightness", + "type": "float", + "default": 1.0, + "min": 0.0, + "max": 2.0, + "step": 0.01 +} +``` + +```slang +color.rgb *= brightness; +``` + +Vector example: + +```json +{ + "id": "offset", + "label": "Offset", + "type": "vec2", + "default": [0.0, 0.0], + "min": [-0.2, -0.2], + "max": [0.2, 0.2], + "step": [0.001, 0.001] +} +``` + +```slang +float2 uv = clamp(context.uv + offset, float2(0.0), float2(1.0)); +``` + +Color example: + +```json +{ + "id": "tint", + "label": "Tint", + "type": "color", + "default": [1.0, 1.0, 1.0, 1.0] +} +``` + +```slang +color *= tint; +``` + +Boolean example: + +```json +{ + "id": "invert", + "label": "Invert", + "type": "bool", + "default": false +} +``` + +```slang +if (invert) + color.rgb = 1.0 - color.rgb; +``` + +Enum example: + +```json +{ + "id": "mode", + "label": "Mode", + "type": "enum", + "default": "normal", + "options": [ + { "value": "normal", "label": "Normal" }, + { "value": "luma", "label": "Luma" }, + { "value": "posterize", "label": "Posterize" } + ] +} +``` + +Enums are stored in presets/state by their string `value`, but exposed to Slang as a zero-based integer index in option order: + +```slang +if (mode == 1) +{ + float luma = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); + color.rgb = float3(luma); +} +else if (mode == 2) +{ + color.rgb = floor(color.rgb * 4.0) / 4.0; +} +``` + +Parameter validation: + +- Float values are clamped to `min`/`max` if provided. +- `vec2` must have exactly 2 numbers. +- `color` must have exactly 4 numbers. +- Enum defaults must match one of the declared option values. +- Non-finite numeric values are rejected. + +## Texture Assets + +Declare texture assets in the manifest: ```json { @@ -41,74 +301,119 @@ Shaders can optionally declare texture assets: } ``` -- `id` becomes a shader-visible sampler name -- `path` is resolved relative to the shader package directory -- texture asset changes trigger shader reload just like shader and manifest edits +Rules: -## Temporal manifests +- `id` must be a valid shader identifier. +- `path` is relative to the shader package directory. +- The file must exist when the manifest is loaded. +- Texture asset changes trigger shader reload. -Shaders can optionally declare temporal history needs: +Texture IDs become `Sampler2D` globals: + +```slang +float4 logo = logoTexture.Sample(logoUv); +``` + +For sprite or overlay shaders, return premultiplied-looking output if you want clean composition: + +```slang +float alpha = logo.a; +return float4(logo.rgb * alpha, alpha); +``` + +See `shaders/dvd-bounce/` for a complete texture-driven example. + +## Temporal Shaders + +Temporal shaders can request access to previous frames. + +Manifest example: ```json { "temporal": { "enabled": true, - "historySource": "source", - "historyLength": 4 + "historySource": "preLayerInput", + "historyLength": 12 } } ``` -Supported temporal history sources: +Supported `historySource` values: -- `source` - decoded source-video history from previous frames -- `preLayerInput` - history of the input arriving at that layer before the shader runs +- `source`: decoded source-video history from previous frames. +- `preLayerInput`: history of the input arriving at this layer before the shader runs. -`historyLength` is requested by the shader and clamped by `config/runtime-host.json` via `maxTemporalHistoryFrames`. +`historyLength` is the requested frame count. The runtime clamps it by `maxTemporalHistoryFrames` in `config/runtime-host.json`. -Temporal history resets automatically when: +Temporal history resets when: - layers are added, removed, or reordered - a layer bypass state changes -- a layer changes to a different shader +- a layer changes shader - a shader is reloaded or recompiled +- render dimensions change -## Slang contract - -The runtime owns the fragment entry point, the UYVY-to-RGBA decode pass, and final mix/bypass behavior. - -Your `shader.slang` file implements: +Use the available history lengths to avoid assuming history is ready on the first frame: ```slang float4 shadeVideo(ShaderContext context) { - return context.sourceColor; + if (context.temporalHistoryLength <= 0) + return context.sourceColor; + + float4 oldFrame = sampleTemporalHistory(3, context.uv); + return lerp(context.sourceColor, oldFrame, 0.4); } ``` -Available built-ins through `ShaderContext`: +See `shaders/temporal-ghost-trail/` and `shaders/temporal-low-fps/` for examples. -- `uv` -- `sourceColor` - the already-decoded full-resolution RGBA video color at `uv` -- `inputResolution` -- `outputResolution` -- `time` -- `frameCount` -- `mixAmount` -- `bypass` -- `sourceHistoryLength` -- `temporalHistoryLength` +## Coordinate And Color Notes -Manifest parameters are exposed to the shader as globals named by their `id`. +- `uv` is normalized. +- Use `context.outputResolution` for pixel-sized effects. +- Use `context.inputResolution` when sampling source video by input pixel size. +- `sourceColor` and `sampleVideo` return RGBA values in normalized `0..1` range. +- Prefer `saturate(color)` or explicit `clamp` before returning if your math can overshoot. -Helper function: - -- `sampleVideo(float2 uv)` returns decoded full-resolution RGBA video from the live DeckLink input. -- `sampleSourceHistory(int framesAgo, float2 uv)` samples the most recent available source history frame, clamping to the oldest available frame if needed. -- `sampleTemporalHistory(int framesAgo, float2 uv)` samples the most recent available pre-layer history frame for temporal shaders, clamping to the oldest available frame if needed. - -Declared texture assets are exposed as `Sampler2D` globals using the texture `id`, for example: +Pixel-size example: ```slang -float4 logo = logoTexture.Sample(uv); +float2 pixel = 1.0 / max(context.outputResolution, float2(1.0)); +float4 right = sampleVideo(context.uv + float2(pixel.x, 0.0)); ``` + +## Reload And Generated Files + +When a shader compiles, the runtime writes generated files under `runtime/shader_cache/`: + +- `active_shader_wrapper.slang` +- `active_shader.raw.frag` +- `active_shader.frag` + +These files are ignored by git and are useful for debugging compiler output. If a shader fails to compile, inspect the wrapper first; it shows the exact generated Slang code including your included shader. + +## Common Pitfalls + +- Do not use hyphens in parameter IDs, texture IDs, or entry point names. +- Do not declare your own `ShaderContext`, `GlobalParams`, `sampleVideo`, `sampleSourceHistory`, or `sampleTemporalHistory`. +- Do not write a `[shader("fragment")]` entry point in `shader.slang`; the runtime provides it. +- Remember enum globals are integer indexes, not strings. +- Declare every texture in `shader.json`; undeclared texture samplers will not be bound. +- Keep temporal history requests modest. They consume texture units and memory and are capped by runtime config. +- If a parameter appears in the UI but not in Slang, the shader may still compile, but the control has no effect. +- If a Slang name collides with a generated global, rename your parameter or local symbol. + +## Minimal Package Checklist + +Before committing a new shader package: + +- `shader.json` is valid JSON. +- `id` is unique across `shaders/`. +- `entryPoint`, parameter IDs, and texture IDs are valid identifiers. +- `shader.slang` implements the configured entry point. +- Texture files referenced by `textures` exist. +- Enum defaults are present in their `options`. +- Temporal shaders handle short or empty history gracefully. +- The app can reload and compile the shader without errors. diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp index 375d195..c0fb0a3 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp @@ -48,6 +48,19 @@ std::vector JsonArrayToNumbers(const JsonValue& value) return numbers; } +bool LooksLikePackagedRuntimeRoot(const std::filesystem::path& candidate) +{ + return std::filesystem::exists(candidate / "config" / "runtime-host.json") && + std::filesystem::exists(candidate / "runtime" / "templates" / "shader_wrapper.slang.in") && + std::filesystem::exists(candidate / "shaders"); +} + +bool LooksLikeRepoRoot(const std::filesystem::path& candidate) +{ + return std::filesystem::exists(candidate / "CMakeLists.txt") && + std::filesystem::exists(candidate / "apps" / "LoopThroughWithOpenGLCompositing"); +} + std::filesystem::path FindRepoRootCandidate() { std::vector rootsToTry; @@ -66,11 +79,8 @@ std::filesystem::path FindRepoRootCandidate() std::filesystem::path candidate = startPath; for (int depth = 0; depth < 10 && !candidate.empty(); ++depth) { - if (std::filesystem::exists(candidate / "CMakeLists.txt") && - std::filesystem::exists(candidate / "apps" / "LoopThroughWithOpenGLCompositing")) - { + if (LooksLikePackagedRuntimeRoot(candidate) || LooksLikeRepoRoot(candidate)) return candidate; - } candidate = candidate.parent_path(); }