Compare commits
11 Commits
v0.0.1
...
cf31c91831
| Author | SHA1 | Date | |
|---|---|---|---|
| cf31c91831 | |||
| 7e4ab5cbd8 | |||
| 6ce09c0e9c | |||
| 62c3ded1f8 | |||
| 3e8b472f74 | |||
| fd0ebb8d40 | |||
| fcdc5bac6e | |||
| fecc936a14 | |||
| 536f65bf88 | |||
| ce5905373a | |||
| 119e49aec1 |
@@ -8,6 +8,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
set(APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/apps/LoopThroughWithOpenGLCompositing")
|
||||
set(GPUDIRECT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/3rdParty/Blackmagic DeckLink SDK 16.0/Win/Samples/NVIDIA_GPUDirect" CACHE PATH "Path to the NVIDIA_GPUDirect sample directory from the Blackmagic DeckLink SDK")
|
||||
set(SLANG_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/3rdParty/slang-2026.8-windows-x86_64" CACHE PATH "Path to a Slang binary release containing bin/slangc.exe")
|
||||
|
||||
if(NOT EXISTS "${APP_DIR}/LoopThroughWithOpenGLCompositing.cpp")
|
||||
message(FATAL_ERROR "Imported app sources were not found under ${APP_DIR}")
|
||||
@@ -17,6 +18,23 @@ if(NOT EXISTS "${GPUDIRECT_DIR}/lib/x64/dvp.lib")
|
||||
message(FATAL_ERROR "NVIDIA GPUDirect library not found under ${GPUDIRECT_DIR}")
|
||||
endif()
|
||||
|
||||
set(SLANG_RUNTIME_FILES
|
||||
"${SLANG_ROOT}/bin/slangc.exe"
|
||||
"${SLANG_ROOT}/bin/slang-compiler.dll"
|
||||
"${SLANG_ROOT}/bin/slang-glslang.dll"
|
||||
)
|
||||
|
||||
foreach(SLANG_RUNTIME_FILE IN LISTS SLANG_RUNTIME_FILES)
|
||||
if(NOT EXISTS "${SLANG_RUNTIME_FILE}")
|
||||
message(FATAL_ERROR "Required Slang runtime file not found: ${SLANG_RUNTIME_FILE}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(SLANG_LICENSE_FILE "${SLANG_ROOT}/LICENSE")
|
||||
if(NOT EXISTS "${SLANG_LICENSE_FILE}")
|
||||
message(FATAL_ERROR "Slang license file not found: ${SLANG_LICENSE_FILE}")
|
||||
endif()
|
||||
|
||||
set(APP_SOURCES
|
||||
"${APP_DIR}/ControlServer.cpp"
|
||||
"${APP_DIR}/ControlServer.h"
|
||||
@@ -69,6 +87,7 @@ target_link_libraries(LoopThroughWithOpenGLCompositing PRIVATE
|
||||
Ws2_32
|
||||
Crypt32
|
||||
Advapi32
|
||||
Gdiplus
|
||||
)
|
||||
|
||||
target_compile_definitions(LoopThroughWithOpenGLCompositing PRIVATE
|
||||
@@ -161,6 +180,15 @@ install(FILES "${GPUDIRECT_DIR}/bin/x64/dvp.dll"
|
||||
DESTINATION "."
|
||||
)
|
||||
|
||||
install(FILES ${SLANG_RUNTIME_FILES}
|
||||
DESTINATION "3rdParty/slang/bin"
|
||||
)
|
||||
|
||||
install(FILES "${SLANG_LICENSE_FILE}"
|
||||
DESTINATION "third_party_notices"
|
||||
RENAME "SLANG_LICENSE.txt"
|
||||
)
|
||||
|
||||
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/config/"
|
||||
DESTINATION "config"
|
||||
)
|
||||
|
||||
38
README.md
38
README.md
@@ -21,7 +21,7 @@ The app loads shader packages from `shaders/`, compiles Slang to GLSL at runtime
|
||||
- CMake 3.24 or newer.
|
||||
- Node.js and npm for the control UI.
|
||||
- Blackmagic DeckLink SDK 16.0 with the NVIDIA GPUDirect sample files available locally.
|
||||
- Slang compiler available under the repo/tooling paths expected by the runtime, or otherwise discoverable by the existing app setup.
|
||||
- Slang binary release with `slangc.exe`, `slang-compiler.dll`, `slang-glslang.dll`, and `LICENSE`.
|
||||
|
||||
The Blackmagic/GPUDirect SDK should not be committed to this repository. `CMakeLists.txt` exposes `GPUDIRECT_DIR` as a cache path so local machines and CI runners can point at their installed SDK location.
|
||||
|
||||
@@ -37,6 +37,18 @@ Override example:
|
||||
cmake --preset vs2022-x64-debug -DGPUDIRECT_DIR="D:/SDKs/Blackmagic DeckLink SDK 16.0/Win/Samples/NVIDIA_GPUDirect"
|
||||
```
|
||||
|
||||
Default expected Slang path:
|
||||
|
||||
```text
|
||||
3rdParty/slang-2026.8-windows-x86_64
|
||||
```
|
||||
|
||||
Override example:
|
||||
|
||||
```powershell
|
||||
cmake --preset vs2022-x64-debug -DSLANG_ROOT="D:/SDKs/slang-2026.8-windows-x86_64"
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Configure and build the native app:
|
||||
@@ -78,11 +90,15 @@ dist/VideoShader/
|
||||
dvp.dll
|
||||
config/
|
||||
shaders/
|
||||
3rdParty/slang/bin/
|
||||
ui/dist/
|
||||
runtime/templates/
|
||||
third_party_notices/
|
||||
```
|
||||
|
||||
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.
|
||||
You can run `LoopThroughWithOpenGLCompositing.exe` directly from that folder. In packaged mode, the app resolves `config/`, `shaders/`, `3rdParty/slang/bin/slangc.exe`, `ui/dist/`, and `runtime/templates/` relative to the exe folder. In development mode, it still falls back to repo-root discovery.
|
||||
|
||||
The install step copies only the Slang runtime files required by the shader compiler (`slangc.exe`, `slang-compiler.dll`, and `slang-glslang.dll`) plus `third_party_notices/SLANG_LICENSE.txt`. It does not copy the full Slang release folder.
|
||||
|
||||
Create a zip for distribution:
|
||||
|
||||
@@ -187,9 +203,10 @@ Each shader package lives under:
|
||||
shaders/<id>/
|
||||
shader.json
|
||||
shader.slang
|
||||
optional-font-or-texture-assets
|
||||
```
|
||||
|
||||
See `SHADER_CONTRACT.md` for the manifest schema, parameter types, texture assets, temporal history support, and the Slang entry point contract.
|
||||
See `SHADER_CONTRACT.md` for the manifest schema, parameter types, texture assets, font/text assets, temporal history support, and the Slang entry point contract. `shaders/text-overlay/` is the reference live text package and bundles Roboto Regular with its OFL license.
|
||||
|
||||
## Generated Files
|
||||
|
||||
@@ -211,3 +228,18 @@ The Gitea workflow expects two act runners:
|
||||
- `ubuntu-latest`: installs UI dependencies and runs the Vite build.
|
||||
|
||||
If your Windows runner stores the Blackmagic SDK outside the repo, configure `GPUDIRECT_DIR` in the runner environment or adjust the workflow configure command to pass `-DGPUDIRECT_DIR=...`.
|
||||
|
||||
|
||||
## Still todo
|
||||
Audio
|
||||
improve text rendering
|
||||
genlock
|
||||
Logs
|
||||
anamorphic desqueeze
|
||||
solid color layer
|
||||
refactor, cleanup of source files
|
||||
display URL (Maybe clicakable) for control in the windows app (Not on the output)
|
||||
Sound shader as seperate .slang in shader package?
|
||||
runtime date time UTC and offset from PCs internal clock
|
||||
Add a value control to the color wheels
|
||||

|
||||
@@ -73,6 +73,7 @@ Optional fields:
|
||||
- `category`: UI grouping label.
|
||||
- `entryPoint`: Slang function to call. Defaults to `shadeVideo`.
|
||||
- `textures`: texture assets to load and expose as samplers.
|
||||
- `fonts`: packaged font assets for live text parameters.
|
||||
- `temporal`: history-buffer requirements.
|
||||
|
||||
Shader-visible identifiers must be valid Slang-style identifiers:
|
||||
@@ -80,6 +81,7 @@ Shader-visible identifiers must be valid Slang-style identifiers:
|
||||
- `entryPoint`
|
||||
- parameter `id`
|
||||
- texture `id`
|
||||
- font `id`
|
||||
|
||||
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.
|
||||
|
||||
@@ -180,6 +182,7 @@ Supported types:
|
||||
| `color` | `float4` | `[r, g, b, a]` |
|
||||
| `bool` | `bool` | `true` or `false` |
|
||||
| `enum` | `int` | selected option index |
|
||||
| `text` | generated texture/helper | string |
|
||||
|
||||
Float example:
|
||||
|
||||
@@ -278,12 +281,42 @@ else if (mode == 2)
|
||||
}
|
||||
```
|
||||
|
||||
Text example:
|
||||
|
||||
```json
|
||||
{
|
||||
"fonts": [
|
||||
{ "id": "inter", "path": "fonts/Inter-Regular.ttf" }
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"id": "titleText",
|
||||
"label": "Title",
|
||||
"type": "text",
|
||||
"default": "LIVE",
|
||||
"font": "inter",
|
||||
"maxLength": 64
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Text parameters are runtime-owned strings. They are not emitted as uniform values. Instead, the runtime renders the current string into a single-line SDF mask texture and the shader wrapper exposes helpers based on the parameter id:
|
||||
|
||||
```slang
|
||||
float mask = sampleTitleText(textUv);
|
||||
float4 premultipliedText = drawTitleText(textUv, float4(1.0, 1.0, 1.0, 1.0));
|
||||
```
|
||||
|
||||
Text is currently limited to printable ASCII. `maxLength` defaults to `64` and is clamped to `1..256`. The optional `font` field references a packaged font declared in `fonts`; if no font is specified, the runtime uses its fallback sans-serif renderer.
|
||||
|
||||
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.
|
||||
- Text defaults must be strings. Non-printable characters are dropped and values are clamped to `maxLength`.
|
||||
- Non-finite numeric values are rejected.
|
||||
|
||||
## Texture Assets
|
||||
@@ -323,6 +356,31 @@ return float4(logo.rgb * alpha, alpha);
|
||||
|
||||
See `shaders/dvd-bounce/` for a complete texture-driven example.
|
||||
|
||||
## Font Assets
|
||||
|
||||
Declare packaged font assets in the manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"fonts": [
|
||||
{
|
||||
"id": "inter",
|
||||
"path": "fonts/Inter-Regular.ttf"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `id` must be a valid shader identifier.
|
||||
- `path` is relative to the shader package directory.
|
||||
- The file must exist when the manifest is loaded.
|
||||
- Font asset changes trigger shader reload.
|
||||
- V1 text layout is single-line; shaders position and scale the generated text texture themselves.
|
||||
|
||||
See `shaders/text-overlay/` for a complete live text example. The sample bundles Roboto Regular and includes its OFL license beside the font file.
|
||||
|
||||
## Temporal Shaders
|
||||
|
||||
Temporal shaders can request access to previous frames.
|
||||
@@ -401,6 +459,7 @@ These files are ignored by git and are useful for debugging compiler output. If
|
||||
- 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.
|
||||
- Declare packaged fonts in `shader.json` when text parameters should use a specific font.
|
||||
- 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.
|
||||
@@ -414,6 +473,7 @@ Before committing a new shader package:
|
||||
- `entryPoint`, parameter IDs, and texture IDs are valid identifiers.
|
||||
- `shader.slang` implements the configured entry point.
|
||||
- Texture files referenced by `textures` exist.
|
||||
- Font files referenced by `fonts` 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.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr DWORD kStateBroadcastIntervalMs = 250;
|
||||
|
||||
bool InitializeWinsock(std::string& error)
|
||||
{
|
||||
WSADATA wsaData = {};
|
||||
@@ -165,9 +167,18 @@ void ControlServer::BroadcastState()
|
||||
|
||||
void ControlServer::ServerLoop()
|
||||
{
|
||||
DWORD lastStateBroadcastMs = GetTickCount();
|
||||
while (mRunning)
|
||||
{
|
||||
TryAcceptClient();
|
||||
|
||||
const DWORD nowMs = GetTickCount();
|
||||
if (nowMs - lastStateBroadcastMs >= kStateBroadcastIntervalMs)
|
||||
{
|
||||
BroadcastState();
|
||||
lastStateBroadcastMs = nowMs;
|
||||
}
|
||||
|
||||
Sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,11 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||||
wglMakeCurrent( NULL, NULL );
|
||||
if (pOpenGLComposite->Start())
|
||||
break; // success
|
||||
MessageBoxA(NULL, "The OpenGL/DeckLink runtime initialized, but playout failed to start. See the previous DeckLink start message for the failing call.", "Startup failed", MB_OK | MB_ICONERROR);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBoxA(NULL, "The OpenGL/DeckLink runtime failed to initialize. See the previous initialization message for the failing call.", "Startup failed", MB_OK | MB_ICONERROR);
|
||||
}
|
||||
|
||||
// Failed to initialize - cleanup
|
||||
|
||||
@@ -47,7 +47,11 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <gdiplus.h>
|
||||
#include <wincodec.h>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
@@ -64,6 +68,12 @@ constexpr GLuint kSourceHistoryTextureUnitBase = 2;
|
||||
constexpr GLuint kPackedVideoTextureUnit = 2;
|
||||
constexpr GLuint kGlobalParamsBindingPoint = 0;
|
||||
constexpr unsigned kPrerollFrameCount = 8;
|
||||
constexpr unsigned kTextTextureWidth = 2048;
|
||||
constexpr unsigned kTextTextureHeight = 256;
|
||||
constexpr int kTextSdfSpread = 20;
|
||||
constexpr unsigned kTextSdfBlurPasses = 1;
|
||||
constexpr float kTextFontPixelSize = 144.0f;
|
||||
constexpr float kTextLayoutPadding = 48.0f;
|
||||
const char* kVertexShaderSource =
|
||||
"#version 430 core\n"
|
||||
"out vec2 vTexCoord;\n"
|
||||
@@ -100,6 +110,31 @@ const char* kDecodeFragmentShaderSource =
|
||||
" fragColor = rec709YCbCr2rgba(ySample, macroPixel.b, macroPixel.r, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
class GdiplusSession
|
||||
{
|
||||
public:
|
||||
GdiplusSession()
|
||||
{
|
||||
Gdiplus::GdiplusStartupInput startupInput;
|
||||
mStarted = Gdiplus::GdiplusStartup(&mToken, &startupInput, NULL) == Gdiplus::Ok;
|
||||
}
|
||||
|
||||
~GdiplusSession()
|
||||
{
|
||||
if (mStarted)
|
||||
Gdiplus::GdiplusShutdown(mToken);
|
||||
}
|
||||
|
||||
GdiplusSession(const GdiplusSession&) = delete;
|
||||
GdiplusSession& operator=(const GdiplusSession&) = delete;
|
||||
|
||||
bool started() const { return mStarted; }
|
||||
|
||||
private:
|
||||
ULONG_PTR mToken = 0;
|
||||
bool mStarted = false;
|
||||
};
|
||||
|
||||
void CopyErrorMessage(const std::string& message, int errorMessageSize, char* errorMessage)
|
||||
{
|
||||
if (!errorMessage || errorMessageSize <= 0)
|
||||
@@ -108,6 +143,305 @@ void CopyErrorMessage(const std::string& message, int errorMessageSize, char* er
|
||||
strncpy_s(errorMessage, errorMessageSize, message.c_str(), _TRUNCATE);
|
||||
}
|
||||
|
||||
std::wstring Utf8ToWide(const std::string& text)
|
||||
{
|
||||
if (text.empty())
|
||||
return std::wstring();
|
||||
const int required = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, NULL, 0);
|
||||
if (required <= 1)
|
||||
return std::wstring();
|
||||
std::wstring wide(static_cast<std::size_t>(required - 1), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, wide.data(), required);
|
||||
return wide;
|
||||
}
|
||||
|
||||
std::string TextValueForBinding(const RuntimeRenderState& state, const std::string& parameterId)
|
||||
{
|
||||
auto valueIt = state.parameterValues.find(parameterId);
|
||||
return valueIt == state.parameterValues.end() ? std::string() : valueIt->second.textValue;
|
||||
}
|
||||
|
||||
const ShaderFontAsset* FindFontAssetForParameter(const RuntimeRenderState& state, const ShaderParameterDefinition& definition)
|
||||
{
|
||||
if (!definition.fontId.empty())
|
||||
{
|
||||
for (const ShaderFontAsset& fontAsset : state.fontAssets)
|
||||
{
|
||||
if (fontAsset.id == definition.fontId)
|
||||
return &fontAsset;
|
||||
}
|
||||
}
|
||||
return state.fontAssets.empty() ? nullptr : &state.fontAssets.front();
|
||||
}
|
||||
|
||||
std::vector<unsigned char> BuildLocalSdf(const std::vector<unsigned char>& alpha, unsigned width, unsigned height)
|
||||
{
|
||||
std::vector<unsigned char> sdf(static_cast<std::size_t>(width) * height * 4, 0);
|
||||
for (unsigned y = 0; y < height; ++y)
|
||||
{
|
||||
for (unsigned x = 0; x < width; ++x)
|
||||
{
|
||||
const bool inside = alpha[static_cast<std::size_t>(y) * width + x] > 127;
|
||||
int bestDistanceSq = kTextSdfSpread * kTextSdfSpread;
|
||||
for (int oy = -kTextSdfSpread; oy <= kTextSdfSpread; ++oy)
|
||||
{
|
||||
const int sy = static_cast<int>(y) + oy;
|
||||
if (sy < 0 || sy >= static_cast<int>(height))
|
||||
continue;
|
||||
for (int ox = -kTextSdfSpread; ox <= kTextSdfSpread; ++ox)
|
||||
{
|
||||
const int sx = static_cast<int>(x) + ox;
|
||||
if (sx < 0 || sx >= static_cast<int>(width))
|
||||
continue;
|
||||
const bool sampleInside = alpha[static_cast<std::size_t>(sy) * width + sx] > 127;
|
||||
if (sampleInside == inside)
|
||||
continue;
|
||||
const int distanceSq = ox * ox + oy * oy;
|
||||
if (distanceSq < bestDistanceSq)
|
||||
bestDistanceSq = distanceSq;
|
||||
}
|
||||
}
|
||||
|
||||
const float distance = std::sqrt(static_cast<float>(bestDistanceSq));
|
||||
const float signedDistance = (inside ? 1.0f : -1.0f) * distance;
|
||||
float normalized = 0.5f + signedDistance / static_cast<float>(kTextSdfSpread * 2);
|
||||
const unsigned char sourceAlpha = alpha[static_cast<std::size_t>(y) * width + x];
|
||||
if (sourceAlpha > 0 && sourceAlpha < 255)
|
||||
normalized = static_cast<float>(sourceAlpha) / 255.0f;
|
||||
if (normalized < 0.0f)
|
||||
normalized = 0.0f;
|
||||
if (normalized > 1.0f)
|
||||
normalized = 1.0f;
|
||||
const unsigned char value = static_cast<unsigned char>(normalized * 255.0f + 0.5f);
|
||||
const std::size_t out = (static_cast<std::size_t>(y) * width + x) * 4;
|
||||
sdf[out + 0] = value;
|
||||
sdf[out + 1] = value;
|
||||
sdf[out + 2] = value;
|
||||
sdf[out + 3] = value;
|
||||
}
|
||||
}
|
||||
return sdf;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> BuildTextCoverageTexture(const std::vector<unsigned char>& alpha, unsigned width, unsigned height)
|
||||
{
|
||||
std::vector<unsigned char> coverage(static_cast<std::size_t>(width) * height * 4, 0);
|
||||
for (unsigned y = 0; y < height; ++y)
|
||||
{
|
||||
for (unsigned x = 0; x < width; ++x)
|
||||
{
|
||||
const unsigned char value = alpha[static_cast<std::size_t>(y) * width + x];
|
||||
const std::size_t out = (static_cast<std::size_t>(y) * width + x) * 4;
|
||||
coverage[out + 0] = value;
|
||||
coverage[out + 1] = value;
|
||||
coverage[out + 2] = value;
|
||||
coverage[out + 3] = value;
|
||||
}
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> FlipTextTextureForShaderUv(const std::vector<unsigned char>& pixels, unsigned width, unsigned height)
|
||||
{
|
||||
std::vector<unsigned char> flipped(pixels.size(), 0);
|
||||
const std::size_t stride = static_cast<std::size_t>(width) * 4;
|
||||
for (unsigned y = 0; y < height; ++y)
|
||||
{
|
||||
const std::size_t srcOffset = static_cast<std::size_t>(y) * stride;
|
||||
const std::size_t dstOffset = static_cast<std::size_t>(height - 1 - y) * stride;
|
||||
std::memcpy(flipped.data() + dstOffset, pixels.data() + srcOffset, stride);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> BlurTextSdf(const std::vector<unsigned char>& pixels, unsigned width, unsigned height, unsigned passes)
|
||||
{
|
||||
std::vector<unsigned char> current = pixels;
|
||||
std::vector<unsigned char> next(pixels.size(), 0);
|
||||
for (unsigned pass = 0; pass < passes; ++pass)
|
||||
{
|
||||
for (unsigned y = 0; y < height; ++y)
|
||||
{
|
||||
for (unsigned x = 0; x < width; ++x)
|
||||
{
|
||||
unsigned weightedTotal = 0;
|
||||
unsigned weightSum = 0;
|
||||
for (int oy = -1; oy <= 1; ++oy)
|
||||
{
|
||||
const int sy = static_cast<int>(y) + oy;
|
||||
if (sy < 0 || sy >= static_cast<int>(height))
|
||||
continue;
|
||||
for (int ox = -1; ox <= 1; ++ox)
|
||||
{
|
||||
const int sx = static_cast<int>(x) + ox;
|
||||
if (sx < 0 || sx >= static_cast<int>(width))
|
||||
continue;
|
||||
const unsigned weight = (ox == 0 && oy == 0) ? 4u : ((ox == 0 || oy == 0) ? 2u : 1u);
|
||||
const std::size_t sample = (static_cast<std::size_t>(sy) * width + sx) * 4;
|
||||
weightedTotal += static_cast<unsigned>(current[sample]) * weight;
|
||||
weightSum += weight;
|
||||
}
|
||||
}
|
||||
|
||||
const unsigned char value = static_cast<unsigned char>((weightedTotal + weightSum / 2) / weightSum);
|
||||
const std::size_t out = (static_cast<std::size_t>(y) * width + x) * 4;
|
||||
next[out + 0] = value;
|
||||
next[out + 1] = value;
|
||||
next[out + 2] = value;
|
||||
next[out + 3] = value;
|
||||
}
|
||||
}
|
||||
current.swap(next);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
void WriteTextMaskDebugDump(const std::string& text, const std::vector<unsigned char>& alpha, const std::vector<unsigned char>& sdf, unsigned width, unsigned height)
|
||||
{
|
||||
try
|
||||
{
|
||||
std::filesystem::path debugDir = std::filesystem::current_path() / "runtime";
|
||||
std::filesystem::create_directories(debugDir);
|
||||
|
||||
auto writePgm = [width, height](const std::filesystem::path& path, const std::vector<unsigned char>& gray, std::size_t stride)
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
if (!out)
|
||||
return;
|
||||
out << "P5\n" << width << " " << height << "\n255\n";
|
||||
for (unsigned y = 0; y < height; ++y)
|
||||
{
|
||||
for (unsigned x = 0; x < width; ++x)
|
||||
out.put(static_cast<char>(gray[(static_cast<std::size_t>(y) * width + x) * stride]));
|
||||
}
|
||||
};
|
||||
|
||||
writePgm(debugDir / "text-mask-alpha-debug.pgm", alpha, 1);
|
||||
writePgm(debugDir / "text-mask-sdf-debug.pgm", sdf, 4);
|
||||
|
||||
unsigned alphaMin = 255;
|
||||
unsigned alphaMax = 0;
|
||||
unsigned sdfMin = 255;
|
||||
unsigned sdfMax = 0;
|
||||
std::size_t alphaLit = 0;
|
||||
std::size_t sdfLit = 0;
|
||||
for (unsigned char value : alpha)
|
||||
{
|
||||
alphaMin = std::min<unsigned>(alphaMin, value);
|
||||
alphaMax = std::max<unsigned>(alphaMax, value);
|
||||
if (value > 0)
|
||||
++alphaLit;
|
||||
}
|
||||
for (std::size_t index = 0; index < sdf.size(); index += 4)
|
||||
{
|
||||
const unsigned char value = sdf[index];
|
||||
sdfMin = std::min<unsigned>(sdfMin, value);
|
||||
sdfMax = std::max<unsigned>(sdfMax, value);
|
||||
if (value > 127)
|
||||
++sdfLit;
|
||||
}
|
||||
|
||||
std::ostringstream message;
|
||||
message << "Text mask debug for '" << text << "': alpha min/max/lit=" << alphaMin << "/" << alphaMax << "/" << alphaLit
|
||||
<< ", sdf min/max/gt127=" << sdfMin << "/" << sdfMax << "/" << sdfLit << "\n";
|
||||
OutputDebugStringA(message.str().c_str());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
OutputDebugStringA("Failed to write text mask debug dump.\n");
|
||||
}
|
||||
}
|
||||
|
||||
GLint FindSamplerUniformLocation(GLuint program, const std::string& samplerName)
|
||||
{
|
||||
GLint location = glGetUniformLocation(program, samplerName.c_str());
|
||||
if (location >= 0)
|
||||
return location;
|
||||
return glGetUniformLocation(program, (samplerName + "_0").c_str());
|
||||
}
|
||||
|
||||
bool RasterizeTextSdf(const std::string& text, const std::filesystem::path& fontPath, std::vector<unsigned char>& sdf, std::string& error)
|
||||
{
|
||||
GdiplusSession gdiplus;
|
||||
if (!gdiplus.started())
|
||||
{
|
||||
error = "Could not start GDI+ for text rendering.";
|
||||
return false;
|
||||
}
|
||||
|
||||
Gdiplus::PrivateFontCollection fontCollection;
|
||||
Gdiplus::FontFamily fallbackFamily(L"Arial");
|
||||
Gdiplus::FontFamily* fontFamily = &fallbackFamily;
|
||||
std::unique_ptr<Gdiplus::FontFamily[]> families;
|
||||
const std::wstring wideFontPath = fontPath.empty() ? std::wstring() : fontPath.wstring();
|
||||
if (!wideFontPath.empty())
|
||||
{
|
||||
if (fontCollection.AddFontFile(wideFontPath.c_str()) != Gdiplus::Ok)
|
||||
{
|
||||
error = "Could not load packaged font file for text rendering: " + fontPath.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
const INT familyCount = fontCollection.GetFamilyCount();
|
||||
if (familyCount <= 0)
|
||||
{
|
||||
error = "Packaged font did not contain a usable font family: " + fontPath.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
families.reset(new Gdiplus::FontFamily[familyCount]);
|
||||
INT found = 0;
|
||||
if (fontCollection.GetFamilies(familyCount, families.get(), &found) != Gdiplus::Ok || found <= 0)
|
||||
{
|
||||
error = "Could not read the packaged font family: " + fontPath.string();
|
||||
return false;
|
||||
}
|
||||
fontFamily = &families[0];
|
||||
}
|
||||
|
||||
Gdiplus::Bitmap bitmap(kTextTextureWidth, kTextTextureHeight, PixelFormat32bppARGB);
|
||||
Gdiplus::Graphics graphics(&bitmap);
|
||||
graphics.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
|
||||
graphics.Clear(Gdiplus::Color(255, 0, 0, 0));
|
||||
graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
|
||||
graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
|
||||
graphics.SetSmoothingMode(Gdiplus::SmoothingModeHighQuality);
|
||||
Gdiplus::Font font(fontFamily, kTextFontPixelSize, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
|
||||
Gdiplus::SolidBrush brush(Gdiplus::Color(255, 255, 255, 255));
|
||||
Gdiplus::StringFormat format;
|
||||
format.SetAlignment(Gdiplus::StringAlignmentNear);
|
||||
format.SetLineAlignment(Gdiplus::StringAlignmentCenter);
|
||||
format.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap | Gdiplus::StringFormatFlagsMeasureTrailingSpaces);
|
||||
const Gdiplus::RectF layout(
|
||||
kTextLayoutPadding,
|
||||
0.0f,
|
||||
static_cast<Gdiplus::REAL>(kTextTextureWidth) - (kTextLayoutPadding * 2.0f),
|
||||
static_cast<Gdiplus::REAL>(kTextTextureHeight));
|
||||
const std::wstring wideText = Utf8ToWide(text);
|
||||
graphics.DrawString(wideText.c_str(), -1, &font, layout, &format, &brush);
|
||||
|
||||
std::vector<unsigned char> alpha(static_cast<std::size_t>(kTextTextureWidth) * kTextTextureHeight, 0);
|
||||
for (unsigned y = 0; y < kTextTextureHeight; ++y)
|
||||
{
|
||||
for (unsigned x = 0; x < kTextTextureWidth; ++x)
|
||||
{
|
||||
Gdiplus::Color pixel;
|
||||
bitmap.GetPixel(x, y, &pixel);
|
||||
BYTE luminance = pixel.GetRed();
|
||||
if (pixel.GetGreen() > luminance)
|
||||
luminance = pixel.GetGreen();
|
||||
if (pixel.GetBlue() > luminance)
|
||||
luminance = pixel.GetBlue();
|
||||
alpha[static_cast<std::size_t>(y) * kTextTextureWidth + x] = static_cast<unsigned char>(luminance);
|
||||
}
|
||||
}
|
||||
sdf = BuildTextCoverageTexture(alpha, kTextTextureWidth, kTextTextureHeight);
|
||||
sdf = BlurTextSdf(sdf, kTextTextureWidth, kTextTextureHeight, kTextSdfBlurPasses);
|
||||
sdf = FlipTextTextureForShaderUv(sdf, kTextTextureWidth, kTextTextureHeight);
|
||||
WriteTextMaskDebugDump(text, alpha, sdf, kTextTextureWidth, kTextTextureHeight);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string NormalizeModeToken(const std::string& value)
|
||||
{
|
||||
std::string normalized;
|
||||
@@ -466,6 +800,7 @@ bool OpenGLComposite::InitDeckLink()
|
||||
BMDDisplayMode outputDisplayMode = bmdModeHD1080p5994;
|
||||
std::string inputDisplayModeName = "1080p59.94";
|
||||
std::string outputDisplayModeName = "1080p59.94";
|
||||
std::string initFailureReason;
|
||||
int outputFrameRowBytes;
|
||||
HRESULT result;
|
||||
|
||||
@@ -550,8 +885,8 @@ bool OpenGLComposite::InitDeckLink()
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use a full duplex device as capture and playback, or half-duplex device
|
||||
// as capture or playback.
|
||||
// Preserve the original input-then-output selection for half-duplex cards.
|
||||
// Input is optional later, but choosing output first can pick the wrong card.
|
||||
bool inputUsed = false;
|
||||
if (!mDLInput && pDL->QueryInterface(IID_IDeckLinkInput, (void**)&mDLInput) == S_OK)
|
||||
inputUsed = true;
|
||||
@@ -575,26 +910,29 @@ bool OpenGLComposite::InitDeckLink()
|
||||
break;
|
||||
}
|
||||
|
||||
if (! mDLOutput || ! mDLInput)
|
||||
if (!mDLOutput)
|
||||
{
|
||||
MessageBox(NULL, _T("Expected both Input and Output DeckLink devices"), _T("This application requires two DeckLink devices."), MB_OK);
|
||||
MessageBox(NULL, _T("Expected an Output DeckLink device"), _T("This application requires a DeckLink output device."), MB_OK);
|
||||
goto error;
|
||||
}
|
||||
|
||||
if (mDLInput->GetDisplayModeIterator(&pDLInputDisplayModeIterator) != S_OK)
|
||||
if (mDLInput && mDLInput->GetDisplayModeIterator(&pDLInputDisplayModeIterator) != S_OK)
|
||||
{
|
||||
MessageBox(NULL, _T("Cannot get input Display Mode Iterator."), _T("DeckLink error."), MB_OK);
|
||||
goto error;
|
||||
}
|
||||
|
||||
if (!FindDeckLinkDisplayMode(pDLInputDisplayModeIterator, inputDisplayMode, &pDLInputDisplayMode))
|
||||
if (mDLInput && !FindDeckLinkDisplayMode(pDLInputDisplayModeIterator, inputDisplayMode, &pDLInputDisplayMode))
|
||||
{
|
||||
const std::string error = "Cannot get specified input BMDDisplayMode for configured mode: " + inputDisplayModeName;
|
||||
MessageBoxA(NULL, error.c_str(), "DeckLink input error.", MB_OK);
|
||||
goto error;
|
||||
}
|
||||
pDLInputDisplayModeIterator->Release();
|
||||
pDLInputDisplayModeIterator = NULL;
|
||||
if (pDLInputDisplayModeIterator)
|
||||
{
|
||||
pDLInputDisplayModeIterator->Release();
|
||||
pDLInputDisplayModeIterator = NULL;
|
||||
}
|
||||
|
||||
if (mDLOutput->GetDisplayModeIterator(&pDLOutputDisplayModeIterator) != S_OK)
|
||||
{
|
||||
@@ -611,13 +949,18 @@ bool OpenGLComposite::InitDeckLink()
|
||||
pDLOutputDisplayModeIterator->Release();
|
||||
pDLOutputDisplayModeIterator = NULL;
|
||||
|
||||
mInputFrameWidth = pDLInputDisplayMode->GetWidth();
|
||||
mInputFrameHeight = pDLInputDisplayMode->GetHeight();
|
||||
mOutputFrameWidth = pDLOutputDisplayMode->GetWidth();
|
||||
mOutputFrameHeight = pDLOutputDisplayMode->GetHeight();
|
||||
mInputFrameWidth = pDLInputDisplayMode ? pDLInputDisplayMode->GetWidth() : mOutputFrameWidth;
|
||||
mInputFrameHeight = pDLInputDisplayMode ? pDLInputDisplayMode->GetHeight() : mOutputFrameHeight;
|
||||
if (!mDLInput)
|
||||
mInputDisplayModeName = "No input - black frame";
|
||||
|
||||
if (! CheckOpenGLExtensions())
|
||||
{
|
||||
initFailureReason = "OpenGL extension checks failed.";
|
||||
goto error;
|
||||
}
|
||||
if (mInputFrameWidth != mOutputFrameWidth || mInputFrameHeight != mOutputFrameHeight)
|
||||
{
|
||||
mFastTransferExtensionAvailable = false;
|
||||
@@ -625,7 +968,10 @@ bool OpenGLComposite::InitDeckLink()
|
||||
}
|
||||
|
||||
if (! InitOpenGLState())
|
||||
{
|
||||
initFailureReason = "OpenGL state initialization failed.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
if (mRuntimeHost)
|
||||
{
|
||||
@@ -660,26 +1006,51 @@ bool OpenGLComposite::InitDeckLink()
|
||||
}
|
||||
}
|
||||
|
||||
if (mDLInput)
|
||||
{
|
||||
// Use custom allocators so we pin only once then recycle them
|
||||
CComPtr<IDeckLinkVideoBufferAllocatorProvider> captureAllocator(new (std::nothrow) InputAllocatorPool(hGLDC, hGLRC));
|
||||
|
||||
if (mDLInput->EnableVideoInputWithAllocatorProvider(inputDisplayMode, bmdFormat8BitYUV, bmdVideoInputFlagDefault, captureAllocator) != S_OK)
|
||||
goto error;
|
||||
{
|
||||
OutputDebugStringA("DeckLink input could not be enabled; continuing in output-only black-frame mode.\n");
|
||||
mDLInput->Release();
|
||||
mDLInput = NULL;
|
||||
mHasNoInputSource = true;
|
||||
mInputDisplayModeName = "No input - black frame";
|
||||
if (mRuntimeHost)
|
||||
mRuntimeHost->SetSignalStatus(false, mInputFrameWidth, mInputFrameHeight, mInputDisplayModeName);
|
||||
}
|
||||
}
|
||||
|
||||
mCaptureDelegate = new CaptureDelegate(this);
|
||||
if (mDLInput->SetCallback(mCaptureDelegate) != S_OK)
|
||||
goto error;
|
||||
if (mDLInput)
|
||||
{
|
||||
mCaptureDelegate = new CaptureDelegate(this);
|
||||
if (mDLInput->SetCallback(mCaptureDelegate) != S_OK)
|
||||
{
|
||||
initFailureReason = "DeckLink input setup failed while installing the capture callback.";
|
||||
goto error;
|
||||
}
|
||||
}
|
||||
else if (mRuntimeHost)
|
||||
{
|
||||
mRuntimeHost->SetSignalStatus(false, mInputFrameWidth, mInputFrameHeight, mInputDisplayModeName);
|
||||
}
|
||||
|
||||
if (mDLOutput->RowBytesForPixelFormat(bmdFormat8BitBGRA, mOutputFrameWidth, &outputFrameRowBytes) != S_OK)
|
||||
{
|
||||
initFailureReason = "DeckLink output setup failed while calculating BGRA row bytes.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
// Use a custom allocator so we pin only once then recycle them
|
||||
mPlayoutAllocator = new PinnedMemoryAllocator(hGLDC, hGLRC, VideoFrameTransfer::GPUtoCPU, 1, outputFrameRowBytes * mOutputFrameHeight);
|
||||
|
||||
if (mDLOutput->EnableVideoOutput(outputDisplayMode, bmdVideoOutputFlagDefault) != S_OK)
|
||||
{
|
||||
initFailureReason = "DeckLink output setup failed while enabling video output.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
if (mDLOutput->QueryInterface(IID_IDeckLinkKeyer, (void**)&mDLKeyer) == S_OK && mDLKeyer != NULL)
|
||||
mDeckLinkKeyerInterfaceAvailable = true;
|
||||
@@ -734,26 +1105,41 @@ bool OpenGLComposite::InitDeckLink()
|
||||
IDeckLinkVideoBuffer* outputFrameBuffer = NULL;
|
||||
|
||||
if (mPlayoutAllocator->AllocateVideoBuffer(&outputFrameBuffer) != S_OK)
|
||||
{
|
||||
initFailureReason = "DeckLink output setup failed while allocating an output frame buffer.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
if (mDLOutput->CreateVideoFrameWithBuffer(mOutputFrameWidth, mOutputFrameHeight, outputFrameRowBytes, bmdFormat8BitBGRA, bmdFrameFlagFlipVertical, outputFrameBuffer, &outputFrame) != S_OK)
|
||||
{
|
||||
initFailureReason = "DeckLink output setup failed while creating an output video frame.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
mDLOutputVideoFrameQueue.push_back(outputFrame);
|
||||
}
|
||||
|
||||
mPlayoutDelegate = new PlayoutDelegate(this);
|
||||
if (mPlayoutDelegate == NULL)
|
||||
{
|
||||
initFailureReason = "DeckLink output setup failed while creating the playout callback.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
if (mDLOutput->SetScheduledFrameCompletionCallback(mPlayoutDelegate) != S_OK)
|
||||
{
|
||||
initFailureReason = "DeckLink output setup failed while installing the scheduled-frame callback.";
|
||||
goto error;
|
||||
}
|
||||
|
||||
bSuccess = true;
|
||||
|
||||
error:
|
||||
if (!bSuccess)
|
||||
{
|
||||
if (!initFailureReason.empty())
|
||||
MessageBoxA(NULL, initFailureReason.c_str(), "DeckLink initialization failed", MB_OK | MB_ICONERROR);
|
||||
|
||||
if (mDLKeyer != NULL)
|
||||
{
|
||||
mDLKeyer->Disable();
|
||||
@@ -1195,7 +1581,8 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame,
|
||||
if (mFastTransferExtensionAvailable)
|
||||
{
|
||||
// Finished with mCaptureTexture
|
||||
VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU);
|
||||
if (!mHasNoInputSource)
|
||||
VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU);
|
||||
|
||||
if (! mPlayoutAllocator->transferFrame(pFrame, mOutputTexture))
|
||||
OutputDebugStringA("Playback: transferFrame() failed\n");
|
||||
@@ -1232,6 +1619,16 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame,
|
||||
bool OpenGLComposite::Start()
|
||||
{
|
||||
mTotalPlayoutFrames = 0;
|
||||
if (!mDLOutput)
|
||||
{
|
||||
MessageBoxA(NULL, "Cannot start playout because no DeckLink output device is available.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
if (mDLOutputVideoFrameQueue.empty())
|
||||
{
|
||||
MessageBoxA(NULL, "Cannot start playout because the output frame queue is empty.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preroll frames
|
||||
for (unsigned i = 0; i < kPrerollFrameCount; i++)
|
||||
@@ -1244,11 +1641,15 @@ bool OpenGLComposite::Start()
|
||||
// Start with a black frame for playout
|
||||
IDeckLinkVideoBuffer* outputVideoFrameBuffer;
|
||||
if (outputVideoFrame->QueryInterface(IID_IDeckLinkVideoBuffer, (void**)&outputVideoFrameBuffer) != S_OK)
|
||||
{
|
||||
MessageBoxA(NULL, "Could not query the preroll output frame buffer.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (outputVideoFrameBuffer->StartAccess(bmdBufferAccessWrite) != S_OK)
|
||||
{
|
||||
outputVideoFrameBuffer->Release();
|
||||
MessageBoxA(NULL, "Could not write to the preroll output frame buffer.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1260,13 +1661,27 @@ bool OpenGLComposite::Start()
|
||||
outputVideoFrameBuffer->Release();
|
||||
|
||||
if (mDLOutput->ScheduleVideoFrame(outputVideoFrame, (mTotalPlayoutFrames * mFrameDuration), mFrameDuration, mFrameTimescale) != S_OK)
|
||||
{
|
||||
MessageBoxA(NULL, "Could not schedule a preroll output frame.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
mTotalPlayoutFrames++;
|
||||
}
|
||||
|
||||
mDLInput->StartStreams();
|
||||
mDLOutput->StartScheduledPlayback(0, mFrameTimescale, 1.0);
|
||||
if (mDLInput)
|
||||
{
|
||||
if (mDLInput->StartStreams() != S_OK)
|
||||
{
|
||||
MessageBoxA(NULL, "Could not start the DeckLink input stream.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (mDLOutput->StartScheduledPlayback(0, mFrameTimescale, 1.0) != S_OK)
|
||||
{
|
||||
MessageBoxA(NULL, "Could not start DeckLink scheduled playback.", "DeckLink start failed", MB_OK | MB_ICONERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1296,11 +1711,17 @@ bool OpenGLComposite::Stop()
|
||||
}
|
||||
}
|
||||
|
||||
mDLInput->StopStreams();
|
||||
mDLInput->DisableVideoInput();
|
||||
if (mDLInput)
|
||||
{
|
||||
mDLInput->StopStreams();
|
||||
mDLInput->DisableVideoInput();
|
||||
}
|
||||
|
||||
mDLOutput->StopScheduledPlayback(0, NULL, 0);
|
||||
mDLOutput->DisableVideoOutput();
|
||||
if (mDLOutput)
|
||||
{
|
||||
mDLOutput->StopScheduledPlayback(0, NULL, 0);
|
||||
mDLOutput->DisableVideoOutput();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1401,13 +1822,33 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state,
|
||||
}
|
||||
textureBindings.push_back(textureBinding);
|
||||
}
|
||||
std::vector<LayerProgram::TextBinding> textBindings;
|
||||
for (const ShaderParameterDefinition& definition : state.parameterDefinitions)
|
||||
{
|
||||
if (definition.type != ShaderParameterType::Text)
|
||||
continue;
|
||||
LayerProgram::TextBinding textBinding;
|
||||
textBinding.parameterId = definition.id;
|
||||
textBinding.samplerName = definition.id + "Texture";
|
||||
textBinding.fontId = definition.fontId;
|
||||
glGenTextures(1, &textBinding.texture);
|
||||
glBindTexture(GL_TEXTURE_2D, textBinding.texture);
|
||||
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);
|
||||
std::vector<unsigned char> empty(static_cast<std::size_t>(kTextTextureWidth) * kTextTextureHeight * 4, 0);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kTextTextureWidth, kTextTextureHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, empty.data());
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
textBindings.push_back(textBinding);
|
||||
}
|
||||
|
||||
const GLuint globalParamsIndex = glGetUniformBlockIndex(newProgram.get(), "GlobalParams");
|
||||
if (globalParamsIndex != GL_INVALID_INDEX)
|
||||
glUniformBlockBinding(newProgram.get(), globalParamsIndex, kGlobalParamsBindingPoint);
|
||||
|
||||
const unsigned historyCap = mRuntimeHost ? mRuntimeHost->GetMaxTemporalHistoryFrames() : 0;
|
||||
const GLuint shaderTextureBase = kSourceHistoryTextureUnitBase + historyCap + historyCap;
|
||||
const GLuint shaderTextureBase = state.isTemporal ? kSourceHistoryTextureUnitBase + historyCap + historyCap : kSourceHistoryTextureUnitBase;
|
||||
glUseProgram(newProgram.get());
|
||||
const GLint videoInputLocation = glGetUniformLocation(newProgram.get(), "gVideoInput");
|
||||
if (videoInputLocation >= 0)
|
||||
@@ -1426,18 +1867,27 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state,
|
||||
}
|
||||
for (std::size_t index = 0; index < textureBindings.size(); ++index)
|
||||
{
|
||||
const GLint textureSamplerLocation = glGetUniformLocation(newProgram.get(), textureBindings[index].samplerName.c_str());
|
||||
const GLint textureSamplerLocation = FindSamplerUniformLocation(newProgram.get(), textureBindings[index].samplerName);
|
||||
if (textureSamplerLocation >= 0)
|
||||
glUniform1i(textureSamplerLocation, static_cast<GLint>(shaderTextureBase + static_cast<GLuint>(index)));
|
||||
}
|
||||
const GLuint textTextureBase = shaderTextureBase + static_cast<GLuint>(textureBindings.size());
|
||||
for (std::size_t index = 0; index < textBindings.size(); ++index)
|
||||
{
|
||||
const GLint textSamplerLocation = FindSamplerUniformLocation(newProgram.get(), textBindings[index].samplerName);
|
||||
if (textSamplerLocation >= 0)
|
||||
glUniform1i(textSamplerLocation, static_cast<GLint>(textTextureBase + static_cast<GLuint>(index)));
|
||||
}
|
||||
glUseProgram(0);
|
||||
|
||||
layerProgram.layerId = state.layerId;
|
||||
layerProgram.shaderId = state.shaderId;
|
||||
layerProgram.shaderTextureBase = shaderTextureBase;
|
||||
layerProgram.program = newProgram.release();
|
||||
layerProgram.vertexShader = newVertexShader.release();
|
||||
layerProgram.fragmentShader = newFragmentShader.release();
|
||||
layerProgram.textureBindings.swap(textureBindings);
|
||||
layerProgram.textBindings.swap(textBindings);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1539,6 +1989,15 @@ void OpenGLComposite::destroySingleLayerProgram(LayerProgram& layerProgram)
|
||||
}
|
||||
}
|
||||
layerProgram.textureBindings.clear();
|
||||
for (LayerProgram::TextBinding& textBinding : layerProgram.textBindings)
|
||||
{
|
||||
if (textBinding.texture != 0)
|
||||
{
|
||||
glDeleteTextures(1, &textBinding.texture);
|
||||
textBinding.texture = 0;
|
||||
}
|
||||
}
|
||||
layerProgram.textBindings.clear();
|
||||
|
||||
if (layerProgram.program != 0)
|
||||
{
|
||||
@@ -1672,15 +2131,58 @@ bool OpenGLComposite::loadTextureAsset(const ShaderTextureAsset& textureAsset, G
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpenGLComposite::renderTextBindingTexture(const RuntimeRenderState& state, LayerProgram::TextBinding& textBinding, std::string& error)
|
||||
{
|
||||
const std::string text = TextValueForBinding(state, textBinding.parameterId);
|
||||
if (text == textBinding.renderedText && textBinding.renderedWidth == kTextTextureWidth && textBinding.renderedHeight == kTextTextureHeight)
|
||||
return true;
|
||||
|
||||
auto definitionIt = std::find_if(state.parameterDefinitions.begin(), state.parameterDefinitions.end(),
|
||||
[&textBinding](const ShaderParameterDefinition& definition) { return definition.id == textBinding.parameterId; });
|
||||
if (definitionIt == state.parameterDefinitions.end())
|
||||
return true;
|
||||
|
||||
const ShaderFontAsset* fontAsset = FindFontAssetForParameter(state, *definitionIt);
|
||||
std::filesystem::path fontPath;
|
||||
if (fontAsset)
|
||||
fontPath = fontAsset->path;
|
||||
|
||||
std::vector<unsigned char> sdf;
|
||||
if (!RasterizeTextSdf(text, fontPath, sdf, error))
|
||||
return false;
|
||||
|
||||
GLint previousActiveTexture = 0;
|
||||
GLint previousUnpackBuffer = 0;
|
||||
glGetIntegerv(GL_ACTIVE_TEXTURE, &previousActiveTexture);
|
||||
glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &previousUnpackBuffer);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
|
||||
glBindTexture(GL_TEXTURE_2D, textBinding.texture);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kTextTextureWidth, kTextTextureHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, sdf.data());
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, static_cast<GLuint>(previousUnpackBuffer));
|
||||
glActiveTexture(static_cast<GLenum>(previousActiveTexture));
|
||||
|
||||
textBinding.renderedText = text;
|
||||
textBinding.renderedWidth = kTextTextureWidth;
|
||||
textBinding.renderedHeight = kTextTextureHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
void OpenGLComposite::bindLayerTextureAssets(const LayerProgram& layerProgram)
|
||||
{
|
||||
const unsigned historyCap = mRuntimeHost ? mRuntimeHost->GetMaxTemporalHistoryFrames() : 0;
|
||||
const GLuint shaderTextureBase = kSourceHistoryTextureUnitBase + historyCap + historyCap;
|
||||
const GLuint shaderTextureBase = layerProgram.shaderTextureBase != 0 ? layerProgram.shaderTextureBase : kSourceHistoryTextureUnitBase;
|
||||
for (std::size_t index = 0; index < layerProgram.textureBindings.size(); ++index)
|
||||
{
|
||||
glActiveTexture(GL_TEXTURE0 + shaderTextureBase + static_cast<GLuint>(index));
|
||||
glBindTexture(GL_TEXTURE_2D, layerProgram.textureBindings[index].texture);
|
||||
}
|
||||
const GLuint textTextureBase = shaderTextureBase + static_cast<GLuint>(layerProgram.textureBindings.size());
|
||||
for (std::size_t index = 0; index < layerProgram.textBindings.size(); ++index)
|
||||
{
|
||||
glActiveTexture(GL_TEXTURE0 + textTextureBase + static_cast<GLuint>(index));
|
||||
glBindTexture(GL_TEXTURE_2D, layerProgram.textBindings[index].texture);
|
||||
}
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
}
|
||||
|
||||
@@ -1708,15 +2210,22 @@ void OpenGLComposite::destroyDecodeShaderProgram()
|
||||
bool OpenGLComposite::validateTemporalTextureUnitBudget(const std::vector<RuntimeRenderState>& layerStates, std::string& error) const
|
||||
{
|
||||
const unsigned historyCap = mRuntimeHost ? mRuntimeHost->GetMaxTemporalHistoryFrames() : 0;
|
||||
unsigned maxAssetTextures = 0;
|
||||
unsigned requiredUnits = kSourceHistoryTextureUnitBase;
|
||||
for (const RuntimeRenderState& state : layerStates)
|
||||
{
|
||||
if (state.textureAssets.size() > maxAssetTextures)
|
||||
maxAssetTextures = static_cast<unsigned>(state.textureAssets.size());
|
||||
unsigned textTextureCount = 0;
|
||||
for (const ShaderParameterDefinition& definition : state.parameterDefinitions)
|
||||
{
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
++textTextureCount;
|
||||
}
|
||||
const unsigned totalShaderTextures = static_cast<unsigned>(state.textureAssets.size()) + textTextureCount;
|
||||
const unsigned layerRequiredUnits = kSourceHistoryTextureUnitBase + (state.isTemporal ? historyCap + historyCap : 0u) + totalShaderTextures;
|
||||
if (layerRequiredUnits > requiredUnits)
|
||||
requiredUnits = layerRequiredUnits;
|
||||
}
|
||||
GLint maxTextureUnits = 0;
|
||||
glGetIntegerv(GL_MAX_TEXTURE_IMAGE_UNITS, &maxTextureUnits);
|
||||
const unsigned requiredUnits = kSourceHistoryTextureUnitBase + historyCap + historyCap + maxAssetTextures;
|
||||
const unsigned availableUnits = maxTextureUnits > 0 ? static_cast<unsigned>(maxTextureUnits) : 0u;
|
||||
if (requiredUnits > availableUnits)
|
||||
{
|
||||
@@ -1920,10 +2429,8 @@ void OpenGLComposite::renderEffect()
|
||||
{
|
||||
PollRuntimeChanges();
|
||||
|
||||
if (mHasNoInputSource)
|
||||
return;
|
||||
|
||||
if (mFastTransferExtensionAvailable)
|
||||
const bool hasInputSource = !mHasNoInputSource;
|
||||
if (hasInputSource && mFastTransferExtensionAvailable)
|
||||
{
|
||||
// Signal that we're about to draw using mCaptureTexture onto mFBOTexture.
|
||||
VideoFrameTransfer::beginTextureInUse(VideoFrameTransfer::CPUtoGPU);
|
||||
@@ -1931,7 +2438,17 @@ void OpenGLComposite::renderEffect()
|
||||
|
||||
glDisable(GL_BLEND);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
renderDecodePass();
|
||||
if (hasInputSource)
|
||||
{
|
||||
renderDecodePass();
|
||||
}
|
||||
else
|
||||
{
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, mDecodeFrameBuf);
|
||||
glViewport(0, 0, mInputFrameWidth, mInputFrameHeight);
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
const std::vector<RuntimeRenderState> layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mInputFrameWidth, mInputFrameHeight) : std::vector<RuntimeRenderState>();
|
||||
if (layerStates.empty() || mLayerPrograms.empty())
|
||||
@@ -1963,12 +2480,19 @@ void OpenGLComposite::renderEffect()
|
||||
|
||||
pushFramebufferToHistoryRing(mDecodeFrameBuf, mSourceHistoryRing);
|
||||
|
||||
if (mFastTransferExtensionAvailable)
|
||||
if (hasInputSource && mFastTransferExtensionAvailable)
|
||||
VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU);
|
||||
}
|
||||
|
||||
void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state)
|
||||
void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, LayerProgram& layerProgram, const RuntimeRenderState& state)
|
||||
{
|
||||
for (LayerProgram::TextBinding& textBinding : layerProgram.textBindings)
|
||||
{
|
||||
std::string textError;
|
||||
if (!renderTextBindingTexture(state, textBinding, textError))
|
||||
OutputDebugStringA((textError + "\n").c_str());
|
||||
}
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, destinationFrameBuffer);
|
||||
glViewport(0, 0, mInputFrameWidth, mInputFrameHeight);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
@@ -1990,8 +2514,8 @@ void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinati
|
||||
glActiveTexture(GL_TEXTURE0 + kSourceHistoryTextureUnitBase + historyCap + index);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
const GLuint shaderTextureBase = kSourceHistoryTextureUnitBase + historyCap + historyCap;
|
||||
for (std::size_t index = 0; index < layerProgram.textureBindings.size(); ++index)
|
||||
const GLuint shaderTextureBase = layerProgram.shaderTextureBase != 0 ? layerProgram.shaderTextureBase : kSourceHistoryTextureUnitBase;
|
||||
for (std::size_t index = 0; index < layerProgram.textureBindings.size() + layerProgram.textBindings.size(); ++index)
|
||||
{
|
||||
glActiveTexture(GL_TEXTURE0 + shaderTextureBase + static_cast<GLuint>(index));
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
@@ -2128,6 +2652,8 @@ bool OpenGLComposite::updateGlobalParamsBuffer(const RuntimeRenderState& state,
|
||||
AppendStd140Int(buffer, selectedIndex);
|
||||
break;
|
||||
}
|
||||
case ShaderParameterType::Text:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,12 +167,25 @@ private:
|
||||
GLuint texture = 0;
|
||||
};
|
||||
|
||||
struct TextBinding
|
||||
{
|
||||
std::string parameterId;
|
||||
std::string samplerName;
|
||||
std::string fontId;
|
||||
GLuint texture = 0;
|
||||
std::string renderedText;
|
||||
unsigned renderedWidth = 0;
|
||||
unsigned renderedHeight = 0;
|
||||
};
|
||||
|
||||
std::string layerId;
|
||||
std::string shaderId;
|
||||
GLuint shaderTextureBase = 0;
|
||||
GLuint program = 0;
|
||||
GLuint vertexShader = 0;
|
||||
GLuint fragmentShader = 0;
|
||||
std::vector<TextureBinding> textureBindings;
|
||||
std::vector<TextBinding> textBindings;
|
||||
};
|
||||
std::vector<LayerProgram> mLayerPrograms;
|
||||
|
||||
@@ -203,8 +216,9 @@ private:
|
||||
void destroySingleLayerProgram(LayerProgram& layerProgram);
|
||||
void destroyDecodeShaderProgram();
|
||||
void renderDecodePass();
|
||||
void renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state);
|
||||
void renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, LayerProgram& layerProgram, const RuntimeRenderState& state);
|
||||
bool loadTextureAsset(const ShaderTextureAsset& textureAsset, GLuint& textureId, std::string& error);
|
||||
bool renderTextBindingTexture(const RuntimeRenderState& state, LayerProgram::TextBinding& textBinding, std::string& error);
|
||||
void bindLayerTextureAssets(const LayerProgram& layerProgram);
|
||||
void renderEffect();
|
||||
bool PollRuntimeChanges();
|
||||
|
||||
@@ -133,6 +133,7 @@ std::string ShaderParameterTypeToString(ShaderParameterType type)
|
||||
case ShaderParameterType::Color: return "color";
|
||||
case ShaderParameterType::Boolean: return "bool";
|
||||
case ShaderParameterType::Enum: return "enum";
|
||||
case ShaderParameterType::Text: return "text";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -179,6 +180,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType&
|
||||
type = ShaderParameterType::Enum;
|
||||
return true;
|
||||
}
|
||||
if (typeName == "text")
|
||||
{
|
||||
type = ShaderParameterType::Text;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -200,6 +206,24 @@ bool TextureAssetsEqual(const std::vector<ShaderTextureAsset>& left, const std::
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FontAssetsEqual(const std::vector<ShaderFontAsset>& left, const std::vector<ShaderFontAsset>& right)
|
||||
{
|
||||
if (left.size() != right.size())
|
||||
return false;
|
||||
|
||||
for (std::size_t index = 0; index < left.size(); ++index)
|
||||
{
|
||||
if (left[index].id != right[index].id ||
|
||||
left[index].path != right[index].path ||
|
||||
left[index].writeTime != right[index].writeTime)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ManifestPathMessage(const std::filesystem::path& manifestPath)
|
||||
{
|
||||
return manifestPath.string();
|
||||
@@ -379,6 +403,49 @@ bool ParseTextureAssets(const JsonValue& manifestJson, ShaderPackage& shaderPack
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseFontAssets(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error)
|
||||
{
|
||||
const JsonValue* fontsValue = nullptr;
|
||||
if (!OptionalArrayField(manifestJson, "fonts", fontsValue, manifestPath, error))
|
||||
return false;
|
||||
if (!fontsValue)
|
||||
return true;
|
||||
|
||||
for (const JsonValue& fontJson : fontsValue->asArray())
|
||||
{
|
||||
if (!fontJson.isObject())
|
||||
{
|
||||
error = "Shader font entry must be an object in: " + ManifestPathMessage(manifestPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string fontId;
|
||||
std::string fontPath;
|
||||
if (!RequireNonEmptyStringField(fontJson, "id", fontId, manifestPath, error) ||
|
||||
!RequireNonEmptyStringField(fontJson, "path", fontPath, manifestPath, error))
|
||||
{
|
||||
error = "Shader font is missing required 'id' or 'path' in: " + ManifestPathMessage(manifestPath);
|
||||
return false;
|
||||
}
|
||||
if (!ValidateShaderIdentifier(fontId, "fonts[].id", manifestPath, error))
|
||||
return false;
|
||||
|
||||
ShaderFontAsset fontAsset;
|
||||
fontAsset.id = fontId;
|
||||
fontAsset.path = shaderPackage.directoryPath / fontPath;
|
||||
if (!std::filesystem::exists(fontAsset.path))
|
||||
{
|
||||
error = "Shader font asset not found for package " + shaderPackage.id + ": " + fontAsset.path.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
fontAsset.writeTime = std::filesystem::last_write_time(fontAsset.path);
|
||||
shaderPackage.fontAssets.push_back(fontAsset);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseTemporalSettings(const JsonValue& manifestJson, ShaderPackage& shaderPackage, unsigned maxTemporalHistoryFrames, const std::filesystem::path& manifestPath, std::string& error)
|
||||
{
|
||||
const JsonValue* temporalValue = nullptr;
|
||||
@@ -461,6 +528,17 @@ bool ParseParameterDefault(const JsonValue& parameterJson, ShaderParameterDefini
|
||||
return true;
|
||||
}
|
||||
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
{
|
||||
if (!defaultValue->isString())
|
||||
{
|
||||
error = "Text parameter default must be a string for: " + definition.id;
|
||||
return false;
|
||||
}
|
||||
definition.defaultTextValue = defaultValue->asString();
|
||||
return true;
|
||||
}
|
||||
|
||||
return NumberListFromJsonValue(*defaultValue, definition.defaultNumbers, "default", manifestPath, error);
|
||||
}
|
||||
|
||||
@@ -543,6 +621,30 @@ bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDef
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
{
|
||||
if (const JsonValue* fontValue = parameterJson.find("font"))
|
||||
{
|
||||
if (!fontValue->isString())
|
||||
{
|
||||
error = "Text parameter 'font' must be a string for: " + definition.id;
|
||||
return false;
|
||||
}
|
||||
definition.fontId = fontValue->asString();
|
||||
if (!definition.fontId.empty() && !ValidateShaderIdentifier(definition.fontId, "parameters[].font", manifestPath, error))
|
||||
return false;
|
||||
}
|
||||
if (const JsonValue* maxLengthValue = parameterJson.find("maxLength"))
|
||||
{
|
||||
if (!maxLengthValue->isNumber() || maxLengthValue->asNumber() < 1.0 || maxLengthValue->asNumber() > 256.0)
|
||||
{
|
||||
error = "Text parameter 'maxLength' must be a number from 1 to 256 for: " + definition.id;
|
||||
return false;
|
||||
}
|
||||
definition.maxLength = static_cast<unsigned>(maxLengthValue->asNumber());
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.type == ShaderParameterType::Enum)
|
||||
return ParseParameterOptions(parameterJson, definition, manifestPath, error);
|
||||
|
||||
@@ -693,7 +795,8 @@ bool RuntimeHost::PollFileChanges(bool& registryChanged, bool& reloadRequested,
|
||||
}
|
||||
if (previous->second.shaderWriteTime != item.second.shaderWriteTime ||
|
||||
previous->second.manifestWriteTime != item.second.manifestWriteTime ||
|
||||
!TextureAssetsEqual(previous->second.textureAssets, item.second.textureAssets))
|
||||
!TextureAssetsEqual(previous->second.textureAssets, item.second.textureAssets) ||
|
||||
!FontAssetsEqual(previous->second.fontAssets, item.second.fontAssets))
|
||||
{
|
||||
registryChanged = true;
|
||||
break;
|
||||
@@ -714,7 +817,8 @@ bool RuntimeHost::PollFileChanges(bool& registryChanged, bool& reloadRequested,
|
||||
if (previous->second.first != active->second.shaderWriteTime ||
|
||||
previous->second.second != active->second.manifestWriteTime ||
|
||||
(previousPackage != previousPackages.end() &&
|
||||
!TextureAssetsEqual(previousPackage->second.textureAssets, active->second.textureAssets)))
|
||||
(!TextureAssetsEqual(previousPackage->second.textureAssets, active->second.textureAssets) ||
|
||||
!FontAssetsEqual(previousPackage->second.fontAssets, active->second.fontAssets))))
|
||||
{
|
||||
mReloadRequested = true;
|
||||
}
|
||||
@@ -1143,6 +1247,7 @@ std::vector<RuntimeRenderState> RuntimeHost::GetLayerRenderStates(unsigned outpu
|
||||
state.outputHeight = outputHeight;
|
||||
state.parameterDefinitions = shaderIt->second.parameters;
|
||||
state.textureAssets = shaderIt->second.textureAssets;
|
||||
state.fontAssets = shaderIt->second.fontAssets;
|
||||
state.isTemporal = shaderIt->second.temporal.enabled;
|
||||
state.temporalHistorySource = shaderIt->second.temporal.historySource;
|
||||
state.requestedTemporalHistoryLength = shaderIt->second.temporal.requestedHistoryLength;
|
||||
@@ -1447,6 +1552,7 @@ bool RuntimeHost::ParseShaderManifest(const std::filesystem::path& manifestPath,
|
||||
shaderPackage.manifestWriteTime = std::filesystem::last_write_time(shaderPackage.manifestPath);
|
||||
|
||||
return ParseTextureAssets(manifestJson, shaderPackage, manifestPath, error) &&
|
||||
ParseFontAssets(manifestJson, shaderPackage, manifestPath, error) &&
|
||||
ParseTemporalSettings(manifestJson, shaderPackage, mConfig.maxTemporalHistoryFrames, manifestPath, error) &&
|
||||
ParseParameterDefinitions(manifestJson, shaderPackage, manifestPath, error);
|
||||
}
|
||||
@@ -1465,8 +1571,62 @@ void RuntimeHost::EnsureLayerDefaultsLocked(LayerPersistentState& layerState, co
|
||||
{
|
||||
for (const ShaderParameterDefinition& definition : shaderPackage.parameters)
|
||||
{
|
||||
if (layerState.parameterValues.find(definition.id) == layerState.parameterValues.end())
|
||||
auto valueIt = layerState.parameterValues.find(definition.id);
|
||||
if (valueIt == layerState.parameterValues.end())
|
||||
{
|
||||
layerState.parameterValues[definition.id] = DefaultValueForDefinition(definition);
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonValue valueJson;
|
||||
bool shouldNormalize = true;
|
||||
switch (definition.type)
|
||||
{
|
||||
case ShaderParameterType::Float:
|
||||
if (valueIt->second.numberValues.empty())
|
||||
shouldNormalize = false;
|
||||
else
|
||||
valueJson = JsonValue(valueIt->second.numberValues.front());
|
||||
break;
|
||||
case ShaderParameterType::Vec2:
|
||||
case ShaderParameterType::Color:
|
||||
valueJson = JsonValue::MakeArray();
|
||||
for (double number : valueIt->second.numberValues)
|
||||
valueJson.pushBack(JsonValue(number));
|
||||
break;
|
||||
case ShaderParameterType::Boolean:
|
||||
valueJson = JsonValue(valueIt->second.booleanValue);
|
||||
break;
|
||||
case ShaderParameterType::Enum:
|
||||
valueJson = JsonValue(valueIt->second.enumValue);
|
||||
break;
|
||||
case ShaderParameterType::Text:
|
||||
{
|
||||
const std::string textValue = !valueIt->second.textValue.empty()
|
||||
? valueIt->second.textValue
|
||||
: valueIt->second.enumValue;
|
||||
if (textValue.empty())
|
||||
{
|
||||
valueIt->second = DefaultValueForDefinition(definition);
|
||||
shouldNormalize = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
valueJson = JsonValue(textValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldNormalize)
|
||||
continue;
|
||||
|
||||
ShaderParameterValue normalizedValue;
|
||||
std::string normalizeError;
|
||||
if (NormalizeAndValidateValue(definition, valueJson, normalizedValue, normalizeError))
|
||||
valueIt->second = normalizedValue;
|
||||
else
|
||||
valueIt->second = DefaultValueForDefinition(definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1657,6 +1817,7 @@ JsonValue RuntimeHost::SerializeLayerStackLocked() const
|
||||
parameter.set("id", JsonValue(definition.id));
|
||||
parameter.set("label", JsonValue(definition.label));
|
||||
parameter.set("type", JsonValue(ShaderParameterTypeToString(definition.type)));
|
||||
parameter.set("defaultValue", SerializeParameterValue(definition, DefaultValueForDefinition(definition)));
|
||||
|
||||
if (!definition.minNumbers.empty())
|
||||
{
|
||||
@@ -1691,6 +1852,12 @@ JsonValue RuntimeHost::SerializeLayerStackLocked() const
|
||||
}
|
||||
parameter.set("options", options);
|
||||
}
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
{
|
||||
parameter.set("maxLength", JsonValue(static_cast<double>(definition.maxLength)));
|
||||
if (!definition.fontId.empty())
|
||||
parameter.set("font", JsonValue(definition.fontId));
|
||||
}
|
||||
|
||||
ShaderParameterValue value = DefaultValueForDefinition(definition);
|
||||
auto valueIt = layer.parameterValues.find(definition.id);
|
||||
@@ -1829,6 +1996,8 @@ JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition&
|
||||
return JsonValue(value.booleanValue);
|
||||
case ShaderParameterType::Enum:
|
||||
return JsonValue(value.enumValue);
|
||||
case ShaderParameterType::Text:
|
||||
return JsonValue(value.textValue);
|
||||
case ShaderParameterType::Float:
|
||||
return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front());
|
||||
case ShaderParameterType::Vec2:
|
||||
|
||||
@@ -36,6 +36,21 @@ std::vector<double> JsonArrayToNumbers(const JsonValue& value)
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
std::string NormalizeTextValue(const std::string& text, unsigned maxLength)
|
||||
{
|
||||
std::string normalized;
|
||||
normalized.reserve(std::min<std::size_t>(text.size(), maxLength));
|
||||
for (unsigned char ch : text)
|
||||
{
|
||||
if (ch < 32 || ch > 126)
|
||||
continue;
|
||||
if (normalized.size() >= maxLength)
|
||||
break;
|
||||
normalized.push_back(static_cast<char>(ch));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
std::string MakeSafePresetFileStem(const std::string& presetName)
|
||||
@@ -82,6 +97,9 @@ ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition&
|
||||
case ShaderParameterType::Enum:
|
||||
value.enumValue = definition.defaultEnumValue;
|
||||
break;
|
||||
case ShaderParameterType::Text:
|
||||
value.textValue = NormalizeTextValue(definition.defaultTextValue, definition.maxLength);
|
||||
break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -164,6 +182,14 @@ bool NormalizeAndValidateParameterValue(const ShaderParameterDefinition& definit
|
||||
error = "Enum parameter '" + definition.id + "' received unsupported option '" + selectedValue + "'.";
|
||||
return false;
|
||||
}
|
||||
case ShaderParameterType::Text:
|
||||
if (!value.isString())
|
||||
{
|
||||
error = "Expected string value for text parameter '" + definition.id + "'.";
|
||||
return false;
|
||||
}
|
||||
normalizedValue.textValue = NormalizeTextValue(value.asString(), definition.maxLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "NativeHandles.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <cctype>
|
||||
#include <regex>
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
@@ -30,15 +31,29 @@ std::string SlangCBufferTypeForParameter(ShaderParameterType type)
|
||||
case ShaderParameterType::Color: return "float4";
|
||||
case ShaderParameterType::Boolean: return "bool";
|
||||
case ShaderParameterType::Enum: return "int";
|
||||
case ShaderParameterType::Text: return "";
|
||||
}
|
||||
return "float";
|
||||
}
|
||||
|
||||
std::string CapitalizeIdentifier(const std::string& identifier)
|
||||
{
|
||||
if (identifier.empty())
|
||||
return identifier;
|
||||
std::string text = identifier;
|
||||
text[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(text[0])));
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string BuildParameterUniforms(const std::vector<ShaderParameterDefinition>& parameters)
|
||||
{
|
||||
std::ostringstream source;
|
||||
for (const ShaderParameterDefinition& definition : parameters)
|
||||
{
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
continue;
|
||||
source << "\t" << SlangCBufferTypeForParameter(definition.type) << " " << definition.id << ";\n";
|
||||
}
|
||||
return source.str();
|
||||
}
|
||||
|
||||
@@ -60,6 +75,44 @@ std::string BuildTextureSamplerDeclarations(const std::vector<ShaderTextureAsset
|
||||
return source.str();
|
||||
}
|
||||
|
||||
std::string BuildTextSamplerDeclarations(const std::vector<ShaderParameterDefinition>& parameters)
|
||||
{
|
||||
std::ostringstream source;
|
||||
for (const ShaderParameterDefinition& definition : parameters)
|
||||
{
|
||||
if (definition.type != ShaderParameterType::Text)
|
||||
continue;
|
||||
source << "Sampler2D<float4> " << definition.id << "Texture;\n";
|
||||
}
|
||||
if (source.tellp() > 0)
|
||||
source << "\n";
|
||||
return source.str();
|
||||
}
|
||||
|
||||
std::string BuildTextHelpers(const std::vector<ShaderParameterDefinition>& parameters)
|
||||
{
|
||||
std::ostringstream source;
|
||||
for (const ShaderParameterDefinition& definition : parameters)
|
||||
{
|
||||
if (definition.type != ShaderParameterType::Text)
|
||||
continue;
|
||||
const std::string suffix = CapitalizeIdentifier(definition.id);
|
||||
source
|
||||
<< "float sample" << suffix << "(float2 uv)\n"
|
||||
<< "{\n"
|
||||
<< "\tif (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)\n"
|
||||
<< "\t\treturn 0.0;\n"
|
||||
<< "\treturn " << definition.id << "Texture.Sample(uv).r;\n"
|
||||
<< "}\n\n"
|
||||
<< "float4 draw" << suffix << "(float2 uv, float4 fillColor)\n"
|
||||
<< "{\n"
|
||||
<< "\tfloat alpha = sample" << suffix << "(uv) * fillColor.a;\n"
|
||||
<< "\treturn float4(fillColor.rgb * alpha, alpha);\n"
|
||||
<< "}\n\n";
|
||||
}
|
||||
return source.str();
|
||||
}
|
||||
|
||||
std::string BuildHistorySwitchCases(const std::string& samplerPrefix, unsigned historyLength)
|
||||
{
|
||||
std::ostringstream source;
|
||||
@@ -115,11 +168,14 @@ bool ShaderCompiler::BuildWrapperSlangSource(const ShaderPackage& shaderPackage,
|
||||
return false;
|
||||
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{PARAMETER_UNIFORMS}}", BuildParameterUniforms(shaderPackage.parameters));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{SOURCE_HISTORY_SAMPLERS}}", BuildHistorySamplerDeclarations("gSourceHistory", mMaxTemporalHistoryFrames));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEMPORAL_HISTORY_SAMPLERS}}", BuildHistorySamplerDeclarations("gTemporalHistory", mMaxTemporalHistoryFrames));
|
||||
const unsigned historySamplerCount = shaderPackage.temporal.enabled ? mMaxTemporalHistoryFrames : 0;
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{SOURCE_HISTORY_SAMPLERS}}", BuildHistorySamplerDeclarations("gSourceHistory", historySamplerCount));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEMPORAL_HISTORY_SAMPLERS}}", BuildHistorySamplerDeclarations("gTemporalHistory", historySamplerCount));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEXTURE_SAMPLERS}}", BuildTextureSamplerDeclarations(shaderPackage.textureAssets));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{SOURCE_HISTORY_SWITCH_CASES}}", BuildHistorySwitchCases("gSourceHistory", mMaxTemporalHistoryFrames));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEMPORAL_HISTORY_SWITCH_CASES}}", BuildHistorySwitchCases("gTemporalHistory", mMaxTemporalHistoryFrames));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEXT_SAMPLERS}}", BuildTextSamplerDeclarations(shaderPackage.parameters));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEXT_HELPERS}}", BuildTextHelpers(shaderPackage.parameters));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{SOURCE_HISTORY_SWITCH_CASES}}", BuildHistorySwitchCases("gSourceHistory", historySamplerCount));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{TEMPORAL_HISTORY_SWITCH_CASES}}", BuildHistorySwitchCases("gTemporalHistory", historySamplerCount));
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{USER_SHADER_INCLUDE}}", shaderPackage.shaderPath.generic_string());
|
||||
wrapperSource = ReplaceAll(wrapperSource, "{{ENTRY_POINT_CALL}}", shaderPackage.entryPoint + "(context)");
|
||||
return true;
|
||||
|
||||
@@ -67,6 +67,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType&
|
||||
type = ShaderParameterType::Enum;
|
||||
return true;
|
||||
}
|
||||
if (typeName == "text")
|
||||
{
|
||||
type = ShaderParameterType::Text;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -283,6 +288,49 @@ bool ParseTextureAssets(const JsonValue& manifestJson, ShaderPackage& shaderPack
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseFontAssets(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error)
|
||||
{
|
||||
const JsonValue* fontsValue = nullptr;
|
||||
if (!OptionalArrayField(manifestJson, "fonts", fontsValue, manifestPath, error))
|
||||
return false;
|
||||
if (!fontsValue)
|
||||
return true;
|
||||
|
||||
for (const JsonValue& fontJson : fontsValue->asArray())
|
||||
{
|
||||
if (!fontJson.isObject())
|
||||
{
|
||||
error = "Shader font entry must be an object in: " + ManifestPathMessage(manifestPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string fontId;
|
||||
std::string fontPath;
|
||||
if (!RequireNonEmptyStringField(fontJson, "id", fontId, manifestPath, error) ||
|
||||
!RequireNonEmptyStringField(fontJson, "path", fontPath, manifestPath, error))
|
||||
{
|
||||
error = "Shader font is missing required 'id' or 'path' in: " + ManifestPathMessage(manifestPath);
|
||||
return false;
|
||||
}
|
||||
if (!ValidateShaderIdentifier(fontId, "fonts[].id", manifestPath, error))
|
||||
return false;
|
||||
|
||||
ShaderFontAsset fontAsset;
|
||||
fontAsset.id = fontId;
|
||||
fontAsset.path = shaderPackage.directoryPath / fontPath;
|
||||
if (!std::filesystem::exists(fontAsset.path))
|
||||
{
|
||||
error = "Shader font asset not found for package " + shaderPackage.id + ": " + fontAsset.path.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
fontAsset.writeTime = std::filesystem::last_write_time(fontAsset.path);
|
||||
shaderPackage.fontAssets.push_back(fontAsset);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseTemporalSettings(const JsonValue& manifestJson, ShaderPackage& shaderPackage, unsigned maxTemporalHistoryFrames, const std::filesystem::path& manifestPath, std::string& error)
|
||||
{
|
||||
const JsonValue* temporalValue = nullptr;
|
||||
@@ -365,6 +413,17 @@ bool ParseParameterDefault(const JsonValue& parameterJson, ShaderParameterDefini
|
||||
return true;
|
||||
}
|
||||
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
{
|
||||
if (!defaultValue->isString())
|
||||
{
|
||||
error = "Text parameter default must be a string for: " + definition.id;
|
||||
return false;
|
||||
}
|
||||
definition.defaultTextValue = defaultValue->asString();
|
||||
return true;
|
||||
}
|
||||
|
||||
return NumberListFromJsonValue(*defaultValue, definition.defaultNumbers, "default", manifestPath, error);
|
||||
}
|
||||
|
||||
@@ -447,6 +506,30 @@ bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDef
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.type == ShaderParameterType::Text)
|
||||
{
|
||||
if (const JsonValue* fontValue = parameterJson.find("font"))
|
||||
{
|
||||
if (!fontValue->isString())
|
||||
{
|
||||
error = "Text parameter 'font' must be a string for: " + definition.id;
|
||||
return false;
|
||||
}
|
||||
definition.fontId = fontValue->asString();
|
||||
if (!definition.fontId.empty() && !ValidateShaderIdentifier(definition.fontId, "parameters[].font", manifestPath, error))
|
||||
return false;
|
||||
}
|
||||
if (const JsonValue* maxLengthValue = parameterJson.find("maxLength"))
|
||||
{
|
||||
if (!maxLengthValue->isNumber() || maxLengthValue->asNumber() < 1.0 || maxLengthValue->asNumber() > 256.0)
|
||||
{
|
||||
error = "Text parameter 'maxLength' must be a number from 1 to 256 for: " + definition.id;
|
||||
return false;
|
||||
}
|
||||
definition.maxLength = static_cast<unsigned>(maxLengthValue->asNumber());
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.type == ShaderParameterType::Enum)
|
||||
return ParseParameterOptions(parameterJson, definition, manifestPath, error);
|
||||
|
||||
@@ -544,6 +627,7 @@ bool ShaderPackageRegistry::ParseManifest(const std::filesystem::path& manifestP
|
||||
shaderPackage.manifestWriteTime = std::filesystem::last_write_time(shaderPackage.manifestPath);
|
||||
|
||||
return ParseTextureAssets(manifestJson, shaderPackage, manifestPath, error) &&
|
||||
ParseFontAssets(manifestJson, shaderPackage, manifestPath, error) &&
|
||||
ParseTemporalSettings(manifestJson, shaderPackage, mMaxTemporalHistoryFrames, manifestPath, error) &&
|
||||
ParseParameterDefinitions(manifestJson, shaderPackage, manifestPath, error);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ enum class ShaderParameterType
|
||||
Vec2,
|
||||
Color,
|
||||
Boolean,
|
||||
Enum
|
||||
Enum,
|
||||
Text
|
||||
};
|
||||
|
||||
struct ShaderParameterOption
|
||||
@@ -31,6 +32,9 @@ struct ShaderParameterDefinition
|
||||
std::vector<double> stepNumbers;
|
||||
bool defaultBoolean = false;
|
||||
std::string defaultEnumValue;
|
||||
std::string defaultTextValue;
|
||||
std::string fontId;
|
||||
unsigned maxLength = 64;
|
||||
std::vector<ShaderParameterOption> enumOptions;
|
||||
};
|
||||
|
||||
@@ -39,6 +43,7 @@ struct ShaderParameterValue
|
||||
std::vector<double> numberValues;
|
||||
bool booleanValue = false;
|
||||
std::string enumValue;
|
||||
std::string textValue;
|
||||
};
|
||||
|
||||
enum class TemporalHistorySource
|
||||
@@ -63,6 +68,13 @@ struct ShaderTextureAsset
|
||||
std::filesystem::file_time_type writeTime;
|
||||
};
|
||||
|
||||
struct ShaderFontAsset
|
||||
{
|
||||
std::string id;
|
||||
std::filesystem::path path;
|
||||
std::filesystem::file_time_type writeTime;
|
||||
};
|
||||
|
||||
struct ShaderPackage
|
||||
{
|
||||
std::string id;
|
||||
@@ -75,6 +87,7 @@ struct ShaderPackage
|
||||
std::filesystem::path manifestPath;
|
||||
std::vector<ShaderParameterDefinition> parameters;
|
||||
std::vector<ShaderTextureAsset> textureAssets;
|
||||
std::vector<ShaderFontAsset> fontAssets;
|
||||
TemporalSettings temporal;
|
||||
std::filesystem::file_time_type shaderWriteTime;
|
||||
std::filesystem::file_time_type manifestWriteTime;
|
||||
@@ -87,6 +100,7 @@ struct RuntimeRenderState
|
||||
std::vector<ShaderParameterDefinition> parameterDefinitions;
|
||||
std::map<std::string, ShaderParameterValue> parameterValues;
|
||||
std::vector<ShaderTextureAsset> textureAssets;
|
||||
std::vector<ShaderFontAsset> fontAssets;
|
||||
double timeSeconds = 0.0;
|
||||
double frameCount = 0.0;
|
||||
double mixAmount = 1.0;
|
||||
|
||||
@@ -32,6 +32,7 @@ cbuffer GlobalParams
|
||||
|
||||
Sampler2D<float4> gVideoInput;
|
||||
{{SOURCE_HISTORY_SAMPLERS}}{{TEMPORAL_HISTORY_SAMPLERS}}{{TEXTURE_SAMPLERS}}
|
||||
{{TEXT_SAMPLERS}}
|
||||
float4 sampleVideo(float2 tc)
|
||||
{
|
||||
return gVideoInput.Sample(tc);
|
||||
@@ -67,6 +68,7 @@ float4 sampleTemporalHistory(int framesAgo, float2 tc)
|
||||
}
|
||||
}
|
||||
|
||||
{{TEXT_HELPERS}}
|
||||
#include "{{USER_SHADER_INCLUDE}}"
|
||||
|
||||
[shader("fragment")]
|
||||
|
||||
114
shaders/balatro-swirl/shader.json
Normal file
114
shaders/balatro-swirl/shader.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"id": "balatro-swirl",
|
||||
"name": "Balatro Swirl",
|
||||
"description": "Animated painterly swirl background. Original by localthunk (https://www.playbalatro.com), adapted from https://www.shadertoy.com/view/XXtBRr.",
|
||||
"category": "Generative",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "spinRotation",
|
||||
"label": "Spin Rotation",
|
||||
"type": "float",
|
||||
"default": -2.0,
|
||||
"min": -8.0,
|
||||
"max": 8.0,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"id": "spinSpeed",
|
||||
"label": "Spin Speed",
|
||||
"type": "float",
|
||||
"default": 7.0,
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"id": "spinAmount",
|
||||
"label": "Spin Amount",
|
||||
"type": "float",
|
||||
"default": 0.25,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "spinEase",
|
||||
"label": "Spin Ease",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 3.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "pixelFilter",
|
||||
"label": "Pixel Filter",
|
||||
"type": "float",
|
||||
"default": 745.0,
|
||||
"min": 120.0,
|
||||
"max": 1600.0,
|
||||
"step": 1.0
|
||||
},
|
||||
{
|
||||
"id": "contrast",
|
||||
"label": "Contrast",
|
||||
"type": "float",
|
||||
"default": 3.5,
|
||||
"min": 0.5,
|
||||
"max": 8.0,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"id": "lighting",
|
||||
"label": "Lighting",
|
||||
"type": "float",
|
||||
"default": 0.4,
|
||||
"min": 0.0,
|
||||
"max": 1.5,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "offset",
|
||||
"label": "Offset",
|
||||
"type": "vec2",
|
||||
"default": [0.0, 0.0],
|
||||
"min": [-1.0, -1.0],
|
||||
"max": [1.0, 1.0],
|
||||
"step": [0.001, 0.001]
|
||||
},
|
||||
{
|
||||
"id": "colour1",
|
||||
"label": "Colour 1",
|
||||
"type": "color",
|
||||
"default": [0.871, 0.267, 0.231, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "colour2",
|
||||
"label": "Colour 2",
|
||||
"type": "color",
|
||||
"default": [0.0, 0.42, 0.706, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "colour3",
|
||||
"label": "Colour 3",
|
||||
"type": "color",
|
||||
"default": [0.086, 0.137, 0.145, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "isRotate",
|
||||
"label": "Rotate Field",
|
||||
"type": "bool",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"id": "sourceMix",
|
||||
"label": "Source Mix",
|
||||
"type": "float",
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
49
shaders/balatro-swirl/shader.slang
Normal file
49
shaders/balatro-swirl/shader.slang
Normal file
@@ -0,0 +1,49 @@
|
||||
float4 balatroSwirl(float2 screenSize, float2 screenCoords, float time)
|
||||
{
|
||||
const float pi = 3.14159265359;
|
||||
float safePixelFilter = max(pixelFilter, 1.0);
|
||||
float safeScreenLength = max(length(screenSize), 1.0);
|
||||
float pixelSize = safeScreenLength / safePixelFilter;
|
||||
float2 uv = (floor(screenCoords * (1.0 / pixelSize)) * pixelSize - 0.5 * screenSize) / safeScreenLength - offset;
|
||||
float uvLength = length(uv);
|
||||
|
||||
float speed = spinRotation * spinEase * 0.2;
|
||||
if (isRotate)
|
||||
speed = time * speed;
|
||||
speed += 302.2;
|
||||
|
||||
float newPixelAngle = atan2(uv.y, uv.x) + speed - spinEase * 20.0 * (spinAmount * uvLength + (1.0 - spinAmount));
|
||||
float2 mid = (screenSize / safeScreenLength) * 0.5;
|
||||
uv = float2(uvLength * cos(newPixelAngle) + mid.x, uvLength * sin(newPixelAngle) + mid.y) - mid;
|
||||
|
||||
uv *= 30.0;
|
||||
speed = time * spinSpeed;
|
||||
float2 uv2 = float2(uv.x + uv.y, uv.x + uv.y);
|
||||
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
uv2 += float2(sin(max(uv.x, uv.y)), sin(max(uv.x, uv.y))) + uv;
|
||||
uv += 0.5 * float2(cos(5.1123314 + 0.353 * uv2.y + speed * 0.131121), sin(uv2.x - 0.113 * speed));
|
||||
float warp = cos(uv.x + uv.y) - sin(uv.x * 0.711 - uv.y);
|
||||
uv -= float2(warp, warp);
|
||||
}
|
||||
|
||||
float contrastMod = 0.25 * contrast + 0.5 * spinAmount + 1.2;
|
||||
float paintRes = min(2.0, max(0.0, length(uv) * 0.035 * contrastMod));
|
||||
float c1p = max(0.0, 1.0 - contrastMod * abs(1.0 - paintRes));
|
||||
float c2p = max(0.0, 1.0 - contrastMod * abs(paintRes));
|
||||
float c3p = 1.0 - min(1.0, c1p + c2p);
|
||||
float light = (lighting - 0.2) * max(c1p * 5.0 - 4.0, 0.0) + lighting * max(c2p * 5.0 - 4.0, 0.0);
|
||||
|
||||
float safeContrast = max(contrast, 0.001);
|
||||
float4 base = (0.3 / safeContrast) * colour1;
|
||||
float4 paint = colour1 * c1p + colour2 * c2p + float4(c3p * colour3.rgb, c3p * colour1.a);
|
||||
return base + (1.0 - 0.3 / safeContrast) * paint + float4(light, light, light, light);
|
||||
}
|
||||
|
||||
float4 shadeVideo(ShaderContext context)
|
||||
{
|
||||
float2 screenSize = max(context.outputResolution, float2(1.0, 1.0));
|
||||
float4 swirl = balatroSwirl(screenSize, context.uv * screenSize, context.time);
|
||||
return saturate(lerp(swirl, context.sourceColor, sourceMix));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "black-and-white",
|
||||
"name": "Black and White",
|
||||
"description": "A minimal monochrome shader that converts the decoded video input to grayscale.",
|
||||
"category": "Built-in",
|
||||
"category": "Color",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": []
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "composition-guides",
|
||||
"name": "Composition Guides",
|
||||
"description": "Overlays rule-of-thirds guides and a center crosshair for camera alignment and framing.",
|
||||
"category": "Utility",
|
||||
"category": "Scopes & Guides",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "dvd-bounce",
|
||||
"name": "DVD Bounce",
|
||||
"description": "A transparent bouncing DVD logo sprite that changes color on each screen hit.",
|
||||
"category": "Built-in",
|
||||
"category": "Generative",
|
||||
"entryPoint": "shadeVideo",
|
||||
"textures": [
|
||||
{
|
||||
|
||||
84
shaders/ether/shader.json
Normal file
84
shaders/ether/shader.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"id": "ether",
|
||||
"name": "Ether",
|
||||
"description": "Raymarched ether field. Original by nimitz 2014 (twitter: @stormoid), adapted from https://www.shadertoy.com/view/MsjSW3.",
|
||||
"category": "Generative",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "speed",
|
||||
"label": "Speed",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 4.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "depth",
|
||||
"label": "Depth",
|
||||
"type": "float",
|
||||
"default": 2.5,
|
||||
"min": 0.2,
|
||||
"max": 8.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "density",
|
||||
"label": "Density",
|
||||
"type": "float",
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "brightness",
|
||||
"label": "Brightness",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 3.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "contrast",
|
||||
"label": "Contrast",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.25,
|
||||
"max": 3.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "offset",
|
||||
"label": "Offset",
|
||||
"type": "vec2",
|
||||
"default": [0.9, 0.5],
|
||||
"min": [0.0, 0.0],
|
||||
"max": [2.0, 2.0],
|
||||
"step": [0.001, 0.001]
|
||||
},
|
||||
{
|
||||
"id": "baseColor",
|
||||
"label": "Base Color",
|
||||
"type": "color",
|
||||
"default": [0.1, 0.3, 0.4, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "energyColor",
|
||||
"label": "Energy Color",
|
||||
"type": "color",
|
||||
"default": [1.0, 0.5, 0.6, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "sourceMix",
|
||||
"label": "Source Mix",
|
||||
"type": "float",
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
40
shaders/ether/shader.slang
Normal file
40
shaders/ether/shader.slang
Normal file
@@ -0,0 +1,40 @@
|
||||
float2x2 rotation2(float angle)
|
||||
{
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
return float2x2(c, -s, s, c);
|
||||
}
|
||||
|
||||
float etherMap(float3 p, float time)
|
||||
{
|
||||
p.xz = mul(rotation2(time * 0.4), p.xz);
|
||||
p.xy = mul(rotation2(time * 0.3), p.xy);
|
||||
|
||||
float3 q = p * 2.0 + time;
|
||||
float wave = sin(q.x + sin(q.z + sin(q.y))) * 0.5;
|
||||
return length(p + float3(sin(time * 0.7), sin(time * 0.7), sin(time * 0.7))) * log(length(p) + 1.0) + wave - 1.0;
|
||||
}
|
||||
|
||||
float4 shadeVideo(ShaderContext context)
|
||||
{
|
||||
float2 resolution = max(context.outputResolution, float2(1.0, 1.0));
|
||||
float2 fragCoord = context.uv * resolution;
|
||||
float2 p = fragCoord / resolution.y - offset;
|
||||
float time = context.time * speed;
|
||||
|
||||
float3 color = float3(0.0, 0.0, 0.0);
|
||||
float d = depth;
|
||||
|
||||
for (int i = 0; i <= 5; ++i)
|
||||
{
|
||||
float3 rayPosition = float3(0.0, 0.0, 5.0) + normalize(float3(p, -1.0)) * d;
|
||||
float rz = etherMap(rayPosition, time);
|
||||
float f = clamp((rz - etherMap(rayPosition + float3(0.1, 0.1, 0.1), time)) * 0.5, -0.1, 1.0);
|
||||
float3 light = baseColor.rgb + energyColor.rgb * 5.0 * f;
|
||||
color = color * light + smoothstep(2.5, 0.0, rz) * density * light;
|
||||
d += min(rz, 1.0);
|
||||
}
|
||||
|
||||
color = pow(max(color * brightness, float3(0.0, 0.0, 0.0)), float3(1.0 / max(contrast, 0.001)));
|
||||
return saturate(lerp(float4(color, 1.0), context.sourceColor, sourceMix));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "false-color",
|
||||
"name": "False Color",
|
||||
"description": "Maps luminance ranges to exposure-assist colors for camera and shader debugging.",
|
||||
"category": "Utility",
|
||||
"category": "Color",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "gaussian-blur",
|
||||
"name": "Gaussian Blur",
|
||||
"description": "Applies a simple Gaussian-style blur to the decoded video input.",
|
||||
"category": "Built-in",
|
||||
"category": "Transform",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "greenscreen-key",
|
||||
"name": "Greenscreen Key",
|
||||
"description": "Keys out a green screen background and outputs transparent alpha for compositing.",
|
||||
"category": "Built-in",
|
||||
"category": "Keying",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
42
shaders/lift-gamma-gain/shader.json
Normal file
42
shaders/lift-gamma-gain/shader.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"id": "lift-gamma-gain",
|
||||
"name": "Lift Gamma Gain",
|
||||
"description": "Basic color grading controls for shadows, midtones, highlights, and overall RGB offset.",
|
||||
"category": "Color",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "lift",
|
||||
"label": "Lift",
|
||||
"type": "color",
|
||||
"default": [0.5, 0.5, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "gamma",
|
||||
"label": "Gamma",
|
||||
"type": "color",
|
||||
"default": [0.5, 0.5, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "gain",
|
||||
"label": "Gain",
|
||||
"type": "color",
|
||||
"default": [0.5, 0.5, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "offset",
|
||||
"label": "Offset",
|
||||
"type": "color",
|
||||
"default": [0.5, 0.5, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "strength",
|
||||
"label": "Strength",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
20
shaders/lift-gamma-gain/shader.slang
Normal file
20
shaders/lift-gamma-gain/shader.slang
Normal file
@@ -0,0 +1,20 @@
|
||||
float3 applyLiftGammaGainOffset(float3 color)
|
||||
{
|
||||
float3 liftAdjust = (lift.rgb - 0.5) * 0.5;
|
||||
float3 offsetAdjust = (offset.rgb - 0.5) * 0.5;
|
||||
float3 gammaAdjust = exp2((gamma.rgb - 0.5) * 2.0);
|
||||
float3 gainAdjust = exp2((gain.rgb - 0.5) * 2.0);
|
||||
|
||||
float3 lifted = color + liftAdjust;
|
||||
float3 gained = lifted * gainAdjust;
|
||||
float3 corrected = pow(saturate(gained), 1.0 / max(gammaAdjust, float3(0.001)));
|
||||
return corrected + offsetAdjust;
|
||||
}
|
||||
|
||||
float4 shadeVideo(ShaderContext context)
|
||||
{
|
||||
float4 source = context.sourceColor;
|
||||
float3 graded = applyLiftGammaGainOffset(source.rgb);
|
||||
source.rgb = lerp(source.rgb, graded, strength);
|
||||
return saturate(source);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "pixelate",
|
||||
"name": "Pixelate",
|
||||
"description": "Reduces the effective X and Y pixel count independently to create a low-resolution blocky image.",
|
||||
"category": "Utility",
|
||||
"category": "Transform",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "safe-area-guides",
|
||||
"name": "Safe Area Guides",
|
||||
"description": "Overlays broadcast action/title safe guides plus optional center marks and aspect matte.",
|
||||
"category": "Utility",
|
||||
"category": "Scopes & Guides",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{ "id": "showActionSafe", "label": "Action Safe", "type": "bool", "default": true },
|
||||
|
||||
90
shaders/singularity/shader.json
Normal file
90
shaders/singularity/shader.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "singularity",
|
||||
"name": "Singularity",
|
||||
"description": "Whirling blackhole and accretion disk. Original by XorDev, adapted from https://www.shadertoy.com/view/3csSWB.",
|
||||
"category": "Generative",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "speed",
|
||||
"label": "Speed",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 4.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "scale",
|
||||
"label": "Scale",
|
||||
"type": "float",
|
||||
"default": 0.7,
|
||||
"min": 0.25,
|
||||
"max": 1.5,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "strength",
|
||||
"label": "Gravity",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.1,
|
||||
"max": 3.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "ringRadius",
|
||||
"label": "Ring Radius",
|
||||
"type": "float",
|
||||
"default": 0.7,
|
||||
"min": 0.2,
|
||||
"max": 1.4,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "tightness",
|
||||
"label": "Tightness",
|
||||
"type": "float",
|
||||
"default": 1.35,
|
||||
"min": 0.5,
|
||||
"max": 3.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "brightness",
|
||||
"label": "Brightness",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.1,
|
||||
"max": 4.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "colorShift",
|
||||
"label": "Color Shift",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": -2.0,
|
||||
"max": 2.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "center",
|
||||
"label": "Center",
|
||||
"type": "vec2",
|
||||
"default": [0.0, 0.0],
|
||||
"min": [-1.0, -1.0],
|
||||
"max": [1.0, 1.0],
|
||||
"step": [0.001, 0.001]
|
||||
},
|
||||
{
|
||||
"id": "sourceMix",
|
||||
"label": "Source Mix",
|
||||
"type": "float",
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
48
shaders/singularity/shader.slang
Normal file
48
shaders/singularity/shader.slang
Normal file
@@ -0,0 +1,48 @@
|
||||
float2 singularitySpiral(float2 c, float time, float iterator)
|
||||
{
|
||||
float radiusSq = max(dot(c, c), 0.0001);
|
||||
float angle = 0.5 * log(radiusSq) + time * iterator;
|
||||
return float2(
|
||||
c.x * cos(angle + 0.0) + c.y * cos(angle + 11.0),
|
||||
c.x * cos(angle + 33.0) + c.y * cos(angle + 0.0)) / max(iterator, 0.001);
|
||||
}
|
||||
|
||||
float4 shadeVideo(ShaderContext context)
|
||||
{
|
||||
float2 resolution = max(context.outputResolution, float2(1.0, 1.0));
|
||||
float2 fragCoord = context.uv * resolution;
|
||||
float safeScale = max(scale, 0.001);
|
||||
float safeRingRadius = max(ringRadius, 0.001);
|
||||
float safeTightness = max(tightness, 0.001);
|
||||
float time = context.time * speed;
|
||||
|
||||
float2 p = (fragCoord + fragCoord - resolution) / resolution.y / safeScale;
|
||||
p -= center;
|
||||
|
||||
float iterator = 0.2;
|
||||
float2 diagonal = float2(-1.0, 1.0);
|
||||
float2 blackholeCenter = p - iterator * diagonal;
|
||||
float gravity = iterator * strength / max(dot(blackholeCenter, blackholeCenter), 0.0001);
|
||||
float2 skew = diagonal / (0.1 + gravity);
|
||||
|
||||
float2 c = float2(p.x + p.y, p.x * skew.x + p.y * skew.y);
|
||||
float2 v = singularitySpiral(c, time, iterator);
|
||||
float2 waves = float2(0.0001, 0.0001);
|
||||
|
||||
for (; iterator < 9.0; iterator += 1.0)
|
||||
{
|
||||
waves += 1.0 + sin(v);
|
||||
v += 0.7 * sin(v.yx * iterator + time) / iterator + 0.5;
|
||||
}
|
||||
|
||||
float diskRadius = length(sin(v / 0.3) * 0.4 + c * float2(2.0, 4.0));
|
||||
float disk = 2.0 + diskRadius * diskRadius * (0.25 * safeTightness) - diskRadius;
|
||||
float centerDarkness = 0.5 + 1.0 / max(dot(c, c), 0.0001);
|
||||
float rim = 0.025 + abs(length(p) - safeRingRadius) * safeTightness;
|
||||
float4 redBlueGradient = exp(c.x * float4(0.6, -0.4, -1.0, 0.0) * colorShift);
|
||||
float4 waveColor = waves.xyyx;
|
||||
|
||||
float4 color = 1.0 - exp(-redBlueGradient / max(waveColor, float4(0.0001, 0.0001, 0.0001, 0.0001)) / disk / centerDarkness / rim * brightness);
|
||||
color.a = 1.0;
|
||||
return saturate(lerp(color, context.sourceColor, sourceMix));
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"id": "studio-color",
|
||||
"name": "Studio Color",
|
||||
"description": "A built-in sample shader package that demonstrates the runtime parameter contract.",
|
||||
"category": "Built-in",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "brightness",
|
||||
"label": "Brightness",
|
||||
"type": "float",
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"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]
|
||||
},
|
||||
{
|
||||
"id": "tint",
|
||||
"label": "Tint",
|
||||
"type": "color",
|
||||
"default": [1.0, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "invert",
|
||||
"label": "Invert",
|
||||
"type": "bool",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"id": "mode",
|
||||
"label": "Mode",
|
||||
"type": "enum",
|
||||
"default": "normal",
|
||||
"options": [
|
||||
{ "value": "normal", "label": "Normal" },
|
||||
{ "value": "luma", "label": "Luma" },
|
||||
{ "value": "posterize", "label": "Posterize" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
float4 shadeVideo(ShaderContext context)
|
||||
{
|
||||
float2 uv = clamp(context.uv + offset, float2(0.0, 0.0), float2(1.0, 1.0));
|
||||
float4 color = sampleVideo(uv);
|
||||
|
||||
color.rgb *= brightness;
|
||||
color *= tint;
|
||||
|
||||
if (invert)
|
||||
color.rgb = 1.0 - color.rgb;
|
||||
|
||||
if (mode == 1)
|
||||
{
|
||||
float luma = dot(color.rgb, float3(0.2126, 0.7152, 0.0722));
|
||||
color.rgb = float3(luma, luma, luma);
|
||||
}
|
||||
else if (mode == 2)
|
||||
{
|
||||
color.rgb = floor(color.rgb * 4.0) / 4.0;
|
||||
}
|
||||
|
||||
return saturate(color);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "temporal-ghost-trail",
|
||||
"name": "Temporal Ghost Trail",
|
||||
"description": "Blends older pre-layer input frames into the current layer input for a soft temporal trail.",
|
||||
"category": "Built-in",
|
||||
"category": "Temporal",
|
||||
"entryPoint": "shadeVideo",
|
||||
"temporal": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "temporal-low-fps",
|
||||
"name": "Temporal Low FPS",
|
||||
"description": "Holds older source frames to create a deliberate choppy playback look.",
|
||||
"category": "Built-in",
|
||||
"category": "Temporal",
|
||||
"entryPoint": "shadeVideo",
|
||||
"temporal": {
|
||||
"enabled": true,
|
||||
|
||||
93
shaders/text-overlay/fonts/OFL.txt
Normal file
93
shaders/text-overlay/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
shaders/text-overlay/fonts/Roboto-Regular.ttf
Normal file
BIN
shaders/text-overlay/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
71
shaders/text-overlay/shader.json
Normal file
71
shaders/text-overlay/shader.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"id": "text-overlay",
|
||||
"name": "Text Overlay",
|
||||
"description": "Single-line live text overlay using the runtime text SDF helper functions.",
|
||||
"category": "Scopes & Guides",
|
||||
"entryPoint": "shadeVideo",
|
||||
"fonts": [
|
||||
{
|
||||
"id": "roboto",
|
||||
"path": "fonts/Roboto-Regular.ttf"
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"id": "titleText",
|
||||
"label": "Text",
|
||||
"type": "text",
|
||||
"default": "VIDEO SHADER",
|
||||
"font": "roboto",
|
||||
"maxLength": 64
|
||||
},
|
||||
{
|
||||
"id": "position",
|
||||
"label": "Position",
|
||||
"type": "vec2",
|
||||
"default": [0.08, 0.12],
|
||||
"min": [0.0, 0.0],
|
||||
"max": [1.0, 1.0],
|
||||
"step": [0.001, 0.001]
|
||||
},
|
||||
{
|
||||
"id": "scale",
|
||||
"label": "Scale",
|
||||
"type": "float",
|
||||
"default": 0.42,
|
||||
"min": 0.1,
|
||||
"max": 1.5,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "fillColor",
|
||||
"label": "Fill",
|
||||
"type": "color",
|
||||
"default": [1.0, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "outlineColor",
|
||||
"label": "Outline",
|
||||
"type": "color",
|
||||
"default": [0.0, 0.0, 0.0, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "outlineWidth",
|
||||
"label": "Outline Width",
|
||||
"type": "float",
|
||||
"default": 0.12,
|
||||
"min": 0.0,
|
||||
"max": 0.5,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "softness",
|
||||
"label": "Softness",
|
||||
"type": "float",
|
||||
"default": 0.04,
|
||||
"min": 0.0,
|
||||
"max": 0.3,
|
||||
"step": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
46
shaders/text-overlay/shader.slang
Normal file
46
shaders/text-overlay/shader.slang
Normal file
@@ -0,0 +1,46 @@
|
||||
float alphaOver(float baseAlpha, float overAlpha)
|
||||
{
|
||||
return overAlpha + baseAlpha * (1.0 - overAlpha);
|
||||
}
|
||||
|
||||
float4 compositeOver(float4 baseColor, float4 overColor)
|
||||
{
|
||||
float outAlpha = alphaOver(baseColor.a, overColor.a);
|
||||
float3 outRgb = overColor.rgb + baseColor.rgb * (1.0 - overColor.a);
|
||||
return float4(outRgb, outAlpha);
|
||||
}
|
||||
|
||||
float4 shadeVideo(ShaderContext context)
|
||||
{
|
||||
float2 resolution = max(context.outputResolution, float2(1.0, 1.0));
|
||||
float aspect = resolution.x / resolution.y;
|
||||
float2 textSize = float2(0.72 * scale, 0.09 * scale * aspect);
|
||||
float2 safeTextSize = max(textSize, float2(0.0001, 0.0001));
|
||||
float2 textUv = (context.uv - position) / safeTextSize;
|
||||
bool insideTextRect = textUv.x >= 0.0 && textUv.x <= 1.0 && textUv.y >= 0.0 && textUv.y <= 1.0;
|
||||
|
||||
float mask = insideTextRect ? sampleTitleText(textUv) : 0.0;
|
||||
float edge = 0.02;
|
||||
float aa = max(fwidth(mask) * 1.5, 0.002);
|
||||
float fill = smoothstep(edge - aa, edge + aa, mask);
|
||||
float shadowRadius = min((outlineWidth + softness) * 0.025, 0.018);
|
||||
float shadow = 0.0;
|
||||
if (shadowRadius > 0.0001)
|
||||
{
|
||||
shadow = max(shadow, sampleTitleText(textUv + float2(shadowRadius, shadowRadius)));
|
||||
shadow = max(shadow, sampleTitleText(textUv + float2(-shadowRadius, shadowRadius)));
|
||||
shadow = max(shadow, sampleTitleText(textUv + float2(shadowRadius, -shadowRadius)));
|
||||
shadow = max(shadow, sampleTitleText(textUv + float2(-shadowRadius, -shadowRadius)));
|
||||
}
|
||||
shadow = smoothstep(edge - aa, edge + aa, shadow) * (0.35 + softness);
|
||||
float outlineAlpha = saturate(shadow * (1.0 - fill)) * outlineColor.a;
|
||||
float fillAlpha = fill * fillColor.a;
|
||||
float textAlpha = max(fillAlpha, outlineAlpha);
|
||||
if (textAlpha <= 0.0001)
|
||||
return context.sourceColor;
|
||||
|
||||
float4 base = context.sourceColor;
|
||||
float4 outlineLayer = float4(outlineColor.rgb * outlineAlpha, outlineAlpha);
|
||||
float4 fillLayer = float4(fillColor.rgb * fillAlpha, fillAlpha);
|
||||
return saturate(compositeOver(compositeOver(base, outlineLayer), fillLayer));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "vhs",
|
||||
"name": "VHS",
|
||||
"description": "VHS with wiggle, smear, and YIQ-style color separation inspired by the Godot shader reference.",
|
||||
"category": "Built-in",
|
||||
"description": "VHS with wiggle, smear, and YIQ-style color separation inspired by nostalgic analog references.",
|
||||
"category": "Glitch",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -95,6 +95,24 @@
|
||||
"max": 0.2,
|
||||
"step": 0.005
|
||||
},
|
||||
{
|
||||
"id": "staticAmount",
|
||||
"label": "Analog Static",
|
||||
"type": "float",
|
||||
"default": 0.045,
|
||||
"min": 0.0,
|
||||
"max": 0.25,
|
||||
"step": 0.005
|
||||
},
|
||||
{
|
||||
"id": "staticLines",
|
||||
"label": "Static Lines",
|
||||
"type": "float",
|
||||
"default": 0.65,
|
||||
"min": 0.0,
|
||||
"max": 1.5,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"id": "noiseSize",
|
||||
"label": "Noise Size",
|
||||
|
||||
@@ -44,6 +44,13 @@ float noiseHash(float2 p)
|
||||
return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
// Gold Noise (c)2015 dcerisano@standard3d.com, adapted for Slang.
|
||||
float goldNoise(float2 xy, float seed)
|
||||
{
|
||||
const float phi = 1.61803398874989484820459;
|
||||
return frac(tan(distance(xy * phi, xy) * seed) * xy.x);
|
||||
}
|
||||
|
||||
float grainScalar(float2 uv)
|
||||
{
|
||||
return frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453);
|
||||
@@ -63,6 +70,56 @@ float3 animatedChromaGrain(float2 uv, float time, float2 outputResolution, float
|
||||
return float3(r, g, b) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
float valueNoise2(float2 p)
|
||||
{
|
||||
float2 cell = floor(p);
|
||||
float2 f = frac(p);
|
||||
float2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
float a = noiseHash(cell);
|
||||
float b = noiseHash(cell + float2(1.0, 0.0));
|
||||
float c = noiseHash(cell + float2(0.0, 1.0));
|
||||
float d = noiseHash(cell + float2(1.0, 1.0));
|
||||
|
||||
return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float tapeLineNoise(float2 uv, float time, float2 outputResolution)
|
||||
{
|
||||
float y = floor(uv.y * outputResolution.y);
|
||||
float slowLine = valueNoise2(float2(y * 0.021, floor(time * 10.0)));
|
||||
float fastLine = noiseHash(float2(y * 1.73, floor(time * 59.94)));
|
||||
float line = (slowLine * 0.7 + fastLine * 0.3) * 2.0 - 1.0;
|
||||
|
||||
float band = sin(uv.y * outputResolution.y * 0.42 + time * 36.0);
|
||||
return line * (0.65 + 0.35 * band);
|
||||
}
|
||||
|
||||
float3 analogStatic(float2 uv, float time, float2 outputResolution)
|
||||
{
|
||||
float2 safeResolution = max(outputResolution, float2(1.0, 1.0));
|
||||
float2 pixel = floor(uv * safeResolution / max(noiseSize, 0.25));
|
||||
float frame = floor(time * 59.94);
|
||||
float seed = frac(time);
|
||||
|
||||
float2 goldPixel = pixel + float2(0.37, 0.61) + frame;
|
||||
float snowA = goldNoise(goldPixel, seed + 0.1);
|
||||
float snowB = goldNoise(goldPixel * float2(0.37, 2.11) + float2(19.0, 41.0), seed + 0.2);
|
||||
float snowC = goldNoise(goldPixel * float2(1.73, 0.81) + float2(53.0, 7.0), seed + 0.3);
|
||||
float snow = (snowA * 0.72 + snowB * 0.28) * 2.0 - 1.0;
|
||||
|
||||
float lineNoise = tapeLineNoise(uv, time, safeResolution);
|
||||
float dropoutSeed = goldNoise(float2(floor(uv.y * safeResolution.y * 0.25) + 1.0, frame + 2.0), seed + 0.4);
|
||||
float dropout = smoothstep(0.965, 1.0, dropoutSeed);
|
||||
float fleck = smoothstep(0.988, 1.0, snowA) - smoothstep(0.0, 0.012, snowC);
|
||||
|
||||
float scan = sin(uv.y * safeResolution.y * 3.14159265);
|
||||
float scanMask = 0.55 + 0.45 * scan * scan;
|
||||
float lumaNoise = snow * 0.55 + lineNoise * staticLines * 0.45 + fleck * 0.7 + dropout * lineNoise * 1.2;
|
||||
|
||||
return float3(lumaNoise * scanMask, lumaNoise * 0.42, lumaNoise * 0.72);
|
||||
}
|
||||
|
||||
float3 softBloom(float2 uv, float2 outputResolution, float radius)
|
||||
{
|
||||
float2 pixel = 1.0 / max(outputResolution, float2(1.0, 1.0));
|
||||
@@ -164,6 +221,11 @@ float4 shadeVideo(ShaderContext context)
|
||||
color.rg = lerp(color.rg, float2(color.r, color.g) + speckle.xy * noiseAmount * 0.2 * chunkiness, 0.35);
|
||||
color.b = lerp(color.b, color.b + speckle.z * noiseAmount * 0.28 * chunkiness, 0.5);
|
||||
|
||||
float3 staticNoise = analogStatic(context.uv, context.time, context.outputResolution);
|
||||
float staticMask = lerp(0.45, 1.15, 1.0 - saturate(luma));
|
||||
color += staticNoise * staticAmount * staticMask;
|
||||
color = lerp(color, color + float3(staticNoise.r * 0.22, staticNoise.g * 0.08, -staticNoise.b * 0.08), saturate(staticAmount * 2.0));
|
||||
|
||||
float3 grayscale = float3(luma, luma, luma);
|
||||
color = lerp(color, grayscale, fadeAmount * 0.18);
|
||||
color = color * (1.0 - fadeAmount * 0.08) + float3(0.055, 0.055, 0.065) * fadeAmount;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "video-cube",
|
||||
"name": "Video Cube",
|
||||
"description": "Maps the live video onto the faces of a rotating cube in screen space.",
|
||||
"category": "Built-in",
|
||||
"category": "Transform",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "video-transform",
|
||||
"name": "Video Transform",
|
||||
"description": "Zooms, pans, and rotates the video by remapping output pixels back into source UV space.",
|
||||
"category": "Utility",
|
||||
"category": "Transform",
|
||||
"entryPoint": "shadeVideo",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "waveform-overlay",
|
||||
"name": "Waveform Overlay",
|
||||
"description": "Draws a lightweight luma waveform overlay along the bottom of the video.",
|
||||
"category": "Utility",
|
||||
"category": "Scopes & Guides",
|
||||
"entryPoint": "shadeVideo",
|
||||
"textures": [
|
||||
{
|
||||
|
||||
@@ -100,6 +100,26 @@ void TestEnumAndDefaults()
|
||||
error.clear();
|
||||
Expect(!NormalizeAndValidateParameterValue(definition, JsonValue("other"), value, error), "enum rejects unknown options");
|
||||
}
|
||||
|
||||
void TestTextNormalization()
|
||||
{
|
||||
ShaderParameterDefinition definition;
|
||||
definition.id = "titleText";
|
||||
definition.type = ShaderParameterType::Text;
|
||||
definition.defaultTextValue = "DEFAULT";
|
||||
definition.maxLength = 6;
|
||||
|
||||
ShaderParameterValue defaultValue = DefaultValueForDefinition(definition);
|
||||
Expect(defaultValue.textValue == "DEFAUL", "text default is clamped to max length");
|
||||
|
||||
ShaderParameterValue value;
|
||||
std::string error;
|
||||
Expect(NormalizeAndValidateParameterValue(definition, JsonValue("ABC\tDEF\x01GHI"), value, error), "text accepts string values");
|
||||
Expect(value.textValue == "ABCDEF", "text drops non-printable characters and clamps length");
|
||||
|
||||
error.clear();
|
||||
Expect(!NormalizeAndValidateParameterValue(definition, JsonValue(12.0), value, error), "text rejects non-string values");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
@@ -108,6 +128,7 @@ int main()
|
||||
TestFloatNormalization();
|
||||
TestVectorNormalization();
|
||||
TestEnumAndDefaults();
|
||||
TestTextNormalization();
|
||||
|
||||
if (gFailures != 0)
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ void TestValidManifest()
|
||||
{
|
||||
const std::filesystem::path root = MakeTestRoot();
|
||||
WriteFile(root / "look" / "mask.png", "not a real png, but enough for existence checks");
|
||||
WriteFile(root / "look" / "Inter.ttf", "not a real font, but enough for existence checks");
|
||||
WriteShaderPackage(root, "look", R"({
|
||||
"id": "look-01",
|
||||
"name": "Look 01",
|
||||
@@ -54,9 +55,11 @@ void TestValidManifest()
|
||||
"category": "Tests",
|
||||
"entryPoint": "shadeVideo",
|
||||
"textures": [{ "id": "maskTex", "path": "mask.png" }],
|
||||
"fonts": [{ "id": "inter", "path": "Inter.ttf" }],
|
||||
"temporal": { "enabled": true, "historySource": "source", "historyLength": 8 },
|
||||
"parameters": [
|
||||
{ "id": "gain", "label": "Gain", "type": "float", "default": 0.5, "min": 0, "max": 1 },
|
||||
{ "id": "titleText", "label": "Title", "type": "text", "default": "LIVE", "font": "inter", "maxLength": 32 },
|
||||
{ "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [
|
||||
{ "value": "soft", "label": "Soft" },
|
||||
{ "value": "hard", "label": "Hard" }
|
||||
@@ -70,8 +73,29 @@ void TestValidManifest()
|
||||
Expect(registry.ParseManifest(root / "look" / "shader.json", package, error), "valid manifest parses");
|
||||
Expect(package.id == "look-01", "manifest id is preserved");
|
||||
Expect(package.textureAssets.size() == 1 && package.textureAssets[0].id == "maskTex", "texture assets parse");
|
||||
Expect(package.fontAssets.size() == 1 && package.fontAssets[0].id == "inter", "font assets parse");
|
||||
Expect(package.temporal.enabled && package.temporal.effectiveHistoryLength == 4, "temporal history is capped");
|
||||
Expect(package.parameters.size() == 2, "parameters parse");
|
||||
Expect(package.parameters.size() == 3, "parameters parse");
|
||||
Expect(package.parameters[1].type == ShaderParameterType::Text && package.parameters[1].defaultTextValue == "LIVE", "text parameter parses");
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
void TestMissingFontAsset()
|
||||
{
|
||||
const std::filesystem::path root = MakeTestRoot();
|
||||
WriteShaderPackage(root, "bad-font", R"({
|
||||
"id": "bad-font",
|
||||
"name": "Bad Font",
|
||||
"fonts": [{ "id": "missingFont", "path": "missing.ttf" }],
|
||||
"parameters": []
|
||||
})");
|
||||
|
||||
ShaderPackageRegistry registry(4);
|
||||
ShaderPackage package;
|
||||
std::string error;
|
||||
Expect(!registry.ParseManifest(root / "bad-font" / "shader.json", package, error), "missing font asset is rejected");
|
||||
Expect(error.find("font asset not found") != std::string::npos, "missing font error is clear");
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
@@ -115,6 +139,7 @@ void TestDuplicateScan()
|
||||
int main()
|
||||
{
|
||||
TestValidManifest();
|
||||
TestMissingFontAsset();
|
||||
TestInvalidManifest();
|
||||
TestDuplicateScan();
|
||||
|
||||
|
||||
56
ui/package-lock.json
generated
56
ui/package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "video-shader-control-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-wheel": "^2.10.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -251,6 +253,16 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -1188,6 +1200,50 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uiw/color-convert": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.10.1.tgz",
|
||||
"integrity": "sha512-/Z3YfBiX+SErRM59yQH88Id+Xy/k10nnkfTuqhX6RB2yYUcG57DoFqb6FudhiQ5fwzKvKf1k4xq9lfT1UTFUKQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-color-wheel": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-color-wheel/-/react-color-wheel-2.10.1.tgz",
|
||||
"integrity": "sha512-LnO7CAsfSDfOSUFUeedNycVtx+ODpkGgcgxAT4QindU2BplTcl3mxQJxC1SIszq9zFdGK+1nXhG8N8ZmgvmVYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "2.10.1",
|
||||
"@uiw/react-drag-event-interactive": "2.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0",
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@uiw/react-drag-event-interactive": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.10.1.tgz",
|
||||
"integrity": "sha512-eArtX/XdSrg5aQs8CV0vne9vChybw2GkNZCP9H68zjBBzucuYgjURqKBJ/+3jid06YpRZ5zz/YTnAlySqOt0Ag==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": ">=7.19.0",
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-wheel": "^2.10.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { GripVertical, Trash2 } from "lucide-react";
|
||||
|
||||
import { postJson } from "../api/controlApi";
|
||||
import { ParameterField } from "./ParameterField";
|
||||
import { ShaderPicker } from "./ShaderPicker";
|
||||
|
||||
export function LayerCard({
|
||||
layer,
|
||||
@@ -19,6 +18,8 @@ export function LayerCard({
|
||||
onRemove,
|
||||
onLayerParameterChange,
|
||||
}) {
|
||||
const selectedShader = shaders.find((shader) => shader.id === layer.shaderId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`layer-card${expanded ? " layer-card--expanded" : ""}${isDragging ? " layer-card--dragging" : ""}${isDropTarget ? " layer-card--drop-target" : ""}`}
|
||||
@@ -90,20 +91,6 @@ export function LayerCard({
|
||||
|
||||
{expanded ? (
|
||||
<div className="layer-card__body">
|
||||
<div className="layer-card__field">
|
||||
<ShaderPicker
|
||||
id={`shader-${layer.id}`}
|
||||
shaders={shaders}
|
||||
value={layer.shaderId}
|
||||
onChange={(shaderId) =>
|
||||
postJson("/api/layers/set-shader", {
|
||||
layerId: layer.id,
|
||||
shaderId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layer.temporal?.enabled ? (
|
||||
<div className="layer-card__field">
|
||||
<label>Temporal</label>
|
||||
@@ -118,6 +105,13 @@ export function LayerCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedShader?.description ? (
|
||||
<div className="shader-description">
|
||||
<div className="shader-description__meta">{selectedShader.category || "Shader"}</div>
|
||||
<p>{selectedShader.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="layer-card__subheader">
|
||||
<h3>Parameters</h3>
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import Wheel from "@uiw/react-color-wheel";
|
||||
import { hsvaToRgba, rgbaToHsva } from "@uiw/color-convert";
|
||||
import { Copy, RotateCcw } from "lucide-react";
|
||||
|
||||
import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
|
||||
import { ParameterValueDisplay } from "./ParameterValueDisplay";
|
||||
|
||||
function ParameterHeader({ layer, parameter }) {
|
||||
function valuesMatch(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function ParameterHeader({ layer, parameter, onReset, resetDisabled }) {
|
||||
const layerKey = layer.shaderId || layer.shaderName || layer.id;
|
||||
const oscRoute = `/VideoShaderToys/${layerKey}/${parameter.id}`;
|
||||
|
||||
@@ -26,6 +32,16 @@ function ParameterHeader({ layer, parameter }) {
|
||||
<span>{oscRoute}</span>
|
||||
<Copy size={13} strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="parameter__reset"
|
||||
title={`Reset ${parameter.label}`}
|
||||
aria-label={`Reset ${parameter.label}`}
|
||||
disabled={resetDisabled}
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw size={13} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,13 +64,26 @@ function colorValueToHex(value) {
|
||||
return `#${colorComponentToHex(values[0])}${colorComponentToHex(values[1])}${colorComponentToHex(values[2])}`;
|
||||
}
|
||||
|
||||
function hexToColorValue(hex, alpha) {
|
||||
const sanitized = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex.slice(1) : "000000";
|
||||
function colorValueToHsva(value) {
|
||||
const values = [...(value ?? [])];
|
||||
while (values.length < 4) {
|
||||
values.push(values.length === 3 ? 1 : 0);
|
||||
}
|
||||
return rgbaToHsva({
|
||||
r: Math.round(clamp01(values[0]) * 255),
|
||||
g: Math.round(clamp01(values[1]) * 255),
|
||||
b: Math.round(clamp01(values[2]) * 255),
|
||||
a: clamp01(values[3]),
|
||||
});
|
||||
}
|
||||
|
||||
function hsvaToColorValue(hsva, alpha) {
|
||||
const rgba = hsvaToRgba({ ...hsva, a: clamp01(alpha ?? hsva.a ?? 1) });
|
||||
return [
|
||||
parseInt(sanitized.slice(0, 2), 16) / 255,
|
||||
parseInt(sanitized.slice(2, 4), 16) / 255,
|
||||
parseInt(sanitized.slice(4, 6), 16) / 255,
|
||||
clamp01(alpha ?? 1),
|
||||
clamp01(rgba.r / 255),
|
||||
clamp01(rgba.g / 255),
|
||||
clamp01(rgba.b / 255),
|
||||
clamp01(alpha ?? rgba.a ?? 1),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -69,7 +98,21 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
|
||||
sendValue,
|
||||
} = useThrottledParameterValue(parameter, onParameterChange);
|
||||
|
||||
const header = <ParameterHeader layer={layer} parameter={parameter} />;
|
||||
const defaultValue = parameter.defaultValue;
|
||||
const resetDisabled = defaultValue === undefined || valuesMatch(draftValue, defaultValue);
|
||||
const resetParameter = () => {
|
||||
if (defaultValue !== undefined) {
|
||||
sendValue(defaultValue);
|
||||
}
|
||||
};
|
||||
const header = (
|
||||
<ParameterHeader
|
||||
layer={layer}
|
||||
parameter={parameter}
|
||||
resetDisabled={resetDisabled}
|
||||
onReset={resetParameter}
|
||||
/>
|
||||
);
|
||||
|
||||
if (parameter.type === "float") {
|
||||
return (
|
||||
@@ -151,14 +194,21 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
|
||||
return (
|
||||
<section className="parameter">
|
||||
{header}
|
||||
<div className="parameter__color-row">
|
||||
<input
|
||||
type="color"
|
||||
value={colorValueToHex(values)}
|
||||
onFocus={beginInteraction}
|
||||
onChange={(event) => sendValue(hexToColorValue(event.target.value, values[3]))}
|
||||
<div className="parameter__wheel-row">
|
||||
<div
|
||||
className="parameter__wheel"
|
||||
onPointerDown={beginInteraction}
|
||||
onPointerUp={endInteraction}
|
||||
onPointerCancel={endInteraction}
|
||||
onBlur={endInteraction}
|
||||
/>
|
||||
>
|
||||
<Wheel
|
||||
color={colorValueToHsva(values)}
|
||||
width={132}
|
||||
height={132}
|
||||
onChange={(color) => scheduleSendValue(hsvaToColorValue(color.hsva, values[3]))}
|
||||
/>
|
||||
</div>
|
||||
<label className="parameter__alpha">
|
||||
<span>Alpha</span>
|
||||
<input
|
||||
@@ -176,6 +226,7 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
|
||||
onBlur={endInteraction}
|
||||
/>
|
||||
</label>
|
||||
<div className="parameter__swatch" style={{ background: colorValueToHex(values) }} aria-hidden="true" />
|
||||
</div>
|
||||
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
|
||||
</section>
|
||||
@@ -222,5 +273,23 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (parameter.type === "text") {
|
||||
return (
|
||||
<section className="parameter">
|
||||
{header}
|
||||
<input
|
||||
type="text"
|
||||
maxLength={parameter.maxLength ?? 64}
|
||||
placeholder={parameter.defaultValue ? `Default: ${parameter.defaultValue}` : ""}
|
||||
value={draftValue ?? ""}
|
||||
onFocus={beginInteraction}
|
||||
onChange={(event) => sendValue(event.target.value)}
|
||||
onBlur={endInteraction}
|
||||
/>
|
||||
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,26 @@ function matchesShader(shader, query) {
|
||||
.some((value) => value.toLowerCase().includes(normalizedQuery));
|
||||
}
|
||||
|
||||
function shaderSummary(shader) {
|
||||
if (!shader) {
|
||||
return "Search available shaders";
|
||||
}
|
||||
|
||||
return shader.description || "No description";
|
||||
}
|
||||
|
||||
function ShaderOptionContent({ shader }) {
|
||||
return (
|
||||
<>
|
||||
<span className="shader-picker__option-head">
|
||||
<span className="shader-picker__name">{shader.name}</span>
|
||||
{shader.category ? <span className="shader-picker__category">{shader.category}</span> : null}
|
||||
</span>
|
||||
<span className="shader-picker__meta">{shaderSummary(shader)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShaderPicker({ id, label = "Shader", shaders, value, onChange }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -38,12 +58,13 @@ export function ShaderPicker({ id, label = "Shader", shaders, value, onChange })
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span>
|
||||
<span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span>
|
||||
<span className="shader-picker__meta">
|
||||
{selectedShader
|
||||
? `${selectedShader.category ? `${selectedShader.category} / ` : ""}${selectedShader.id}`
|
||||
: "Search available shaders"}
|
||||
<span className="shader-picker__option-head">
|
||||
<span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span>
|
||||
{selectedShader?.category ? (
|
||||
<span className="shader-picker__category">{selectedShader.category}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shader-picker__meta">{shaderSummary(selectedShader)}</span>
|
||||
</span>
|
||||
<ChevronDown size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -76,11 +97,7 @@ export function ShaderPicker({ id, label = "Shader", shaders, value, onChange })
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<span className="shader-picker__name">{shader.name}</span>
|
||||
<span className="shader-picker__meta">
|
||||
{shader.category ? `${shader.category} / ` : ""}
|
||||
{shader.id}
|
||||
</span>
|
||||
<ShaderOptionContent shader={shader} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -5,33 +5,34 @@ function valuesMatch(left, right) {
|
||||
}
|
||||
|
||||
export function useThrottledParameterValue(parameter, onParameterChange) {
|
||||
const [draftValue, setDraftValue] = useState(parameter.value);
|
||||
const [appliedValue, setAppliedValue] = useState(parameter.value);
|
||||
const currentValue = parameter.value === undefined ? parameter.defaultValue : parameter.value;
|
||||
const [draftValue, setDraftValue] = useState(currentValue);
|
||||
const [appliedValue, setAppliedValue] = useState(currentValue);
|
||||
const pendingTimeoutRef = useRef(null);
|
||||
const latestDraftRef = useRef(parameter.value);
|
||||
const latestDraftRef = useRef(currentValue);
|
||||
const lastSentAtRef = useRef(0);
|
||||
const isInteractingRef = useRef(false);
|
||||
const isDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValue(parameter.value);
|
||||
setAppliedValue(parameter.value);
|
||||
latestDraftRef.current = parameter.value;
|
||||
setDraftValue(currentValue);
|
||||
setAppliedValue(currentValue);
|
||||
latestDraftRef.current = currentValue;
|
||||
lastSentAtRef.current = 0;
|
||||
isInteractingRef.current = false;
|
||||
isDirtyRef.current = false;
|
||||
}, [parameter.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setAppliedValue(parameter.value);
|
||||
setAppliedValue(currentValue);
|
||||
latestDraftRef.current = draftValue;
|
||||
if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) {
|
||||
if (isDirtyRef.current && valuesMatch(currentValue, latestDraftRef.current)) {
|
||||
isDirtyRef.current = false;
|
||||
}
|
||||
if (!isInteractingRef.current && !isDirtyRef.current) {
|
||||
setDraftValue(parameter.value);
|
||||
setDraftValue(currentValue);
|
||||
}
|
||||
}, [draftValue, parameter.value]);
|
||||
}, [draftValue, currentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -591,7 +591,8 @@ pre {
|
||||
}
|
||||
|
||||
.layer-card__drag-handle:hover:not(:disabled),
|
||||
.parameter__osc:hover:not(:disabled) {
|
||||
.parameter__osc:hover:not(:disabled),
|
||||
.parameter__reset:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
color: var(--app-text);
|
||||
}
|
||||
@@ -604,6 +605,28 @@ pre {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shader-description {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-radius);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.shader-description__meta {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--app-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.shader-description p {
|
||||
margin: 0;
|
||||
color: var(--app-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.shader-picker__topline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -617,7 +640,8 @@ pre {
|
||||
.shader-picker__empty,
|
||||
.parameter__value,
|
||||
.parameter__alpha,
|
||||
.parameter__osc {
|
||||
.parameter__osc,
|
||||
.parameter__reset {
|
||||
color: var(--app-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@@ -634,7 +658,8 @@ pre {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.625rem;
|
||||
min-height: 54px;
|
||||
min-height: 50px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
text-align: left;
|
||||
background: #182232;
|
||||
border-color: var(--app-border);
|
||||
@@ -642,7 +667,7 @@ pre {
|
||||
|
||||
.shader-picker__trigger > span {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -654,8 +679,8 @@ pre {
|
||||
|
||||
.shader-picker__popover {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid var(--app-border);
|
||||
background: #121821;
|
||||
}
|
||||
@@ -674,27 +699,28 @@ pre {
|
||||
|
||||
.shader-picker__search input {
|
||||
padding-left: 2.125rem;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.shader-picker__list {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
max-height: 220px;
|
||||
gap: 0.45rem;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--app-border);
|
||||
background: #10151d;
|
||||
}
|
||||
|
||||
.shader-picker__option {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
min-height: 58px;
|
||||
padding: 0.5rem 0.625rem;
|
||||
gap: 0.25rem;
|
||||
min-height: 4.25rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-align: left;
|
||||
background: #182232;
|
||||
border-color: var(--app-border);
|
||||
align-content: center;
|
||||
align-content: start;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@@ -707,14 +733,58 @@ pre {
|
||||
.shader-picker__name,
|
||||
.shader-picker__meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.shader-picker__option-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shader-picker__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shader-picker__category {
|
||||
flex: 0 0 auto;
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
margin-top: 0.05rem;
|
||||
padding: 0.14rem 0.42rem;
|
||||
border: 1px solid rgba(26, 156, 219, 0.35);
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: rgba(26, 156, 219, 0.12);
|
||||
color: #b9def1;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shader-picker__meta {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.3;
|
||||
min-height: 2.05em;
|
||||
}
|
||||
|
||||
.shader-picker__trigger .shader-picker__meta {
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shader-picker__empty {
|
||||
padding: 0.625rem;
|
||||
}
|
||||
@@ -732,12 +802,13 @@ pre {
|
||||
|
||||
.parameter__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.parameter__osc {
|
||||
.parameter__osc,
|
||||
.parameter__reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -751,6 +822,13 @@ pre {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.parameter__reset {
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
.parameter__osc span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -758,7 +836,8 @@ pre {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parameter__osc svg {
|
||||
.parameter__osc svg,
|
||||
.parameter__reset svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -777,11 +856,29 @@ pre {
|
||||
min-width: 7.5rem;
|
||||
}
|
||||
|
||||
.parameter__color-row {
|
||||
.parameter__wheel-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.75rem, 0.42fr) minmax(7.5rem, 0.58fr);
|
||||
grid-template-columns: auto minmax(5.25rem, 1fr);
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.parameter__wheel {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
}
|
||||
|
||||
.parameter__wheel [class*="react-colorful"],
|
||||
.parameter__wheel > div {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.parameter__swatch {
|
||||
grid-column: 2;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
|
||||
.parameter__alpha {
|
||||
@@ -866,10 +963,18 @@ pre {
|
||||
.kv-rows,
|
||||
.parameter-grid,
|
||||
.parameter__header,
|
||||
.parameter__color-row {
|
||||
.parameter__wheel-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.parameter__reset {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.parameter__swatch {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
grid-template-columns: minmax(6.25rem, 0.6fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user