12 Commits

Author SHA1 Message Date
27bf2ae45c doc updates
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m22s
CI / Windows Release Package (push) Successful in 2m27s
2026-05-08 18:49:27 +10:00
1ea44ba3ae fix typo
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m19s
CI / Windows Release Package (push) Successful in 2m31s
2026-05-08 18:43:48 +10:00
0af9a72937 removed redundant code
Some checks failed
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m22s
CI / Windows Release Package (push) Has been cancelled
2026-05-08 18:40:56 +10:00
d650cac857 control layout updates
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m20s
CI / Windows Release Package (push) Successful in 2m28s
2026-05-08 18:28:28 +10:00
a0cc86f189 description updates
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m20s
CI / Windows Release Package (push) Successful in 2m28s
2026-05-08 18:11:26 +10:00
f322abf79a updates
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m22s
CI / Windows Release Package (push) Has been cancelled
2026-05-08 18:07:45 +10:00
eede6938cb Update multipass shader test
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m17s
CI / Windows Release Package (push) Successful in 2m27s
2026-05-08 17:41:53 +10:00
ad24a20fdb Multi pass test
Some checks failed
CI / React UI Build (push) Successful in 10s
CI / Windows Release Package (push) Has been cancelled
CI / Native Windows Build And Tests (push) Has been cancelled
2026-05-08 17:40:09 +10:00
5ae43513a7 annotations
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m16s
CI / Windows Release Package (push) Has been cancelled
2026-05-08 17:35:48 +10:00
cc23e73d51 Removed uneeded code 2026-05-08 17:33:57 +10:00
f85abef237 Multi pass
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m16s
CI / Windows Release Package (push) Successful in 2m28s
2026-05-08 17:28:48 +10:00
596d370f43 Add manifest support for pass declarations
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m17s
CI / Windows Release Package (push) Successful in 2m28s
2026-05-08 17:19:30 +10:00
78 changed files with 2526 additions and 1062 deletions

View File

@@ -2,7 +2,7 @@
Native video shader host with an OpenGL render path, pluggable video I/O boundary, DeckLink backend, Slang shader packages, and a local React control UI.
The app loads shader packages from `shaders/`, compiles Slang to GLSL at runtime, renders a configurable layer stack, and exposes a browser-based control surface over a local HTTP/WebSocket server.
The app loads shader packages from `shaders/`, compiles Slang to GLSL at runtime, renders a configurable layer stack, and exposes a browser-based control surface over a local HTTP/WebSocket server. Shader compilation is prepared off the frame path where possible, then committed on the render thread so editing shader files does not block video output for the whole compile.
## Repository Layout
@@ -62,6 +62,14 @@ npm run build
The native app serves `ui/dist` when it exists, otherwise it falls back to the source UI directory during development.
The control UI provides:
- A searchable shader library for adding layers.
- Compact parameter rows with inline descriptions and OSC copy controls.
- Stack save/recall presets.
- Manual shader reload.
- Screenshot capture from the final output render target.
## Package
Build the UI, build the native Release target, then install into a portable runtime folder:
@@ -121,8 +129,9 @@ Current native test coverage includes:
- JSON parsing and serialization.
- Parameter normalization and preset filename safety.
- Shader manifest parsing, temporal manifest validation, and package registry scanning.
- Video I/O format helpers, v210 pack/unpack math, playout scheduler timing, and fake backend contract coverage.
- Video I/O format helpers, v210/Ay10 row-byte math, v210 pack/unpack math, playout scheduler timing, and fake backend contract coverage.
- OSC packet parsing.
- Slang validation for every available shader package.
## Runtime Configuration
@@ -182,7 +191,11 @@ http://127.0.0.1:<serverPort>/docs
Use those docs to inspect the `/api/state`, layer control, stack preset, and reload endpoints. Live state updates are also sent over the `/ws` WebSocket.
The control UI also has a Screenshot button. It queues a capture of the final output render target and writes a PNG under:
The control UI has a **Reload shaders** button. It rescans `shaders/`, re-reads manifests, queues shader compilation, refreshes shader availability/errors, and keeps the previous working shader stack running if a changed shader fails to compile.
Each parameter row also includes a small **OSC** button. Clicking it copies that parameter's OSC route to the clipboard.
The control UI also has a **Screenshot** button. It queues a capture of the final output render target and writes a PNG under:
```text
runtime/screenshots/
@@ -206,10 +219,11 @@ Each shader package lives under:
shaders/<id>/
shader.json
shader.slang
optional-extra-pass.slang
optional-font-or-texture-assets
```
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.
See `SHADER_CONTRACT.md` for the manifest schema, parameter types, texture assets, font/text assets, temporal history support, optional render-pass declarations, and the Slang entry point contract. `shaders/text-overlay/` is the reference live text package and bundles Roboto Regular with its OFL license. Broken shader packages are shown as unavailable in the selector with their error text instead of preventing the app from launching.
## Generated Files
@@ -249,15 +263,13 @@ If `SLANG_ROOT` is not set, the workflow falls back to the repo-local default un
- Audio.
- Genlock.
- Find a better UI library for React.
- Logs.
- Add more video I/O backends now that the DeckLink path is behind `videoio/`.
- Support a separate sound shader `.slang` file in shader packages. (https://www.shadertoy.com/view/XsBXWt)
- Add WebView2
- Add WebView2 for an embedded native control surface.
- MSDF typography rasterisation
- More shader-library organisation and filtering as the built-in library grows.
- linear compositing?
- Optional linear-light compositing mode.
- compute shaders or a small 1x1 or nx1 RGBA16f render target for arbitrary data storage
- allow shaders to read other shaders data store based on name? or output over OSC
- Mipmapping for shader-declared textures
- Multipass for shaders at request

View File

@@ -55,7 +55,7 @@ float4 shadeVideo(ShaderContext context)
}
```
With `autoReload` enabled in `config/runtime-host.json`, edits to shader source, manifests, and declared texture assets are picked up automatically.
With `autoReload` enabled in `config/runtime-host.json`, edits to shader source, manifests, and declared texture assets are picked up automatically. You can also use **Reload shaders** in the control UI to manually rescan the shader library.
## Guidance For Shaders
@@ -80,7 +80,7 @@ Important rules:
- If adapting third-party code, include attribution and source URL in the manifest description when the license allows adaptation.
- If the source license is unclear or incompatible, do not add the shader package.
Before finishing, compile-check the shader through the runtime wrapper or launch the app and verify the shader appears without an error in the selector.
Before finishing, compile-check the shader through the runtime wrapper or launch the app and verify the shader appears without an error in the selector. CI also runs shader validation, so every available package in `shaders/` should compile successfully. Intentionally broken examples should stay visibly marked as broken rather than pretending to be production shaders.
## Manifest Fields
@@ -97,10 +97,13 @@ Optional fields:
- `description`: display/help text for the shader library.
- `category`: UI grouping label.
- `entryPoint`: Slang function to call. Defaults to `shadeVideo`.
- `passes`: advanced render-pass declarations. Omit this for normal single-pass shaders.
- `textures`: texture assets to load and expose as samplers.
- `fonts`: packaged font assets for live text parameters.
- `temporal`: history-buffer requirements.
Parameter objects may also include an optional `description` string. The control UI displays it as one-line helper text with the full text available on hover, so use it for short operational guidance rather than long documentation.
Shader-visible identifiers must be valid Slang-style identifiers:
- `entryPoint`
@@ -110,6 +113,87 @@ Shader-visible identifiers must be valid Slang-style identifiers:
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.
## Render Passes
Most shaders should omit `passes`. The runtime then creates one implicit pass:
```json
{
"id": "main",
"source": "shader.slang",
"entryPoint": "shadeVideo",
"output": "layerOutput"
}
```
Advanced shaders may declare explicit passes. All passes may live in one `.slang` file by using different `entryPoint` values, or they may be split across multiple source files:
```json
{
"passes": [
{
"id": "blurX",
"source": "blur-x.slang",
"entryPoint": "blurHorizontal",
"inputs": ["layerInput"],
"output": "blurredX"
},
{
"id": "final",
"source": "final.slang",
"entryPoint": "finish",
"inputs": ["blurredX"],
"output": "layerOutput"
}
]
}
```
Pass fields:
- `id`: required pass identifier. It must be a valid shader identifier and unique inside the package.
- `source`: required Slang source path relative to the package directory.
- `entryPoint`: optional Slang function for this pass. Defaults to the package-level `entryPoint`.
- `inputs`: optional list of named inputs. The first input is used as the pass input texture.
- `output`: optional output name. Use `layerOutput` for the final visible layer result.
Pass input names:
- `layerInput`: the input to this layer, before any of its passes run.
- `previousPass`: the previous pass output in this layer. If there is no previous pass, this falls back to `layerInput`.
- Any earlier pass `id` or `output` name from the same layer.
If `inputs` is omitted, the first pass samples `layerInput` and later passes sample `previousPass`.
Single-file multipass example:
```json
{
"passes": [
{
"id": "mask",
"source": "shader.slang",
"entryPoint": "makeMask",
"output": "maskBuffer"
},
{
"id": "final",
"source": "shader.slang",
"entryPoint": "finish",
"inputs": ["maskBuffer"],
"output": "layerOutput"
}
]
}
```
Pass output names:
- `layerOutput`: the final visible output of this layer.
- Any other name creates an intermediate 16-bit float render target that later passes may sample.
If the final declared pass does not explicitly output `layerOutput`, the runtime still treats that final pass as the visible layer output. Existing single-pass shaders are unaffected.
## Slang Entry Point
Your shader file must implement the manifest `entryPoint`.
@@ -177,7 +261,7 @@ Fields:
Color/precision notes:
- `context.sourceColor`, `sampleVideo()`, and temporal history samples are display-referred Rec.709-like RGB, not linear-light RGB.
- The current DeckLink backend prefers 10-bit YUV capture and output when the card/mode supports it, with automatic 8-bit fallback.
- The current DeckLink backend prefers 10-bit YUV capture and output when the card/mode supports it, with automatic 8-bit fallback. If external keying is enabled, output prefers 10-bit YUVA (`Ay10`) when supported so shader alpha can drive the key signal, then falls back to 8-bit BGRA.
- Internal decoded, layer, composite, output, and temporal render targets are 16-bit floating point, so gradients and LUT work have more headroom than packed byte video I/O formats.
- Do not add extra Rec.709 or linear conversions unless the shader intentionally documents that behavior.
@@ -547,6 +631,8 @@ When a shader compiles, the runtime writes generated files under `runtime/shader
These files are ignored by git and are useful for debugging compiler output. If a shader fails to compile, inspect the wrapper first; it shows the exact generated Slang code including your included shader.
For multipass shaders, these files reflect the most recently compiled pass. If a package has several passes, the reported compile error and pass name are usually more useful than assuming the cache contains the first pass.
## Common Pitfalls
- Do not use hyphens in parameter IDs, texture IDs, or entry point names.

View File

@@ -105,7 +105,8 @@ bool OpenGLComposite::InitVideoIO()
MessageBoxA(NULL, initFailureReason.c_str(), title, MB_OK | MB_ICONERROR);
return false;
}
if (!mVideoIO->SelectPreferredFormats(videoModes, initFailureReason))
const bool outputAlphaRequired = mRuntimeHost && mRuntimeHost->ExternalKeyingEnabled();
if (!mVideoIO->SelectPreferredFormats(videoModes, outputAlphaRequired, initFailureReason))
goto error;
if (! CheckOpenGLExtensions())

View File

@@ -2,6 +2,8 @@
#include "GlRenderConstants.h"
#include <map>
OpenGLRenderPass::OpenGLRenderPass(OpenGLRenderer& renderer) :
mRenderer(renderer)
{
@@ -91,9 +93,15 @@ std::vector<RenderPassDescriptor> OpenGLRenderPass::BuildLayerPassDescriptors(
const std::vector<RuntimeRenderState>& layerStates,
std::vector<LayerProgram>& layerPrograms) const
{
// Flatten the layer stack into concrete GL passes. A layer may now contain
// several shader passes, but the outer stack still sees one visible output
// per layer.
std::vector<RenderPassDescriptor> passes;
const std::size_t passCount = layerStates.size() < layerPrograms.size() ? layerStates.size() : layerPrograms.size();
passes.reserve(passCount);
std::size_t descriptorCount = 0;
for (std::size_t index = 0; index < passCount; ++index)
descriptorCount += layerPrograms[index].passes.size();
passes.reserve(descriptorCount);
GLuint sourceTexture = mRenderer.DecodedTexture();
GLuint sourceFramebuffer = mRenderer.DecodeFramebuffer();
@@ -103,27 +111,101 @@ std::vector<RenderPassDescriptor> OpenGLRenderPass::BuildLayerPassDescriptors(
LayerProgram& layerProgram = layerPrograms[index];
if (layerProgram.passes.empty())
continue;
// Preserve the original two-target layer ping-pong. Intermediate passes
// inside this layer are routed through pooled temporary targets instead.
const std::size_t remaining = layerStates.size() - index;
const bool writeToMain = (remaining % 2) == 1;
const GLuint layerOutputTexture = writeToMain ? mRenderer.CompositeTexture() : mRenderer.LayerTempTexture();
const GLuint layerOutputFramebuffer = writeToMain ? mRenderer.CompositeFramebuffer() : mRenderer.LayerTempFramebuffer();
const RenderPassOutputTarget layerOutputTarget = writeToMain ? RenderPassOutputTarget::Composite : RenderPassOutputTarget::LayerTemp;
RenderPassDescriptor pass;
pass.kind = RenderPassKind::LayerEffect;
pass.outputTarget = writeToMain ? RenderPassOutputTarget::Composite : RenderPassOutputTarget::LayerTemp;
pass.passIndex = index;
pass.passId = state.layerId;
pass.layerId = state.layerId;
pass.shaderId = state.shaderId;
pass.sourceTexture = sourceTexture;
pass.sourceFramebuffer = sourceFramebuffer;
pass.destinationFramebuffer = writeToMain ? mRenderer.CompositeFramebuffer() : mRenderer.LayerTempFramebuffer();
pass.layerProgram = &layerProgram;
pass.passProgram = &layerProgram.passes.front();
pass.layerState = &state;
pass.capturePreLayerHistory = state.temporalHistorySource == TemporalHistorySource::PreLayerInput;
passes.push_back(pass);
const GLuint layerInputTexture = sourceTexture;
const GLuint layerInputFramebuffer = sourceFramebuffer;
GLuint previousPassTexture = layerInputTexture;
GLuint previousPassFramebuffer = layerInputFramebuffer;
std::map<std::string, std::pair<GLuint, GLuint>> namedOutputs;
std::size_t temporaryTargetIndex = 0;
sourceTexture = writeToMain ? mRenderer.CompositeTexture() : mRenderer.LayerTempTexture();
sourceFramebuffer = writeToMain ? mRenderer.CompositeFramebuffer() : mRenderer.LayerTempFramebuffer();
for (std::size_t passIndex = 0; passIndex < layerProgram.passes.size(); ++passIndex)
{
PassProgram& passProgram = layerProgram.passes[passIndex];
const bool lastPassForLayer = passIndex + 1 == layerProgram.passes.size();
const std::string outputName = passProgram.outputName.empty() ? passProgram.passId : passProgram.outputName;
const bool writesLayerOutput = outputName == "layerOutput" || lastPassForLayer;
GLuint passSourceTexture = previousPassTexture;
GLuint passSourceFramebuffer = previousPassFramebuffer;
if (!passProgram.inputNames.empty())
{
// v1 multipass uses the first declared input as gVideoInput.
// Later inputs are parsed for forward compatibility.
const std::string& inputName = passProgram.inputNames.front();
if (inputName == "layerInput")
{
passSourceTexture = layerInputTexture;
passSourceFramebuffer = layerInputFramebuffer;
}
else if (inputName == "previousPass")
{
passSourceTexture = previousPassTexture;
passSourceFramebuffer = previousPassFramebuffer;
}
else
{
auto namedOutputIt = namedOutputs.find(inputName);
if (namedOutputIt != namedOutputs.end())
{
passSourceTexture = namedOutputIt->second.first;
passSourceFramebuffer = namedOutputIt->second.second;
}
}
}
GLuint passDestinationTexture = layerOutputTexture;
GLuint passDestinationFramebuffer = layerOutputFramebuffer;
RenderPassOutputTarget outputTarget = layerOutputTarget;
if (!writesLayerOutput)
{
// Temporary targets are reserved when the shader stack is
// committed, avoiding texture allocation during playback.
if (temporaryTargetIndex < mRenderer.TemporaryRenderTargetCount())
{
const RenderTarget& temporaryTarget = mRenderer.TemporaryRenderTarget(temporaryTargetIndex);
++temporaryTargetIndex;
passDestinationTexture = temporaryTarget.texture;
passDestinationFramebuffer = temporaryTarget.framebuffer;
outputTarget = RenderPassOutputTarget::Temporary;
}
}
RenderPassDescriptor pass;
pass.kind = RenderPassKind::LayerEffect;
pass.outputTarget = outputTarget;
pass.passIndex = passes.size();
pass.passId = passProgram.passId;
pass.layerId = state.layerId;
pass.shaderId = state.shaderId;
pass.sourceTexture = passSourceTexture;
pass.sourceFramebuffer = passIndex == 0 ? layerInputFramebuffer : passSourceFramebuffer;
pass.destinationTexture = passDestinationTexture;
pass.destinationFramebuffer = passDestinationFramebuffer;
pass.layerProgram = &layerProgram;
pass.passProgram = &passProgram;
pass.layerState = &state;
pass.capturePreLayerHistory = passIndex == 0 && state.temporalHistorySource == TemporalHistorySource::PreLayerInput;
passes.push_back(pass);
// A later pass can reference either the explicit output name or the
// pass id, which keeps small manifests pleasant to write.
namedOutputs[outputName] = std::make_pair(passDestinationTexture, passDestinationFramebuffer);
namedOutputs[passProgram.passId] = std::make_pair(passDestinationTexture, passDestinationFramebuffer);
previousPassTexture = passDestinationTexture;
previousPassFramebuffer = passDestinationFramebuffer;
}
sourceTexture = layerOutputTexture;
sourceFramebuffer = layerOutputFramebuffer;
}
return passes;
@@ -183,6 +265,8 @@ void OpenGLRenderPass::RenderShaderProgram(
mTextureBindings.BindRuntimeTexturePlan(texturePlan);
glBindVertexArray(mRenderer.FullscreenVertexArray());
glUseProgram(passProgram.program);
// The UBO is shared by every pass in a layer; texture routing is what
// changes from pass to pass.
updateGlobalParams(state, mRenderer.TemporalHistory().SourceAvailableCount(), mRenderer.TemporalHistory().AvailableCountForLayer(state.layerId));
glDrawArrays(GL_TRIANGLES, 0, 3);
glUseProgram(0);

View File

@@ -34,8 +34,8 @@ bool OpenGLRenderPipeline::RenderFrame(const RenderPipelineFrameContext& context
glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputFramebuffer());
if (mOutputReady)
mOutputReady();
if (state.outputPixelFormat == VideoIOPixelFormat::V210)
PackOutputForV210(state);
if (state.outputPixelFormat == VideoIOPixelFormat::V210 || state.outputPixelFormat == VideoIOPixelFormat::Yuva10)
PackOutputFor10Bit(state);
glFlush();
const auto renderEndTime = std::chrono::steady_clock::now();
@@ -50,7 +50,7 @@ bool OpenGLRenderPipeline::RenderFrame(const RenderPipelineFrameContext& context
return true;
}
void OpenGLRenderPipeline::PackOutputForV210(const VideoIOState& state)
void OpenGLRenderPipeline::PackOutputFor10Bit(const VideoIOState& state)
{
glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputPackFramebuffer());
glViewport(0, 0, state.outputPackTextureWidth, state.outputFrameSize.height);
@@ -64,10 +64,13 @@ void OpenGLRenderPipeline::PackOutputForV210(const VideoIOState& state)
const GLint outputResolutionLocation = glGetUniformLocation(mRenderer.OutputPackProgram(), "uOutputVideoResolution");
const GLint activeWordsLocation = glGetUniformLocation(mRenderer.OutputPackProgram(), "uActiveV210Words");
const GLint packFormatLocation = glGetUniformLocation(mRenderer.OutputPackProgram(), "uOutputPackFormat");
if (outputResolutionLocation >= 0)
glUniform2f(outputResolutionLocation, static_cast<float>(state.outputFrameSize.width), static_cast<float>(state.outputFrameSize.height));
if (activeWordsLocation >= 0)
glUniform1f(activeWordsLocation, static_cast<float>(ActiveV210WordsForWidth(state.outputFrameSize.width)));
if (packFormatLocation >= 0)
glUniform1i(packFormatLocation, state.outputPixelFormat == VideoIOPixelFormat::Yuva10 ? 2 : 1);
glDrawArrays(GL_TRIANGLES, 0, 3);
glUseProgram(0);
@@ -79,7 +82,7 @@ void OpenGLRenderPipeline::ReadOutputFrame(const VideoIOState& state, VideoIOOut
{
glPixelStorei(GL_PACK_ALIGNMENT, 4);
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
if (state.outputPixelFormat == VideoIOPixelFormat::V210)
if (state.outputPixelFormat == VideoIOPixelFormat::V210 || state.outputPixelFormat == VideoIOPixelFormat::Yuva10)
{
glBindFramebuffer(GL_READ_FRAMEBUFFER, mRenderer.OutputPackFramebuffer());
glReadPixels(0, 0, state.outputPackTextureWidth, state.outputFrameSize.height, GL_RGBA, GL_UNSIGNED_BYTE, outputFrame.bytes);

View File

@@ -30,7 +30,7 @@ public:
bool RenderFrame(const RenderPipelineFrameContext& context, VideoIOOutputFrame& outputFrame);
private:
void PackOutputForV210(const VideoIOState& state);
void PackOutputFor10Bit(const VideoIOState& state);
void ReadOutputFrame(const VideoIOState& state, VideoIOOutputFrame& outputFrame);
OpenGLRenderer& mRenderer;

View File

@@ -15,6 +15,7 @@ enum class RenderPassKind
enum class RenderPassOutputTarget
{
Temporary,
LayerTemp,
Composite
};
@@ -29,6 +30,7 @@ struct RenderPassDescriptor
std::string shaderId;
GLuint sourceTexture = 0;
GLuint sourceFramebuffer = 0;
GLuint destinationTexture = 0;
GLuint destinationFramebuffer = 0;
OpenGLRenderer::LayerProgram* layerProgram = nullptr;
OpenGLRenderer::LayerProgram::PassProgram* passProgram = nullptr;

View File

@@ -80,6 +80,11 @@ void OpenGLRenderer::SetOutputPackShaderProgram(GLuint program, GLuint vertexSha
mOutputPackFragmentShader = fragmentShader;
}
bool OpenGLRenderer::ReserveTemporaryRenderTargets(std::size_t count, unsigned width, unsigned height, std::string& error)
{
return mRenderTargets.ReserveTemporaryTargets(count, width, height, GL_RGBA16F, GL_RGBA, GL_FLOAT, error);
}
void OpenGLRenderer::ResizeView(int width, int height)
{
mViewWidth = width;

View File

@@ -41,6 +41,8 @@ public:
struct PassProgram
{
std::string passId;
std::vector<std::string> inputNames;
std::string outputName;
GLuint shaderTextureBase = 0;
GLuint program = 0;
GLuint vertexShader = 0;
@@ -73,6 +75,9 @@ public:
void ReplaceLayerPrograms(std::vector<LayerProgram>& newPrograms) { mLayerPrograms.swap(newPrograms); }
std::vector<LayerProgram>& LayerPrograms() { return mLayerPrograms; }
const std::vector<LayerProgram>& LayerPrograms() const { return mLayerPrograms; }
bool ReserveTemporaryRenderTargets(std::size_t count, unsigned width, unsigned height, std::string& error);
const RenderTarget& TemporaryRenderTarget(std::size_t index) const { return mRenderTargets.TemporaryTarget(index); }
std::size_t TemporaryRenderTargetCount() const { return mRenderTargets.TemporaryTargetCount(); }
TemporalHistoryBuffers& TemporalHistory() { return mTemporalHistory; }
const TemporalHistoryBuffers& TemporalHistory() const { return mTemporalHistory; }
void SetDecodeShaderProgram(GLuint program, GLuint vertexShader, GLuint fragmentShader);

View File

@@ -69,11 +69,10 @@ bool RenderTargetPool::ReserveTemporaryTargets(
GLenum pixelType,
std::string& error)
{
if (!mTemporaryTargets.empty())
{
error = "Temporary render targets were already initialized.";
return false;
}
if (mTemporaryTargets.size() == count)
return true;
DestroyTemporaryTargets();
mTemporaryTargets.resize(count);
for (std::size_t index = 0; index < mTemporaryTargets.size(); ++index)
@@ -105,6 +104,18 @@ bool RenderTargetPool::ReserveTemporaryTargets(
return true;
}
void RenderTargetPool::DestroyTemporaryTargets()
{
for (RenderTarget& target : mTemporaryTargets)
{
if (target.framebuffer != 0)
glDeleteFramebuffers(1, &target.framebuffer);
if (target.texture != 0)
glDeleteTextures(1, &target.texture);
}
mTemporaryTargets.clear();
}
void RenderTargetPool::Destroy()
{
for (RenderTarget& target : mTargets)
@@ -116,14 +127,7 @@ void RenderTargetPool::Destroy()
target = RenderTarget();
}
for (RenderTarget& target : mTemporaryTargets)
{
if (target.framebuffer != 0)
glDeleteFramebuffers(1, &target.framebuffer);
if (target.texture != 0)
glDeleteTextures(1, &target.texture);
}
mTemporaryTargets.clear();
DestroyTemporaryTargets();
}
const RenderTarget& RenderTargetPool::Target(RenderTargetId id) const

View File

@@ -47,6 +47,7 @@ public:
GLenum pixelFormat,
GLenum pixelType,
std::string& error);
void DestroyTemporaryTargets();
void Destroy();
GLuint Texture(RenderTargetId id) const { return Target(id).texture; }

View File

@@ -90,12 +90,17 @@ const char* kOutputPackFragmentShaderSource =
"layout(binding = 0) uniform sampler2D uOutputRgb;\n"
"uniform vec2 uOutputVideoResolution;\n"
"uniform float uActiveV210Words;\n"
"uniform int uOutputPackFormat;\n"
"in vec2 vTexCoord;\n"
"layout(location = 0) out vec4 fragColor;\n"
"vec3 rgbAt(int x, int y)\n"
"vec4 rgbaAt(int x, int y)\n"
"{\n"
" ivec2 size = ivec2(max(uOutputVideoResolution, vec2(1.0, 1.0)));\n"
" return clamp(texelFetch(uOutputRgb, ivec2(clamp(x, 0, size.x - 1), clamp(y, 0, size.y - 1)), 0).rgb, vec3(0.0), vec3(1.0));\n"
" return clamp(texelFetch(uOutputRgb, ivec2(clamp(x, 0, size.x - 1), clamp(y, 0, size.y - 1)), 0), vec4(0.0), vec4(1.0));\n"
"}\n"
"vec3 rgbAt(int x, int y)\n"
"{\n"
" return rgbaAt(x, y).rgb;\n"
"}\n"
"vec3 rgbToLegalYcbcr10(vec3 rgb)\n"
"{\n"
@@ -112,9 +117,35 @@ const char* kOutputPackFragmentShaderSource =
"{\n"
" return vec4(float(word & 255u), float((word >> 8) & 255u), float((word >> 16) & 255u), float((word >> 24) & 255u)) / 255.0;\n"
"}\n"
"vec4 bigEndianWordToBytes(uint word)\n"
"{\n"
" return vec4(float((word >> 24) & 255u), float((word >> 16) & 255u), float((word >> 8) & 255u), float(word & 255u)) / 255.0;\n"
"}\n"
"vec4 packAy10Word(ivec2 outCoord)\n"
"{\n"
" ivec2 size = ivec2(max(uOutputVideoResolution, vec2(1.0, 1.0)));\n"
" if (outCoord.x >= size.x)\n"
" return vec4(0.0);\n"
" int pixelBase = (outCoord.x / 2) * 2;\n"
" int y = outCoord.y;\n"
" vec4 rgba0 = rgbaAt(pixelBase + 0, y);\n"
" vec4 rgba1 = rgbaAt(pixelBase + 1, y);\n"
" vec3 c0 = rgbToLegalYcbcr10(rgba0.rgb);\n"
" vec3 c1 = rgbToLegalYcbcr10(rgba1.rgb);\n"
" float chroma = (outCoord.x & 1) == 0 ? round((c0.y + c1.y) * 0.5) : round((c0.z + c1.z) * 0.5);\n"
" float alpha = round(clamp(((outCoord.x & 1) == 0 ? rgba0.a : rgba1.a), 0.0, 1.0) * 1023.0);\n"
" float luma = (outCoord.x & 1) == 0 ? c0.x : c1.x;\n"
" uint word = ((uint(luma) & 1023u) << 22) | ((uint(chroma) & 1023u) << 12) | ((uint(alpha) & 1023u) << 2);\n"
" return bigEndianWordToBytes(word);\n"
"}\n"
"void main()\n"
"{\n"
" ivec2 outCoord = ivec2(gl_FragCoord.xy);\n"
" if (uOutputPackFormat == 2)\n"
" {\n"
" fragColor = packAy10Word(outCoord);\n"
" return;\n"
" }\n"
" if (float(outCoord.x) >= uActiveV210Words)\n"
" {\n"
" fragColor = vec4(0.0);\n"

View File

@@ -13,6 +13,20 @@ void CopyErrorMessage(const std::string& message, int errorMessageSize, char* er
strncpy_s(errorMessage, errorMessageSize, message.c_str(), _TRUNCATE);
}
std::size_t RequiredTemporaryRenderTargets(const std::vector<OpenGLRenderer::LayerProgram>& layerPrograms)
{
// Only one layer renders at a time, so the pool needs to cover the widest
// layer, not the sum of every intermediate pass in the stack.
std::size_t requiredTargets = 0;
for (const OpenGLRenderer::LayerProgram& layerProgram : layerPrograms)
{
const std::size_t internalPasses = layerProgram.passes.size() > 0 ? layerProgram.passes.size() - 1 : 0;
if (internalPasses > requiredTargets)
requiredTargets = internalPasses;
}
return requiredTargets;
}
}
OpenGLShaderPrograms::OpenGLShaderPrograms(OpenGLRenderer& renderer, RuntimeHost& runtimeHost) :
@@ -39,6 +53,8 @@ bool OpenGLShaderPrograms::CompileLayerPrograms(unsigned inputFrameWidth, unsign
return false;
}
// Initial startup still compiles synchronously; auto-reload uses the build
// queue so Slang/file work stays off the playback path.
std::vector<LayerProgram> newPrograms;
newPrograms.reserve(layerStates.size());
@@ -54,6 +70,15 @@ bool OpenGLShaderPrograms::CompileLayerPrograms(unsigned inputFrameWidth, unsign
newPrograms.push_back(layerProgram);
}
std::string targetError;
if (!mRenderer.ReserveTemporaryRenderTargets(RequiredTemporaryRenderTargets(newPrograms), inputFrameWidth, inputFrameHeight, targetError))
{
for (LayerProgram& program : newPrograms)
DestroySingleLayerProgram(program);
CopyErrorMessage(targetError, errorMessageSize, errorMessage);
return false;
}
DestroyLayerPrograms();
mRenderer.ReplaceLayerPrograms(newPrograms);
mCommittedLayerStates = layerStates;
@@ -85,13 +110,15 @@ bool OpenGLShaderPrograms::CommitPreparedLayerPrograms(const PreparedShaderBuild
return false;
}
// The prepared build already contains GLSL text for each pass. This commit
// step performs the short GL work on the render thread.
std::vector<LayerProgram> newPrograms;
newPrograms.reserve(preparedBuild.layers.size());
for (const PreparedLayerShader& preparedLayer : preparedBuild.layers)
{
LayerProgram layerProgram;
if (!mCompiler.CompilePreparedLayerProgram(preparedLayer.state, preparedLayer.fragmentShaderSource, layerProgram, errorMessageSize, errorMessage))
if (!mCompiler.CompilePreparedLayerProgram(preparedLayer.state, preparedLayer.passes, layerProgram, errorMessageSize, errorMessage))
{
for (LayerProgram& program : newPrograms)
DestroySingleLayerProgram(program);
@@ -100,6 +127,15 @@ bool OpenGLShaderPrograms::CommitPreparedLayerPrograms(const PreparedShaderBuild
newPrograms.push_back(layerProgram);
}
std::string targetError;
if (!mRenderer.ReserveTemporaryRenderTargets(RequiredTemporaryRenderTargets(newPrograms), inputFrameWidth, inputFrameHeight, targetError))
{
for (LayerProgram& program : newPrograms)
DestroySingleLayerProgram(program);
CopyErrorMessage(targetError, errorMessageSize, errorMessage);
return false;
}
DestroyLayerPrograms();
mRenderer.ReplaceLayerPrograms(newPrograms);
mCommittedLayerStates = preparedBuild.layerStates;

View File

@@ -120,7 +120,7 @@ PreparedShaderBuild ShaderBuildQueue::Build(uint64_t generation, unsigned output
{
PreparedLayerShader layer;
layer.state = state;
if (!mRuntimeHost.BuildLayerFragmentShaderSource(state.layerId, layer.fragmentShaderSource, build.message))
if (!mRuntimeHost.BuildLayerPassFragmentShaderSources(state.layerId, layer.passes, build.message))
{
build.succeeded = false;
return build;

View File

@@ -14,7 +14,7 @@ class RuntimeHost;
struct PreparedLayerShader
{
RuntimeRenderState state;
std::string fragmentShaderSource;
std::vector<ShaderPassBuildSource> passes;
};
struct PreparedShaderBuild

View File

@@ -28,102 +28,113 @@ ShaderProgramCompiler::ShaderProgramCompiler(OpenGLRenderer& renderer, RuntimeHo
bool ShaderProgramCompiler::CompileLayerProgram(const RuntimeRenderState& state, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage)
{
std::string fragmentShaderSource;
std::vector<ShaderPassBuildSource> passSources;
std::string loadError;
if (!mRuntimeHost.BuildLayerFragmentShaderSource(state.layerId, fragmentShaderSource, loadError))
if (!mRuntimeHost.BuildLayerPassFragmentShaderSources(state.layerId, passSources, loadError))
{
CopyErrorMessage(loadError, errorMessageSize, errorMessage);
return false;
}
return CompilePreparedLayerProgram(state, fragmentShaderSource, layerProgram, errorMessageSize, errorMessage);
return CompilePreparedLayerProgram(state, passSources, layerProgram, errorMessageSize, errorMessage);
}
bool ShaderProgramCompiler::CompilePreparedLayerProgram(const RuntimeRenderState& state, const std::string& fragmentShaderSource, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage)
bool ShaderProgramCompiler::CompilePreparedLayerProgram(const RuntimeRenderState& state, const std::vector<ShaderPassBuildSource>& passSources, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage)
{
GLsizei errorBufferSize = 0;
GLint compileResult = GL_FALSE;
GLint linkResult = GL_FALSE;
std::string loadError;
std::vector<LayerProgram::TextureBinding> textureBindings;
const char* vertexSource = kFullscreenTriangleVertexShaderSource;
const char* fragmentSource = fragmentShaderSource.c_str();
ScopedGlShader newVertexShader(glCreateShader(GL_VERTEX_SHADER));
glShaderSource(newVertexShader.get(), 1, (const GLchar**)&vertexSource, NULL);
glCompileShader(newVertexShader.get());
glGetShaderiv(newVertexShader.get(), GL_COMPILE_STATUS, &compileResult);
if (compileResult == GL_FALSE)
{
glGetShaderInfoLog(newVertexShader.get(), errorMessageSize, &errorBufferSize, errorMessage);
return false;
}
ScopedGlShader newFragmentShader(glCreateShader(GL_FRAGMENT_SHADER));
glShaderSource(newFragmentShader.get(), 1, (const GLchar**)&fragmentSource, NULL);
glCompileShader(newFragmentShader.get());
glGetShaderiv(newFragmentShader.get(), GL_COMPILE_STATUS, &compileResult);
if (compileResult == GL_FALSE)
{
glGetShaderInfoLog(newFragmentShader.get(), errorMessageSize, &errorBufferSize, errorMessage);
return false;
}
ScopedGlProgram newProgram(glCreateProgram());
glAttachShader(newProgram.get(), newVertexShader.get());
glAttachShader(newProgram.get(), newFragmentShader.get());
glLinkProgram(newProgram.get());
glGetProgramiv(newProgram.get(), GL_LINK_STATUS, &linkResult);
if (linkResult == GL_FALSE)
{
glGetProgramInfoLog(newProgram.get(), errorMessageSize, &errorBufferSize, errorMessage);
return false;
}
for (const ShaderTextureAsset& textureAsset : state.textureAssets)
{
LayerProgram::TextureBinding textureBinding;
textureBinding.samplerName = textureAsset.id;
textureBinding.sourcePath = textureAsset.path;
if (!mTextureBindings.LoadTextureAsset(textureAsset, textureBinding.texture, loadError))
{
for (LayerProgram::TextureBinding& loadedTexture : textureBindings)
{
if (loadedTexture.texture != 0)
glDeleteTextures(1, &loadedTexture.texture);
}
CopyErrorMessage(loadError, errorMessageSize, errorMessage);
return false;
}
textureBindings.push_back(textureBinding);
}
std::vector<LayerProgram::TextBinding> textBindings;
mTextureBindings.CreateTextBindings(state, textBindings);
layerProgram.layerId = state.layerId;
layerProgram.shaderId = state.shaderId;
layerProgram.passes.clear();
PassProgram passProgram;
passProgram.passId = "main";
passProgram.shaderTextureBase = mTextureBindings.ResolveShaderTextureBase(state, mRuntimeHost.GetMaxTemporalHistoryFrames());
passProgram.textureBindings.swap(textureBindings);
passProgram.textBindings.swap(textBindings);
for (const auto& passSource : passSources)
{
GLint compileResult = GL_FALSE;
GLint linkResult = GL_FALSE;
const char* fragmentSource = passSource.fragmentShaderSource.c_str();
const GLuint globalParamsIndex = glGetUniformBlockIndex(newProgram.get(), "GlobalParams");
if (globalParamsIndex != GL_INVALID_INDEX)
glUniformBlockBinding(newProgram.get(), globalParamsIndex, kGlobalParamsBindingPoint);
ScopedGlShader newVertexShader(glCreateShader(GL_VERTEX_SHADER));
glShaderSource(newVertexShader.get(), 1, (const GLchar**)&vertexSource, NULL);
glCompileShader(newVertexShader.get());
glGetShaderiv(newVertexShader.get(), GL_COMPILE_STATUS, &compileResult);
if (compileResult == GL_FALSE)
{
glGetShaderInfoLog(newVertexShader.get(), errorMessageSize, &errorBufferSize, errorMessage);
mRenderer.DestroySingleLayerProgram(layerProgram);
return false;
}
const unsigned historyCap = mRuntimeHost.GetMaxTemporalHistoryFrames();
glUseProgram(newProgram.get());
mTextureBindings.AssignLayerSamplerUniforms(newProgram.get(), state, passProgram, historyCap);
glUseProgram(0);
ScopedGlShader newFragmentShader(glCreateShader(GL_FRAGMENT_SHADER));
glShaderSource(newFragmentShader.get(), 1, (const GLchar**)&fragmentSource, NULL);
glCompileShader(newFragmentShader.get());
glGetShaderiv(newFragmentShader.get(), GL_COMPILE_STATUS, &compileResult);
if (compileResult == GL_FALSE)
{
glGetShaderInfoLog(newFragmentShader.get(), errorMessageSize, &errorBufferSize, errorMessage);
mRenderer.DestroySingleLayerProgram(layerProgram);
return false;
}
passProgram.program = newProgram.release();
passProgram.vertexShader = newVertexShader.release();
passProgram.fragmentShader = newFragmentShader.release();
layerProgram.passes.push_back(std::move(passProgram));
ScopedGlProgram newProgram(glCreateProgram());
glAttachShader(newProgram.get(), newVertexShader.get());
glAttachShader(newProgram.get(), newFragmentShader.get());
glLinkProgram(newProgram.get());
glGetProgramiv(newProgram.get(), GL_LINK_STATUS, &linkResult);
if (linkResult == GL_FALSE)
{
glGetProgramInfoLog(newProgram.get(), errorMessageSize, &errorBufferSize, errorMessage);
mRenderer.DestroySingleLayerProgram(layerProgram);
return false;
}
std::vector<LayerProgram::TextureBinding> textureBindings;
for (const ShaderTextureAsset& textureAsset : state.textureAssets)
{
LayerProgram::TextureBinding textureBinding;
textureBinding.samplerName = textureAsset.id;
textureBinding.sourcePath = textureAsset.path;
if (!mTextureBindings.LoadTextureAsset(textureAsset, textureBinding.texture, loadError))
{
for (LayerProgram::TextureBinding& loadedTexture : textureBindings)
{
if (loadedTexture.texture != 0)
glDeleteTextures(1, &loadedTexture.texture);
}
CopyErrorMessage(loadError, errorMessageSize, errorMessage);
mRenderer.DestroySingleLayerProgram(layerProgram);
return false;
}
textureBindings.push_back(textureBinding);
}
std::vector<LayerProgram::TextBinding> textBindings;
mTextureBindings.CreateTextBindings(state, textBindings);
PassProgram passProgram;
passProgram.passId = passSource.passId;
passProgram.inputNames = passSource.inputNames;
passProgram.outputName = passSource.outputName;
passProgram.shaderTextureBase = mTextureBindings.ResolveShaderTextureBase(state, mRuntimeHost.GetMaxTemporalHistoryFrames());
passProgram.textureBindings.swap(textureBindings);
passProgram.textBindings.swap(textBindings);
const GLuint globalParamsIndex = glGetUniformBlockIndex(newProgram.get(), "GlobalParams");
if (globalParamsIndex != GL_INVALID_INDEX)
glUniformBlockBinding(newProgram.get(), globalParamsIndex, kGlobalParamsBindingPoint);
const unsigned historyCap = mRuntimeHost.GetMaxTemporalHistoryFrames();
glUseProgram(newProgram.get());
mTextureBindings.AssignLayerSamplerUniforms(newProgram.get(), state, passProgram, historyCap);
glUseProgram(0);
passProgram.program = newProgram.release();
passProgram.vertexShader = newVertexShader.release();
passProgram.fragmentShader = newFragmentShader.release();
layerProgram.passes.push_back(std::move(passProgram));
}
return true;
}

View File

@@ -5,6 +5,7 @@
#include "ShaderTextureBindings.h"
#include <string>
#include <vector>
class ShaderProgramCompiler
{
@@ -15,7 +16,7 @@ public:
ShaderProgramCompiler(OpenGLRenderer& renderer, RuntimeHost& runtimeHost, ShaderTextureBindings& textureBindings);
bool CompileLayerProgram(const RuntimeRenderState& state, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage);
bool CompilePreparedLayerProgram(const RuntimeRenderState& state, const std::string& fragmentShaderSource, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage);
bool CompilePreparedLayerProgram(const RuntimeRenderState& state, const std::vector<ShaderPassBuildSource>& passSources, LayerProgram& layerProgram, int errorMessageSize, char* errorMessage);
bool CompileDecodeShader(int errorMessageSize, char* errorMessage);
bool CompileOutputPackShader(int errorMessageSize, char* errorMessage);

View File

@@ -82,17 +82,6 @@ bool TryParseLayerIdNumber(const std::string& layerId, uint64_t& number)
return true;
}
std::vector<double> JsonArrayToNumbers(const JsonValue& value)
{
std::vector<double> numbers;
for (const JsonValue& item : value.asArray())
{
if (item.isNumber())
numbers.push_back(item.asNumber());
}
return numbers;
}
bool LooksLikePackagedRuntimeRoot(const std::filesystem::path& candidate)
{
return std::filesystem::exists(candidate / "config" / "runtime-host.json") &&
@@ -629,6 +618,9 @@ bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDef
if (!ValidateShaderIdentifier(definition.id, "parameters[].id", manifestPath, error))
return false;
if (!OptionalStringField(parameterJson, "description", definition.description, "", manifestPath, error))
return false;
if (!ParseParameterDefault(parameterJson, definition, manifestPath, error) ||
!ParseParameterNumberField(parameterJson, "min", definition.minNumbers, manifestPath, error) ||
!ParseParameterNumberField(parameterJson, "max", definition.maxNumbers, manifestPath, error) ||
@@ -1300,7 +1292,7 @@ bool RuntimeHost::TryAdvanceFrame()
return true;
}
bool RuntimeHost::BuildLayerFragmentShaderSource(const std::string& layerId, std::string& fragmentShaderSource, std::string& error)
bool RuntimeHost::BuildLayerPassFragmentShaderSources(const std::string& layerId, std::vector<ShaderPassBuildSource>& passSources, std::string& error)
{
try
{
@@ -1324,16 +1316,30 @@ bool RuntimeHost::BuildLayerFragmentShaderSource(const std::string& layerId, std
}
ShaderCompiler compiler(mRepoRoot, mWrapperPath, mGeneratedGlslPath, mPatchedGlslPath, mConfig.maxTemporalHistoryFrames);
return compiler.BuildLayerFragmentShaderSource(shaderPackage, fragmentShaderSource, error);
// Compile every declared pass while the caller remains backend-neutral.
// The GL layer decides how the resulting pass sources are routed.
passSources.clear();
passSources.reserve(shaderPackage.passes.size());
for (const ShaderPassDefinition& pass : shaderPackage.passes)
{
ShaderPassBuildSource passSource;
passSource.passId = pass.id;
passSource.inputNames = pass.inputNames;
passSource.outputName = pass.outputName;
if (!compiler.BuildPassFragmentShaderSource(shaderPackage, pass, passSource.fragmentShaderSource, error))
return false;
passSources.push_back(std::move(passSource));
}
return true;
}
catch (const std::exception& exception)
{
error = std::string("RuntimeHost::BuildLayerFragmentShaderSource exception: ") + exception.what();
error = std::string("RuntimeHost::BuildLayerPassFragmentShaderSources exception: ") + exception.what();
return false;
}
catch (...)
{
error = "RuntimeHost::BuildLayerFragmentShaderSource threw a non-standard exception.";
error = "RuntimeHost::BuildLayerPassFragmentShaderSources threw a non-standard exception.";
return false;
}
}
@@ -1671,39 +1677,6 @@ bool RuntimeHost::ScanShaderPackages(std::string& error)
return true;
}
bool RuntimeHost::ParseShaderManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const
{
const std::string manifestText = ReadTextFile(manifestPath, error);
if (manifestText.empty())
return false;
JsonValue manifestJson;
if (!ParseJson(manifestText, manifestJson, error))
return false;
if (!manifestJson.isObject())
{
error = "Shader manifest root must be an object: " + manifestPath.string();
return false;
}
if (!ParseShaderMetadata(manifestJson, shaderPackage, manifestPath, error))
return false;
if (!std::filesystem::exists(shaderPackage.shaderPath))
{
error = "Shader source not found for package " + shaderPackage.id + ": " + shaderPackage.shaderPath.string();
return false;
}
shaderPackage.shaderWriteTime = std::filesystem::last_write_time(shaderPackage.shaderPath);
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);
}
bool RuntimeHost::NormalizeAndValidateValue(const ShaderParameterDefinition& definition, const JsonValue& value, ShaderParameterValue& normalizedValue, std::string& error) const
{
return NormalizeAndValidateParameterValue(definition, value, normalizedValue, error);
@@ -1987,6 +1960,8 @@ JsonValue RuntimeHost::SerializeLayerStackLocked() const
JsonValue parameter = JsonValue::MakeObject();
parameter.set("id", JsonValue(definition.id));
parameter.set("label", JsonValue(definition.label));
if (!definition.description.empty())
parameter.set("description", JsonValue(definition.description));
parameter.set("type", JsonValue(ShaderParameterTypeToString(definition.type)));
parameter.set("defaultValue", SerializeParameterValue(definition, DefaultValueForDefinition(definition)));

View File

@@ -9,6 +9,7 @@
#include <map>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
class RuntimeHost
@@ -50,7 +51,7 @@ public:
void AdvanceFrame();
bool TryAdvanceFrame();
bool BuildLayerFragmentShaderSource(const std::string& layerId, std::string& fragmentShaderSource, std::string& error);
bool BuildLayerPassFragmentShaderSources(const std::string& layerId, std::vector<ShaderPassBuildSource>& passSources, std::string& error);
std::vector<RuntimeRenderState> GetLayerRenderStates(unsigned outputWidth, unsigned outputHeight) const;
bool TryGetLayerRenderStates(unsigned outputWidth, unsigned outputHeight, std::vector<RuntimeRenderState>& states) const;
void RefreshDynamicRenderStateFields(std::vector<RuntimeRenderState>& states) const;
@@ -115,7 +116,6 @@ private:
bool LoadPersistentState(std::string& error);
bool SavePersistentState(std::string& error) const;
bool ScanShaderPackages(std::string& error);
bool ParseShaderManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const;
bool NormalizeAndValidateValue(const ShaderParameterDefinition& definition, const JsonValue& value, ShaderParameterValue& normalizedValue, std::string& error) const;
ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& definition) const;
void EnsureLayerDefaultsLocked(LayerPersistentState& layerState, const ShaderPackage& shaderPackage) const;

View File

@@ -661,3 +661,14 @@ std::string SerializeJson(const JsonValue& value, bool pretty)
SerializeJsonImpl(value, output, pretty, 0);
return output.str();
}
std::vector<double> JsonArrayToNumbers(const JsonValue& value)
{
std::vector<double> numbers;
for (const JsonValue& item : value.asArray())
{
if (item.isNumber())
numbers.push_back(item.asNumber());
}
return numbers;
}

View File

@@ -62,3 +62,4 @@ private:
bool ParseJson(const std::string& text, JsonValue& value, std::string& error);
std::string SerializeJson(const JsonValue& value, bool pretty = false);
std::vector<double> JsonArrayToNumbers(const JsonValue& value);

View File

@@ -26,17 +26,6 @@ bool IsFiniteNumber(double value)
return std::isfinite(value) != 0;
}
std::vector<double> JsonArrayToNumbers(const JsonValue& value)
{
std::vector<double> numbers;
for (const JsonValue& item : value.asArray())
{
if (item.isNumber())
numbers.push_back(item.asNumber());
}
return numbers;
}
std::string NormalizeTextValue(const std::string& text, unsigned maxLength)
{
std::string normalized;

View File

@@ -143,10 +143,10 @@ ShaderCompiler::ShaderCompiler(
{
}
bool ShaderCompiler::BuildLayerFragmentShaderSource(const ShaderPackage& shaderPackage, std::string& fragmentShaderSource, std::string& error) const
bool ShaderCompiler::BuildPassFragmentShaderSource(const ShaderPackage& shaderPackage, const ShaderPassDefinition& pass, std::string& fragmentShaderSource, std::string& error) const
{
std::string wrapperSource;
if (!BuildWrapperSlangSource(shaderPackage, wrapperSource, error))
if (!BuildWrapperSlangSource(shaderPackage, pass, wrapperSource, error))
return false;
if (!WriteTextFile(mWrapperPath, wrapperSource, error))
return false;
@@ -167,7 +167,7 @@ bool ShaderCompiler::BuildLayerFragmentShaderSource(const ShaderPackage& shaderP
return true;
}
bool ShaderCompiler::BuildWrapperSlangSource(const ShaderPackage& shaderPackage, std::string& wrapperSource, std::string& error) const
bool ShaderCompiler::BuildWrapperSlangSource(const ShaderPackage& shaderPackage, const ShaderPassDefinition& pass, std::string& wrapperSource, std::string& error) const
{
const std::filesystem::path templatePath = mRepoRoot / "runtime" / "templates" / "shader_wrapper.slang.in";
wrapperSource = ReadTextFile(templatePath, error);
@@ -183,8 +183,8 @@ bool ShaderCompiler::BuildWrapperSlangSource(const ShaderPackage& shaderPackage,
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)");
wrapperSource = ReplaceAll(wrapperSource, "{{USER_SHADER_INCLUDE}}", pass.sourcePath.generic_string());
wrapperSource = ReplaceAll(wrapperSource, "{{ENTRY_POINT_CALL}}", pass.entryPoint + "(context)");
return true;
}

View File

@@ -15,10 +15,10 @@ public:
const std::filesystem::path& patchedGlslPath,
unsigned maxTemporalHistoryFrames);
bool BuildLayerFragmentShaderSource(const ShaderPackage& shaderPackage, std::string& fragmentShaderSource, std::string& error) const;
bool BuildPassFragmentShaderSource(const ShaderPackage& shaderPackage, const ShaderPassDefinition& pass, std::string& fragmentShaderSource, std::string& error) const;
private:
bool BuildWrapperSlangSource(const ShaderPackage& shaderPackage, std::string& wrapperSource, std::string& error) const;
bool BuildWrapperSlangSource(const ShaderPackage& shaderPackage, const ShaderPassDefinition& pass, std::string& wrapperSource, std::string& error) const;
bool FindSlangCompiler(std::filesystem::path& compilerPath, std::string& error) const;
bool RunSlangCompiler(const std::filesystem::path& wrapperPath, const std::filesystem::path& outputPath, std::string& error) const;
bool PatchGeneratedGlsl(std::string& shaderText, std::string& error) const;

View File

@@ -29,17 +29,6 @@ bool IsFiniteNumber(double value)
return std::isfinite(value) != 0;
}
std::vector<double> JsonArrayToNumbers(const JsonValue& value)
{
std::vector<double> numbers;
for (const JsonValue& item : value.asArray())
{
if (item.isNumber())
numbers.push_back(item.asNumber());
}
return numbers;
}
bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type)
{
if (typeName == "float")
@@ -250,6 +239,107 @@ bool ParseShaderMetadata(const JsonValue& manifestJson, ShaderPackage& shaderPac
return true;
}
bool ParsePassDefinitions(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error)
{
const JsonValue* passesValue = nullptr;
if (!OptionalArrayField(manifestJson, "passes", passesValue, manifestPath, error))
return false;
if (!passesValue)
{
// Existing shader packages are treated as a single implicit pass, so
// multipass support does not require manifest churn.
ShaderPassDefinition pass;
pass.id = "main";
pass.entryPoint = shaderPackage.entryPoint;
pass.sourcePath = shaderPackage.shaderPath;
pass.outputName = "layerOutput";
if (!std::filesystem::exists(pass.sourcePath))
{
error = "Shader source not found for package " + shaderPackage.id + ": " + pass.sourcePath.string();
return false;
}
pass.sourceWriteTime = std::filesystem::last_write_time(pass.sourcePath);
shaderPackage.passes.push_back(pass);
return true;
}
if (passesValue->asArray().empty())
{
error = "Shader manifest 'passes' field must not be empty in: " + ManifestPathMessage(manifestPath);
return false;
}
for (const JsonValue& passJson : passesValue->asArray())
{
if (!passJson.isObject())
{
error = "Shader pass entry must be an object in: " + ManifestPathMessage(manifestPath);
return false;
}
std::string passId;
std::string sourcePath;
if (!RequireNonEmptyStringField(passJson, "id", passId, manifestPath, error) ||
!RequireNonEmptyStringField(passJson, "source", sourcePath, manifestPath, error))
{
error = "Shader pass is missing required 'id' or 'source' in: " + ManifestPathMessage(manifestPath);
return false;
}
if (!ValidateShaderIdentifier(passId, "passes[].id", manifestPath, error))
return false;
for (const ShaderPassDefinition& existingPass : shaderPackage.passes)
{
if (existingPass.id == passId)
{
error = "Duplicate shader pass id '" + passId + "' in: " + ManifestPathMessage(manifestPath);
return false;
}
}
ShaderPassDefinition pass;
pass.id = passId;
pass.sourcePath = shaderPackage.directoryPath / sourcePath;
if (!OptionalStringField(passJson, "entryPoint", pass.entryPoint, shaderPackage.entryPoint, manifestPath, error) ||
!OptionalStringField(passJson, "output", pass.outputName, passId, manifestPath, error))
{
return false;
}
if (!ValidateShaderIdentifier(pass.entryPoint, "passes[].entryPoint", manifestPath, error))
return false;
const JsonValue* inputsValue = nullptr;
if (!OptionalArrayField(passJson, "inputs", inputsValue, manifestPath, error))
return false;
if (inputsValue)
{
for (const JsonValue& inputValue : inputsValue->asArray())
{
if (!inputValue.isString())
{
error = "Shader pass inputs must be strings in: " + ManifestPathMessage(manifestPath);
return false;
}
pass.inputNames.push_back(inputValue.asString());
}
}
// Keep source validation in the registry. Bad pass declarations then
// appear as unavailable shaders instead of failing at render time.
if (!std::filesystem::exists(pass.sourcePath))
{
error = "Shader pass source not found for package " + shaderPackage.id + ": " + pass.sourcePath.string();
return false;
}
pass.sourceWriteTime = std::filesystem::last_write_time(pass.sourcePath);
shaderPackage.passes.push_back(pass);
}
shaderPackage.shaderPath = shaderPackage.passes.front().sourcePath;
return true;
}
bool ParseTextureAssets(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error)
{
const JsonValue* texturesValue = nullptr;
@@ -503,6 +593,9 @@ bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDef
if (!ValidateShaderIdentifier(definition.id, "parameters[].id", manifestPath, error))
return false;
if (!OptionalStringField(parameterJson, "description", definition.description, "", manifestPath, error))
return false;
if (!ParseParameterDefault(parameterJson, definition, manifestPath, error) ||
!ParseParameterNumberField(parameterJson, "min", definition.minNumbers, manifestPath, error) ||
!ParseParameterNumberField(parameterJson, "max", definition.maxNumbers, manifestPath, error) ||
@@ -666,13 +759,15 @@ bool ShaderPackageRegistry::ParseManifest(const std::filesystem::path& manifestP
if (!ParseShaderMetadata(manifestJson, shaderPackage, manifestPath, error))
return false;
if (!std::filesystem::exists(shaderPackage.shaderPath))
{
error = "Shader source not found for package " + shaderPackage.id + ": " + shaderPackage.shaderPath.string();
if (!ParsePassDefinitions(manifestJson, shaderPackage, manifestPath, error))
return false;
}
shaderPackage.shaderWriteTime = std::filesystem::last_write_time(shaderPackage.shaderPath);
shaderPackage.shaderWriteTime = shaderPackage.passes.front().sourceWriteTime;
for (const ShaderPassDefinition& pass : shaderPackage.passes)
{
if (pass.sourceWriteTime > shaderPackage.shaderWriteTime)
shaderPackage.shaderWriteTime = pass.sourceWriteTime;
}
shaderPackage.manifestWriteTime = std::filesystem::last_write_time(shaderPackage.manifestPath);
return ParseTextureAssets(manifestJson, shaderPackage, manifestPath, error) &&

View File

@@ -26,6 +26,7 @@ struct ShaderParameterDefinition
{
std::string id;
std::string label;
std::string description;
ShaderParameterType type = ShaderParameterType::Float;
std::vector<double> defaultNumbers;
std::vector<double> minNumbers;
@@ -76,6 +77,24 @@ struct ShaderFontAsset
std::filesystem::file_time_type writeTime;
};
struct ShaderPassDefinition
{
std::string id;
std::string entryPoint;
std::filesystem::path sourcePath;
std::filesystem::file_time_type sourceWriteTime;
std::vector<std::string> inputNames;
std::string outputName;
};
struct ShaderPassBuildSource
{
std::string passId;
std::string fragmentShaderSource;
std::vector<std::string> inputNames;
std::string outputName;
};
struct ShaderPackage
{
std::string id;
@@ -86,6 +105,7 @@ struct ShaderPackage
std::filesystem::path directoryPath;
std::filesystem::path shaderPath;
std::filesystem::path manifestPath;
std::vector<ShaderPassDefinition> passes;
std::vector<ShaderParameterDefinition> parameters;
std::vector<ShaderTextureAsset> textureAssets;
std::vector<ShaderFontAsset> fontAssets;

View File

@@ -54,6 +54,8 @@ const char* VideoIOPixelFormatName(VideoIOPixelFormat format)
{
case VideoIOPixelFormat::V210:
return "10-bit YUV v210";
case VideoIOPixelFormat::Yuva10:
return "10-bit YUVA Ay10";
case VideoIOPixelFormat::Bgra8:
return "8-bit BGRA";
case VideoIOPixelFormat::Uyvy8:
@@ -64,7 +66,7 @@ const char* VideoIOPixelFormatName(VideoIOPixelFormat format)
bool VideoIOPixelFormatIsTenBit(VideoIOPixelFormat format)
{
return format == VideoIOPixelFormat::V210;
return format == VideoIOPixelFormat::V210 || format == VideoIOPixelFormat::Yuva10;
}
VideoIOPixelFormat ChoosePreferredVideoIOFormat(bool tenBitSupported)
@@ -80,6 +82,8 @@ unsigned VideoIOBytesPerPixel(VideoIOPixelFormat format)
return 2u;
case VideoIOPixelFormat::Bgra8:
return 4u;
case VideoIOPixelFormat::Yuva10:
return 4u;
case VideoIOPixelFormat::V210:
default:
return 0u;
@@ -90,6 +94,8 @@ unsigned VideoIORowBytes(VideoIOPixelFormat format, unsigned frameWidth)
{
if (format == VideoIOPixelFormat::V210)
return MinimumV210RowBytes(frameWidth);
if (format == VideoIOPixelFormat::Yuva10)
return MinimumYuva10RowBytes(frameWidth);
return frameWidth * VideoIOBytesPerPixel(format);
}
@@ -103,6 +109,11 @@ unsigned MinimumV210RowBytes(unsigned frameWidth)
return ((frameWidth + 5u) / 6u) * 16u;
}
unsigned MinimumYuva10RowBytes(unsigned frameWidth)
{
return ((frameWidth + 63u) / 64u) * 256u;
}
unsigned ActiveV210WordsForWidth(unsigned frameWidth)
{
return ((frameWidth + 5u) / 6u) * 4u;

View File

@@ -7,6 +7,7 @@ enum class VideoIOPixelFormat
{
Uyvy8,
V210,
Yuva10,
Bgra8
};
@@ -31,6 +32,7 @@ unsigned VideoIOBytesPerPixel(VideoIOPixelFormat format);
unsigned VideoIORowBytes(VideoIOPixelFormat format, unsigned frameWidth);
unsigned PackedTextureWidthFromRowBytes(unsigned rowBytes);
unsigned MinimumV210RowBytes(unsigned frameWidth);
unsigned MinimumYuva10RowBytes(unsigned frameWidth);
unsigned ActiveV210WordsForWidth(unsigned frameWidth);
V210CodeValues Rec709RgbToLegalV210(float red, float green, float blue);
std::array<uint8_t, 16> PackV210Block(const V210SixPixelBlock& block);

View File

@@ -95,7 +95,7 @@ public:
virtual ~VideoIODevice() = default;
virtual void ReleaseResources() = 0;
virtual bool DiscoverDevicesAndModes(const VideoFormatSelection& videoModes, std::string& error) = 0;
virtual bool SelectPreferredFormats(const VideoFormatSelection& videoModes, std::string& error) = 0;
virtual bool SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error) = 0;
virtual bool ConfigureInput(InputFrameCallback callback, const VideoFormat& inputVideoMode, std::string& error) = 0;
virtual bool ConfigureOutput(OutputFrameCallback callback, const VideoFormat& outputVideoMode, bool externalKeyingEnabled, std::string& error) = 0;
virtual bool Start() = 0;

View File

@@ -223,7 +223,7 @@ bool DeckLinkSession::DiscoverDevicesAndModes(const VideoFormatSelection& videoM
return true;
}
bool DeckLinkSession::SelectPreferredFormats(const VideoFormatSelection& videoModes, std::string& error)
bool DeckLinkSession::SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error)
{
if (!output)
{
@@ -239,8 +239,15 @@ bool DeckLinkSession::SelectPreferredFormats(const VideoFormatSelection& videoMo
mState.formatStatusMessage += "DeckLink input does not report 10-bit YUV support for the configured mode; using 8-bit capture. ";
const bool outputTenBitSupported = OutputSupportsFormat(output, videoModes.output.displayMode, bmdFormat10BitYUV);
mState.outputPixelFormat = outputTenBitSupported ? VideoIOPixelFormat::V210 : VideoIOPixelFormat::Bgra8;
if (!outputTenBitSupported)
const bool outputTenBitYuvaSupported = OutputSupportsFormat(output, videoModes.output.displayMode, bmdFormat10BitYUVA);
mState.outputPixelFormat = outputAlphaRequired
? (outputTenBitYuvaSupported ? VideoIOPixelFormat::Yuva10 : VideoIOPixelFormat::Bgra8)
: (outputTenBitSupported ? VideoIOPixelFormat::V210 : VideoIOPixelFormat::Bgra8);
if (outputAlphaRequired && outputTenBitYuvaSupported)
mState.formatStatusMessage += "External keying requires alpha; using 10-bit YUVA output. ";
else if (outputAlphaRequired)
mState.formatStatusMessage += "External keying requires alpha, but DeckLink output does not report 10-bit YUVA support for the configured mode; using 8-bit BGRA output. ";
else if (!outputTenBitSupported)
mState.formatStatusMessage += "DeckLink output does not report 10-bit YUV support for the configured mode; using 8-bit BGRA output. ";
int deckLinkOutputRowBytes = 0;

View File

@@ -22,7 +22,7 @@ public:
void ReleaseResources() override;
bool DiscoverDevicesAndModes(const VideoFormatSelection& videoModes, std::string& error) override;
bool SelectPreferredFormats(const VideoFormatSelection& videoModes, std::string& error) override;
bool SelectPreferredFormats(const VideoFormatSelection& videoModes, bool outputAlphaRequired, std::string& error) override;
bool ConfigureInput(InputFrameCallback callback, const VideoFormat& inputVideoMode, std::string& error) override;
bool ConfigureOutput(OutputFrameCallback callback, const VideoFormat& outputVideoMode, bool externalKeyingEnabled, std::string& error) override;
bool Start() override;

View File

@@ -6,6 +6,8 @@ BMDPixelFormat DeckLinkPixelFormatForVideoIO(VideoIOPixelFormat format)
{
case VideoIOPixelFormat::V210:
return bmdFormat10BitYUV;
case VideoIOPixelFormat::Yuva10:
return bmdFormat10BitYUVA;
case VideoIOPixelFormat::Bgra8:
return bmdFormat8BitBGRA;
case VideoIOPixelFormat::Uyvy8:
@@ -18,6 +20,8 @@ VideoIOPixelFormat VideoIOPixelFormatFromDeckLink(BMDPixelFormat format)
{
if (format == bmdFormat10BitYUV)
return VideoIOPixelFormat::V210;
if (format == bmdFormat10BitYUVA)
return VideoIOPixelFormat::Yuva10;
if (format == bmdFormat8BitBGRA)
return VideoIOPixelFormat::Bgra8;
return VideoIOPixelFormat::Uyvy8;

View File

@@ -47,6 +47,8 @@ Matching is exact first. If that fails, names are compared in a simplified form
If multiple layers use the same shader package ID or display name, the first matching layer in the stack is controlled. Use the internal layer ID shown in the UI when you need to target one duplicate layer precisely.
In the control UI, each parameter row has a small **OSC** button. Clicking it copies that parameter's exact OSC address to the clipboard, which is the safest way to target controls with long names or duplicate shader layers.
## Values
The listener accepts these OSC argument types:
@@ -65,7 +67,7 @@ Examples:
/VideoShaderToys/fisheye-reproject/panDegrees 45.0
/VideoShaderToys/fisheye-reproject/fisheyeModel "equisolid"
/VideoShaderToys/video-transform/pan 0.25 -0.5
/VideoShaderToys/composition-guides/lineColor 1.0 0.8 0.1 1.0
/VideoShaderToys/safe-area-guides/lineColor 1.0 0.8 0.1 1.0
```
Values are validated with the same shader parameter rules used by the REST API. Invalid values or unknown addresses are ignored and reported to the native debug output.

View File

@@ -201,6 +201,7 @@ paths:
post:
tags: [Runtime]
summary: Reload shaders
description: Rescans the shader library, re-reads manifests, queues shader compilation, and refreshes shader availability/errors. If a changed shader fails, the previous working stack remains active where possible.
operationId: reloadShaders
requestBody:
required: false
@@ -465,6 +466,18 @@ components:
type: number
budgetUsedPercent:
type: number
completionIntervalMs:
type: number
smoothedCompletionIntervalMs:
type: number
maxCompletionIntervalMs:
type: number
lateFrameCount:
type: number
droppedFrameCount:
type: number
flushedFrameCount:
type: number
ShaderSummary:
type: object
properties:
@@ -476,6 +489,12 @@ components:
type: string
category:
type: string
available:
type: boolean
description: False when the shader package exists but failed manifest or compile validation.
error:
type: string
description: Error text for unavailable shader packages.
temporal:
$ref: "#/components/schemas/TemporalState"
TemporalState:
@@ -514,9 +533,21 @@ components:
type: string
label:
type: string
description:
type: string
description: Short helper text shown under the parameter label in the control UI.
type:
type: string
enum: [float, vec2, color, bool, enum, text, trigger]
defaultValue:
description: Default parameter value from the shader manifest.
oneOf:
- type: number
- type: boolean
- type: string
- type: array
items:
type: number
min:
type: array
items:
@@ -533,6 +564,12 @@ components:
type: array
items:
$ref: "#/components/schemas/ParameterOption"
maxLength:
type: number
description: Maximum length for text parameters.
font:
type: string
description: Font asset id used by text parameters, when declared.
value:
description: Current parameter value.
oneOf:

View File

@@ -14,9 +14,9 @@ Packaged documentation:
Generated files:
- `shader_cache/active_shader_wrapper.slang`: generated Slang wrapper for the active shader/layer.
- `shader_cache/active_shader.raw.frag`: raw GLSL emitted by `slangc`.
- `shader_cache/active_shader.frag`: patched GLSL consumed by the OpenGL path.
- `shader_cache/active_shader_wrapper.slang`: generated Slang wrapper for the most recently compiled shader pass.
- `shader_cache/active_shader.raw.frag`: raw GLSL emitted by `slangc` for the most recently compiled pass.
- `shader_cache/active_shader.frag`: patched GLSL consumed by the OpenGL path for the most recently compiled pass.
- `runtime_state.json`: autosaved latest layer stack, layer order, bypass state, shader assignments, and parameter values. The host reloads this file on startup.
- `stack_presets/*.json`: user-saved layer stack presets.
- `screenshots/*.png`: screenshots captured from the final output render target through the control UI/API.

View File

@@ -11,11 +11,24 @@
"type": "enum",
"default": "x1_33",
"options": [
{ "value": "x1_3", "label": "1.3x" },
{ "value": "x1_33", "label": "1.33x" },
{ "value": "x1_5", "label": "1.5x" },
{ "value": "x2_0", "label": "2x" }
]
{
"value": "x1_3",
"label": "1.3x"
},
{
"value": "x1_33",
"label": "1.33x"
},
{
"value": "x1_5",
"label": "1.5x"
},
{
"value": "x2_0",
"label": "2x"
}
],
"description": "Horizontal stretch factor matching the anamorphic lens or adapter."
},
{
"id": "framing",
@@ -23,24 +36,50 @@
"type": "enum",
"default": "fit",
"options": [
{ "value": "fit", "label": "Fit" },
{ "value": "fill", "label": "Fill" }
]
{
"value": "fit",
"label": "Fit"
},
{
"value": "fill",
"label": "Fill"
}
],
"description": "Fit preserves the whole image; Fill crops to remove borders."
},
{
"id": "pan",
"label": "Pan",
"type": "vec2",
"default": [0.0, 0.0],
"min": [-1.0, -1.0],
"max": [1.0, 1.0],
"step": [0.001, 0.001]
"default": [
0,
0
],
"min": [
-1,
-1
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Reframes the desqueezed image after fit/fill scaling."
},
{
"id": "outsideColor",
"label": "Outside Color",
"type": "color",
"default": [0.0, 0.0, 0.0, 1.0]
"default": [
0,
0,
0,
1
],
"description": "Color used where the remapped image samples outside the source frame."
}
]
}

View File

@@ -9,37 +9,41 @@
"id": "spinRotation",
"label": "Spin Rotation",
"type": "float",
"default": -2.0,
"min": -8.0,
"max": 8.0,
"step": 0.05
"default": -2,
"min": -8,
"max": 8,
"step": 0.05,
"description": "Base rotation applied to the swirl field."
},
{
"id": "spinSpeed",
"label": "Spin Speed",
"type": "float",
"default": 7.0,
"min": 0.0,
"max": 20.0,
"step": 0.1
"default": 7,
"min": 0,
"max": 20,
"step": 0.1,
"description": "How quickly the swirl pattern rotates."
},
{
"id": "spinAmount",
"label": "Spin Amount",
"type": "float",
"default": 0.25,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Amount of radial twisting in the swirl."
},
{
"id": "spinEase",
"label": "Spin Ease",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 3.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 3,
"step": 0.01,
"description": "Changes how strongly the twist falls off from the center."
},
{
"id": "contrast",
@@ -47,59 +51,94 @@
"type": "float",
"default": 3.5,
"min": 0.5,
"max": 8.0,
"step": 0.05
"max": 8,
"step": 0.05,
"description": "Adjusts separation between dark and bright areas."
},
{
"id": "lighting",
"label": "Lighting",
"type": "float",
"default": 0.4,
"min": 0.0,
"min": 0,
"max": 1.5,
"step": 0.01
"step": 0.01,
"description": "Strength of the highlight/shadow modulation."
},
{
"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]
"default": [
0,
0
],
"min": [
-1,
-1
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Moves the generated field in normalized coordinates."
},
{
"id": "colour1",
"label": "Colour 1",
"type": "color",
"default": [0.871, 0.267, 0.231, 1.0]
"default": [
0.871,
0.267,
0.231,
1
],
"description": "Primary warm swirl color."
},
{
"id": "colour2",
"label": "Colour 2",
"type": "color",
"default": [0.0, 0.42, 0.706, 1.0]
"default": [
0,
0.42,
0.706,
1
],
"description": "Secondary cool swirl color."
},
{
"id": "colour3",
"label": "Colour 3",
"type": "color",
"default": [0.086, 0.137, 0.145, 1.0]
"default": [
0.086,
0.137,
0.145,
1
],
"description": "Dark base color in the swirl."
},
{
"id": "isRotate",
"label": "Rotate Field",
"type": "bool",
"default": false
"default": false,
"description": "Rotates the whole generated field over time."
},
{
"id": "sourceMix",
"label": "Source Mix",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends the generated effect with the incoming video."
}
]
}

View File

@@ -9,7 +9,8 @@
"id": "badToggle",
"label": "Bad Toggle",
"type": "boolean",
"default": true
"default": true,
"description": "Intentionally unsupported parameter type used to test shader error handling."
}
]
}

View File

@@ -9,55 +9,67 @@
"id": "showThirds",
"label": "Rule of Thirds",
"type": "bool",
"default": true
"default": true,
"description": "Shows vertical and horizontal thirds lines."
},
{
"id": "showCrosshair",
"label": "Center Crosshair",
"type": "bool",
"default": true
"default": true,
"description": "Shows a center crosshair for lens/framing alignment."
},
{
"id": "lineColor",
"label": "Line Color",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0]
"default": [
1,
1,
1,
1
],
"description": "Color used for guide lines and marks."
},
{
"id": "lineOpacity",
"label": "Line Opacity",
"type": "float",
"default": 0.65,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Overall visibility of the guide lines."
},
{
"id": "lineThicknessPixels",
"label": "Line Thickness",
"type": "float",
"default": 2.0,
"default": 2,
"min": 0.5,
"max": 12.0,
"step": 0.1
"max": 12,
"step": 0.1,
"description": "Guide line width in output pixels."
},
{
"id": "crosshairSizePixels",
"label": "Crosshair Size",
"type": "float",
"default": 54.0,
"min": 8.0,
"max": 240.0,
"step": 1.0
"default": 54,
"min": 8,
"max": 240,
"step": 1,
"description": "Length of each crosshair arm in output pixels."
},
{
"id": "crosshairGapPixels",
"label": "Crosshair Gap",
"type": "float",
"default": 10.0,
"min": 0.0,
"max": 80.0,
"step": 1.0
"default": 10,
"min": 0,
"max": 80,
"step": 1,
"description": "Empty gap around the exact frame center."
}
]
}

View File

@@ -15,36 +15,52 @@
"label": "Amount",
"type": "float",
"default": 0.45,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Strength of the blocky temporal smear."
},
{
"id": "blockCount",
"label": "Block Count",
"type": "vec2",
"default": [32.0, 18.0],
"min": [2.0, 2.0],
"max": [160.0, 120.0],
"step": [1.0, 1.0]
"default": [
32,
18
],
"min": [
2,
2
],
"max": [
160,
120
],
"step": [
1,
1
],
"description": "Number of glitch blocks across X and Y."
},
{
"id": "tearAmount",
"label": "Tear",
"type": "float",
"default": 0.18,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Horizontal scanline tearing intensity."
},
{
"id": "chromaShift",
"label": "Chroma Shift",
"type": "float",
"default": 1.8,
"min": 0.0,
"max": 12.0,
"step": 0.1
"min": 0,
"max": 12,
"step": 0.1,
"description": "Separate color-channel offset in pixels."
}
]
}

View File

@@ -18,7 +18,8 @@
"default": 0.28,
"min": 0.12,
"max": 0.5,
"step": 0.01
"step": 0.01,
"description": "Logo size relative to the frame."
},
{
"id": "bounceSpeed",
@@ -27,34 +28,38 @@
"default": 0.22,
"min": 0.02,
"max": 0.8,
"step": 0.01
"step": 0.01,
"description": "How fast the logo moves between edge hits."
},
{
"id": "edgePadding",
"label": "Edge Padding",
"type": "float",
"default": 0.018,
"min": 0.0,
"min": 0,
"max": 0.08,
"step": 0.001
"step": 0.001,
"description": "Inset distance from the frame edges."
},
{
"id": "glowAmount",
"label": "Glow",
"type": "float",
"default": 0.18,
"min": 0.0,
"min": 0,
"max": 0.75,
"step": 0.01
"step": 0.01,
"description": "Adds a soft colored glow around the logo."
},
{
"id": "baseAlpha",
"label": "Alpha",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.05,
"max": 1.0,
"step": 0.01
"max": 1,
"step": 0.01,
"description": "Overall opacity of the overlay."
}
]
}

View File

@@ -9,10 +9,11 @@
"id": "speed",
"label": "Speed",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 4,
"step": 0.01,
"description": "Animation speed multiplier; set to 0 to pause motion."
},
{
"id": "depth",
@@ -20,65 +21,95 @@
"type": "float",
"default": 2.5,
"min": 0.2,
"max": 8.0,
"step": 0.01
"max": 8,
"step": 0.01,
"description": "Raymarch depth through the ether volume."
},
{
"id": "density",
"label": "Density",
"type": "float",
"default": 0.7,
"min": 0.0,
"max": 2.0,
"step": 0.01
"min": 0,
"max": 2,
"step": 0.01,
"description": "Density of the volumetric strands."
},
{
"id": "brightness",
"label": "Brightness",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 3.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 3,
"step": 0.01,
"description": "Adjusts the generated effect brightness."
},
{
"id": "contrast",
"label": "Contrast",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.25,
"max": 3.0,
"step": 0.01
"max": 3,
"step": 0.01,
"description": "Adjusts separation between dark and bright areas."
},
{
"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]
"default": [
0.9,
0.5
],
"min": [
0,
0
],
"max": [
2,
2
],
"step": [
0.001,
0.001
],
"description": "Moves the generated field in normalized coordinates."
},
{
"id": "baseColor",
"label": "Base Color",
"type": "color",
"default": [0.1, 0.3, 0.4, 1.0]
"default": [
0.1,
0.3,
0.4,
1
],
"description": "Low-energy color used in the generated field."
},
{
"id": "energyColor",
"label": "Energy Color",
"type": "color",
"default": [1.0, 0.5, 0.6, 1.0]
"default": [
1,
0.5,
0.6,
1
],
"description": "High-energy color used in the generated field."
},
{
"id": "sourceMix",
"label": "Source Mix",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends the generated effect with the incoming video."
}
]
}

View File

@@ -9,34 +9,38 @@
"id": "blendAmount",
"label": "Blend",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Mix amount for the processed result."
},
{
"id": "showLuma",
"label": "Show Luma",
"type": "bool",
"default": false
"default": false,
"description": "Shows grayscale luminance before applying false-color mapping."
},
{
"id": "lift",
"label": "Lift",
"type": "float",
"default": 0.0,
"default": 0,
"min": -0.25,
"max": 0.25,
"step": 0.001
"step": 0.001,
"description": "Offsets luminance before false-color mapping."
},
{
"id": "gain",
"label": "Gain",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.25,
"max": 2.0,
"step": 0.01
"max": 2,
"step": 0.01,
"description": "Scales luminance before false-color mapping."
}
]
}

View File

@@ -9,55 +9,85 @@
"id": "lensFovDegrees",
"label": "Lens FOV",
"type": "float",
"default": 190.0,
"min": 1.0,
"max": 220.0,
"step": 0.1
"default": 190,
"min": 1,
"max": 220,
"step": 0.1,
"description": "Actual fisheye lens field of view in degrees."
},
{
"id": "center",
"label": "Optical Center",
"type": "vec2",
"default": [0.5, 0.5],
"min": [0.0, 0.0],
"max": [1.0, 1.0],
"step": [0.001, 0.001]
"default": [
0.5,
0.5
],
"min": [
0,
0
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Normalized position in the frame, where 0.5, 0.5 is center."
},
{
"id": "radius",
"label": "Fisheye Radius",
"type": "vec2",
"default": [0.5, 0.8889],
"min": [0.001, 0.001],
"max": [2.0, 2.0],
"step": [0.001, 0.001]
"default": [
0.5,
0.8889
],
"min": [
0.001,
0.001
],
"max": [
2,
2
],
"step": [
0.001,
0.001
],
"description": "Normalized fisheye radius; adjust X/Y when the image is cropped or squeezed."
},
{
"id": "yawDegrees",
"label": "Yaw",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Rotates the virtual view horizontally."
},
{
"id": "pitchDegrees",
"label": "Pitch",
"type": "float",
"default": 0.0,
"min": -120.0,
"max": 120.0,
"step": 0.1
"default": 0,
"min": -120,
"max": 120,
"step": 0.1,
"description": "Rotates the virtual view vertically."
},
{
"id": "rollDegrees",
"label": "Roll",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Live roll rotation around the viewing axis."
},
{
"id": "fisheyeModel",
@@ -65,35 +95,56 @@
"type": "enum",
"default": "equidistant",
"options": [
{ "value": "equidistant", "label": "Equidistant" },
{ "value": "equisolid", "label": "Equisolid" },
{ "value": "stereographic", "label": "Stereographic" },
{ "value": "orthographic", "label": "Orthographic" }
]
{
"value": "equidistant",
"label": "Equidistant"
},
{
"value": "equisolid",
"label": "Equisolid"
},
{
"value": "stereographic",
"label": "Stereographic"
},
{
"value": "orthographic",
"label": "Orthographic"
}
],
"description": "Projection model used by the physical fisheye lens."
},
{
"id": "edgeFill",
"label": "Edge Fill",
"type": "float",
"default": 0.06,
"min": 0.0,
"min": 0,
"max": 0.3,
"step": 0.001
"step": 0.001,
"description": "Extends edge samples outward to cover small missing areas."
},
{
"id": "edgeBlur",
"label": "Edge Blur",
"type": "float",
"default": 0.018,
"min": 0.0,
"min": 0,
"max": 0.12,
"step": 0.001
"step": 0.001,
"description": "Softens the dilated edge fill."
},
{
"id": "outsideColor",
"label": "Outside Color",
"type": "color",
"default": [0.0, 0.0, 0.0, 1.0]
"default": [
0,
0,
0,
1
],
"description": "Color used where the remapped image samples outside the source frame."
}
]
}

View File

@@ -9,91 +9,125 @@
"id": "lensFovDegrees",
"label": "Lens FOV",
"type": "float",
"default": 190.0,
"min": 1.0,
"max": 220.0,
"step": 0.1
"default": 190,
"min": 1,
"max": 220,
"step": 0.1,
"description": "Actual fisheye lens field of view in degrees."
},
{
"id": "center",
"label": "Optical Center",
"type": "vec2",
"default": [0.5, 0.5],
"min": [0.0, 0.0],
"max": [1.0, 1.0],
"step": [0.001, 0.001]
"default": [
0.5,
0.5
],
"min": [
0,
0
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Normalized position in the frame, where 0.5, 0.5 is center."
},
{
"id": "radius",
"label": "Fisheye Radius",
"type": "vec2",
"default": [0.5, 0.885],
"min": [0.001, 0.001],
"max": [2.0, 2.0],
"step": [0.001, 0.001]
"default": [
0.5,
0.885
],
"min": [
0.001,
0.001
],
"max": [
2,
2
],
"step": [
0.001,
0.001
],
"description": "Normalized fisheye radius; adjust X/Y when the image is cropped or squeezed."
},
{
"id": "virtualFovDegrees",
"label": "Virtual FOV",
"type": "float",
"default": 75.0,
"min": 1.0,
"max": 175.0,
"step": 0.1
"default": 75,
"min": 1,
"max": 175,
"step": 0.1,
"description": "Field of view of the generated virtual camera."
},
{
"id": "basePanDegrees",
"label": "Base Pan",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Permanent horizontal alignment offset before live pan."
},
{
"id": "baseTiltDegrees",
"label": "Base Tilt",
"type": "float",
"default": 0.0,
"min": -120.0,
"max": 120.0,
"step": 0.1
"default": 0,
"min": -120,
"max": 120,
"step": 0.1,
"description": "Permanent vertical alignment offset before live tilt."
},
{
"id": "baseRollDegrees",
"label": "Base Roll",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Permanent roll alignment offset before live roll."
},
{
"id": "panDegrees",
"label": "Pan",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Live horizontal view rotation."
},
{
"id": "tiltDegrees",
"label": "Tilt",
"type": "float",
"default": 0.0,
"min": -120.0,
"max": 120.0,
"step": 0.1
"default": 0,
"min": -120,
"max": 120,
"step": 0.1,
"description": "Live vertical view rotation."
},
{
"id": "rollDegrees",
"label": "Roll",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Live roll rotation around the viewing axis."
},
{
"id": "fisheyeModel",
@@ -101,11 +135,24 @@
"type": "enum",
"default": "equidistant",
"options": [
{ "value": "equidistant", "label": "Equidistant" },
{ "value": "equisolid", "label": "Equisolid" },
{ "value": "stereographic", "label": "Stereographic" },
{ "value": "orthographic", "label": "Orthographic" }
]
{
"value": "equidistant",
"label": "Equidistant"
},
{
"value": "equisolid",
"label": "Equisolid"
},
{
"value": "stereographic",
"label": "Stereographic"
},
{
"value": "orthographic",
"label": "Orthographic"
}
],
"description": "Projection model used by the physical fisheye lens."
},
{
"id": "outputProjection",
@@ -113,15 +160,28 @@
"type": "enum",
"default": "rectilinear",
"options": [
{ "value": "rectilinear", "label": "Rectilinear" },
{ "value": "cylindrical", "label": "Cylindrical" }
]
{
"value": "rectilinear",
"label": "Rectilinear"
},
{
"value": "cylindrical",
"label": "Cylindrical"
}
],
"description": "Chooses rectilinear perspective or cylindrical reprojection."
},
{
"id": "outsideColor",
"label": "Outside Color",
"type": "color",
"default": [0.0, 0.0, 0.0, 1.0]
"default": [
0,
0,
0,
1
],
"description": "Color used where the remapped image samples outside the source frame."
}
]
}

View File

@@ -1,36 +1,59 @@
{
"id": "gaussian-blur",
"name": "Gaussian Blur",
"description": "Applies a simple Gaussian-style blur to the decoded video input.",
"description": "Applies a separable two-pass Gaussian-style blur to the decoded video input.",
"category": "Transform",
"entryPoint": "shadeVideo",
"passes": [
{
"id": "horizontal",
"source": "shader.slang",
"entryPoint": "blurHorizontal",
"inputs": [
"layerInput"
],
"output": "blurHorizontal"
},
{
"id": "vertical",
"source": "shader.slang",
"entryPoint": "blurVertical",
"inputs": [
"blurHorizontal"
],
"output": "layerOutput"
}
],
"parameters": [
{
"id": "radius",
"label": "Radius",
"type": "float",
"default": 2.0,
"min": 0.0,
"max": 8.0,
"step": 0.1
"default": 2,
"min": 0,
"max": 8,
"step": 0.1,
"description": "Blur radius in pixels for each separable pass."
},
{
"id": "strength",
"label": "Strength",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends between the original and blurred result."
},
{
"id": "samples",
"label": "Samples",
"type": "float",
"default": 2.0,
"min": 0.0,
"max": 25.0,
"step": 1.0
"default": 2,
"min": 0,
"max": 25,
"step": 1,
"description": "Number of taps per direction; higher values cost more GPU time."
}
]
}

View File

@@ -1,25 +1,22 @@
float4 shadeVideo(ShaderContext context)
float4 gaussianBlurDirection(ShaderContext context, float2 direction)
{
float2 texel = 1.0 / max(context.inputResolution, float2(1.0, 1.0));
float blurRadius = max(radius, 0.0);
float2 sampleStep = texel * blurRadius;
float blurRadius = max(radius, 0.0) * saturate(strength);
float2 sampleStep = texel * blurRadius * direction;
int sampleRadius = int(clamp(samples, 0.0, 8.0) + 0.5);
float4 center = sampleVideo(context.uv);
float4 blur = float4(0.0, 0.0, 0.0, 0.0);
float totalWeight = 0.0;
for (int y = -sampleRadius; y <= sampleRadius; ++y)
for (int x = -sampleRadius; x <= sampleRadius; ++x)
{
for (int x = -sampleRadius; x <= sampleRadius; ++x)
{
float distanceSquared = float(x * x + y * y);
float sigma = max(float(sampleRadius) * 0.5, 0.5);
float weight = exp(-distanceSquared / (2.0 * sigma * sigma));
float2 offset = float2(float(x), float(y)) * sampleStep;
blur += sampleVideo(context.uv + offset) * weight;
totalWeight += weight;
}
float distanceSquared = float(x * x);
float sigma = max(float(sampleRadius) * 0.5, 0.5);
float weight = exp(-distanceSquared / (2.0 * sigma * sigma));
float2 offset = float(x) * sampleStep;
blur += sampleVideo(context.uv + offset) * weight;
totalWeight += weight;
}
if (sampleRadius == 0)
@@ -29,7 +26,20 @@ float4 shadeVideo(ShaderContext context)
}
blur /= max(totalWeight, 0.0001);
float mixValue = saturate(strength);
return lerp(center, blur, mixValue);
return blur;
}
float4 blurHorizontal(ShaderContext context)
{
return gaussianBlurDirection(context, float2(1.0, 0.0));
}
float4 blurVertical(ShaderContext context)
{
return gaussianBlurDirection(context, float2(0.0, 1.0));
}
float4 shadeVideo(ShaderContext context)
{
return blurVertical(context);
}

View File

@@ -4,15 +4,65 @@
"description": "Production-style green/blue screen keyer with matte refinement, despill, edge treatment, and debug views.",
"category": "Keying",
"entryPoint": "shadeVideo",
"passes": [
{
"id": "rawMatte",
"source": "shader.slang",
"entryPoint": "buildRawMatte",
"inputs": [
"layerInput"
],
"output": "rawMatte"
},
{
"id": "refinedMatte",
"source": "shader.slang",
"entryPoint": "refineMatte",
"inputs": [
"rawMatte"
],
"output": "refinedMatte"
},
{
"id": "final",
"source": "shader.slang",
"entryPoint": "applyKey",
"inputs": [
"refinedMatte"
],
"output": "layerOutput"
}
],
"parameters": [
{
"id": "screenColor",
"label": "Screen Color",
"type": "color",
"default": [0.15, 0.85, 0.2, 1.0],
"min": [0.0, 0.0, 0.0, 0.0],
"max": [1.0, 1.0, 1.0, 1.0],
"step": [0.01, 0.01, 0.01, 0.01]
"default": [
0.15,
0.85,
0.2,
1
],
"min": [
0,
0,
0,
0
],
"max": [
1,
1,
1,
1
],
"step": [
0.01,
0.01,
0.01,
0.01
],
"description": "Target screen color to remove; use green or blue depending on the backdrop."
},
{
"id": "threshold",
@@ -21,7 +71,8 @@
"default": 0.24,
"min": 0.01,
"max": 0.8,
"step": 0.005
"step": 0.005,
"description": "Higher values keep more foreground; lower values remove more screen."
},
{
"id": "softness",
@@ -30,142 +81,238 @@
"default": 0.16,
"min": 0.001,
"max": 0.5,
"step": 0.005
"step": 0.005,
"description": "Feathers the transition between foreground and keyed screen."
},
{
"id": "screenBalance",
"label": "Screen Balance",
"type": "float",
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.005
"min": 0,
"max": 1,
"step": 0.005,
"description": "Balances chroma-distance keying against color-direction keying."
},
{
"id": "screenPreBlur",
"label": "Screen PreBlur",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 8.0,
"step": 0.1
"default": 1,
"min": 0,
"max": 8,
"step": 0.1,
"description": "Blurs source color before matte generation to reduce noisy edges."
},
{
"id": "erodeDilate",
"label": "Erode/Dilate",
"type": "float",
"default": 0.0,
"default": 0,
"min": -0.3,
"max": 0.3,
"step": 0.005
"step": 0.005,
"description": "Negative erodes the matte; positive expands it."
},
{
"id": "matteBlur",
"label": "Matte Blur",
"type": "float",
"default": 1.25,
"min": 0.0,
"max": 6.0,
"step": 0.1
"min": 0,
"max": 6,
"step": 0.1,
"description": "Softens the generated matte after keying."
},
{
"id": "matteGamma",
"label": "Matte Gamma",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.25,
"max": 4.0,
"step": 0.01
"max": 4,
"step": 0.01,
"description": "Shapes midtone opacity in the matte."
},
{
"id": "matteContrast",
"label": "Matte Contrast",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.25,
"max": 4.0,
"step": 0.01
"max": 4,
"step": 0.01,
"description": "Increases or reduces matte separation around 50 percent alpha."
},
{
"id": "blackCleanup",
"label": "Black Cleanup",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.005
"default": 0,
"min": 0,
"max": 1,
"step": 0.005,
"description": "Pushes semi-transparent dark matte areas toward transparent."
},
{
"id": "whiteCleanup",
"label": "White Cleanup",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.005
"default": 0,
"min": 0,
"max": 1,
"step": 0.005,
"description": "Pushes semi-transparent light matte areas toward opaque."
},
{
"id": "despill",
"label": "Despill",
"type": "float",
"default": 0.45,
"min": 0.0,
"min": 0,
"max": 1.5,
"step": 0.01
"step": 0.01,
"description": "Removes screen-colored contamination from foreground edges."
},
{
"id": "despillBias",
"label": "Despill Bias",
"type": "float",
"default": 0.0,
"default": 0,
"min": -0.5,
"max": 0.5,
"step": 0.005
"step": 0.005,
"description": "Offsets spill detection when foreground colors are close to the screen color."
},
{
"id": "spillTint",
"label": "Spill Tint",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0],
"min": [0.0, 0.0, 0.0, 0.0],
"max": [1.0, 1.0, 1.0, 1.0],
"step": [0.01, 0.01, 0.01, 0.01]
"default": [
1,
1,
1,
1
],
"min": [
0,
0,
0,
0
],
"max": [
1,
1,
1,
1
],
"step": [
0.01,
0.01,
0.01,
0.01
],
"description": "Tint used when neutralizing spill."
},
{
"id": "edgeRecover",
"label": "Edge Recover",
"type": "float",
"default": 0.18,
"min": 0.0,
"max": 1.0,
"step": 0.005
"min": 0,
"max": 1,
"step": 0.005,
"description": "Adds color recovery along semi-transparent matte edges."
},
{
"id": "edgeColor",
"label": "Edge Color",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0],
"min": [0.0, 0.0, 0.0, 0.0],
"max": [1.0, 1.0, 1.0, 1.0],
"step": [0.01, 0.01, 0.01, 0.01]
"default": [
1,
1,
1,
1
],
"min": [
0,
0,
0,
0
],
"max": [
1,
1,
1,
1
],
"step": [
0.01,
0.01,
0.01,
0.01
],
"description": "Tint applied to recovered edge detail."
},
{
"id": "clipBlack",
"label": "Clip Black",
"type": "float",
"default": 0.0,
"min": 0.0,
"default": 0,
"min": 0,
"max": 0.5,
"step": 0.005
"step": 0.005,
"description": "Matte values below this become transparent."
},
{
"id": "clipWhite",
"label": "Clip White",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.5,
"max": 1.0,
"step": 0.005
"max": 1,
"step": 0.005,
"description": "Matte values above this become opaque."
},
{
"id": "cropLeft",
"label": "Crop Left",
"description": "Trims the final matte from the left edge as a fraction of frame width.",
"type": "float",
"default": 0,
"min": 0,
"max": 0.5,
"step": 0.001
},
{
"id": "cropRight",
"label": "Crop Right",
"description": "Trims the final matte from the right edge as a fraction of frame width.",
"type": "float",
"default": 0,
"min": 0,
"max": 0.5,
"step": 0.001
},
{
"id": "cropTop",
"label": "Crop Top",
"description": "Trims the final matte from the top edge as a fraction of frame height.",
"type": "float",
"default": 0,
"min": 0,
"max": 0.5,
"step": 0.001
},
{
"id": "cropBottom",
"label": "Crop Bottom",
"description": "Trims the final matte from the bottom edge as a fraction of frame height.",
"type": "float",
"default": 0,
"min": 0,
"max": 0.5,
"step": 0.001
},
{
"id": "viewMode",
@@ -173,12 +320,28 @@
"type": "enum",
"default": "composite",
"options": [
{ "value": "composite", "label": "Composite" },
{ "value": "matte", "label": "Matte" },
{ "value": "spill", "label": "Spill" },
{ "value": "despill", "label": "Despill" },
{ "value": "status", "label": "Status" }
]
{
"value": "composite",
"label": "Composite"
},
{
"value": "matte",
"label": "Matte"
},
{
"value": "spill",
"label": "Spill"
},
{
"value": "despill",
"label": "Despill"
},
{
"value": "status",
"label": "Status"
}
],
"description": "Debug output mode for inspecting matte, spill, and despill stages."
}
]
}

View File

@@ -50,12 +50,17 @@ float rawAlphaAt(float2 uv, ShaderContext context)
return saturate(alpha);
}
float refinedAlphaAt(float2 uv, ShaderContext context)
float matteAlphaAt(float2 uv)
{
return saturate(sampleVideo(saturate(uv)).a);
}
float refinedAlphaFromMatte(float2 uv, ShaderContext context)
{
float2 pixel = 1.0 / max(context.outputResolution, float2(1.0, 1.0));
float blur = max(matteBlur, 0.0);
float aaRadius = max(blur, 0.65);
float centerAlpha = rawAlphaAt(uv, context);
float centerAlpha = matteAlphaAt(uv);
float alpha = centerAlpha * 0.30;
if (aaRadius > 0.0001)
@@ -64,51 +69,51 @@ float refinedAlphaAt(float2 uv, ShaderContext context)
float2 halfRadius = radius * 0.5;
float alphaMin = centerAlpha;
float alphaMax = centerAlpha;
float sampleAlpha = rawAlphaAt(uv + float2(halfRadius.x, 0.0), context);
float sampleAlpha = matteAlphaAt(uv + float2(halfRadius.x, 0.0));
alpha += sampleAlpha * 0.065;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv - float2(halfRadius.x, 0.0), context);
sampleAlpha = matteAlphaAt(uv - float2(halfRadius.x, 0.0));
alpha += sampleAlpha * 0.065;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv + float2(0.0, halfRadius.y), context);
sampleAlpha = matteAlphaAt(uv + float2(0.0, halfRadius.y));
alpha += sampleAlpha * 0.065;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv - float2(0.0, halfRadius.y), context);
sampleAlpha = matteAlphaAt(uv - float2(0.0, halfRadius.y));
alpha += sampleAlpha * 0.065;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv + float2(radius.x, 0.0), context);
sampleAlpha = matteAlphaAt(uv + float2(radius.x, 0.0));
alpha += sampleAlpha * 0.06;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv - float2(radius.x, 0.0), context);
sampleAlpha = matteAlphaAt(uv - float2(radius.x, 0.0));
alpha += sampleAlpha * 0.06;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv + float2(0.0, radius.y), context);
sampleAlpha = matteAlphaAt(uv + float2(0.0, radius.y));
alpha += sampleAlpha * 0.06;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv - float2(0.0, radius.y), context);
sampleAlpha = matteAlphaAt(uv - float2(0.0, radius.y));
alpha += sampleAlpha * 0.06;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv + radius, context);
sampleAlpha = matteAlphaAt(uv + radius);
alpha += sampleAlpha * 0.05;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv - radius, context);
sampleAlpha = matteAlphaAt(uv - radius);
alpha += sampleAlpha * 0.05;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv + float2(radius.x, -radius.y), context);
sampleAlpha = matteAlphaAt(uv + float2(radius.x, -radius.y));
alpha += sampleAlpha * 0.05;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
sampleAlpha = rawAlphaAt(uv + float2(-radius.x, radius.y), context);
sampleAlpha = matteAlphaAt(uv + float2(-radius.x, radius.y));
alpha += sampleAlpha * 0.05;
alphaMin = min(alphaMin, sampleAlpha);
alphaMax = max(alphaMax, sampleAlpha);
@@ -118,7 +123,7 @@ float refinedAlphaAt(float2 uv, ShaderContext context)
}
else
{
alpha = rawAlphaAt(uv, context);
alpha = centerAlpha;
}
alpha = saturate((alpha - clipBlack) / max(clipWhite - clipBlack, 0.0001));
@@ -147,17 +152,43 @@ float3 despillColor(float3 color, float alpha)
return saturate(neutralized);
}
float4 shadeVideo(ShaderContext context)
float cropMaskAt(float2 uv, ShaderContext context)
{
float2 feather = 1.0 / max(context.outputResolution, float2(1.0, 1.0));
float left = smoothstep(saturate(cropLeft), saturate(cropLeft) + feather.x, uv.x);
float right = 1.0 - smoothstep(1.0 - saturate(cropRight) - feather.x, 1.0 - saturate(cropRight), uv.x);
float top = smoothstep(saturate(cropTop), saturate(cropTop) + feather.y, uv.y);
float bottom = 1.0 - smoothstep(1.0 - saturate(cropBottom) - feather.y, 1.0 - saturate(cropBottom), uv.y);
return saturate(left * right * top * bottom);
}
float4 buildRawMatte(ShaderContext context)
{
float4 src = context.sourceColor;
float3 color = saturate(src.rgb);
float alpha = refinedAlphaAt(context.uv, context);
float alpha = rawAlphaAt(context.uv, context);
return float4(color, alpha);
}
float4 refineMatte(ShaderContext context)
{
float4 raw = sampleVideo(context.uv);
float alpha = refinedAlphaFromMatte(context.uv, context);
return float4(saturate(raw.rgb), alpha);
}
float4 applyKey(ShaderContext context)
{
float4 keyed = sampleVideo(context.uv);
float3 color = saturate(keyed.rgb);
float alpha = saturate(keyed.a);
float spill = spillAmountForColor(color);
float3 despilled = despillColor(color, alpha);
float cropMask = cropMaskAt(context.uv, context);
alpha *= cropMask;
float edgeAmount = saturate(1.0 - abs(alpha * 2.0 - 1.0));
despilled = lerp(despilled, despilled * saturate(edgeColor.rgb), edgeAmount * saturate(edgeRecover));
alpha = saturate(lerp(alpha, rawAlphaAt(context.uv, context), edgeAmount * saturate(edgeRecover) * 0.35));
if (viewMode == 1)
return float4(alpha, alpha, alpha, 1.0);
@@ -167,10 +198,15 @@ float4 shadeVideo(ShaderContext context)
return float4(despilled, 1.0);
if (viewMode == 4)
{
float rawAlpha = rawAlphaAt(context.uv, context);
float rawAlpha = rawAlphaAt(context.uv, context) * cropMask;
return float4(rawAlpha, alpha, spill, 1.0);
}
float3 premultiplied = saturate(despilled) * alpha;
return float4(premultiplied, alpha);
}
float4 shadeVideo(ShaderContext context)
{
return applyKey(context);
}

View File

@@ -9,46 +9,51 @@
"id": "speed",
"label": "Speed",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 4,
"step": 0.01,
"description": "Animation speed multiplier; set to 0 to pause motion."
},
{
"id": "scale",
"label": "Scale",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.25,
"max": 3.0,
"step": 0.01
"max": 3,
"step": 0.01,
"description": "Overall size of the effect in the frame."
},
{
"id": "raySteps",
"label": "Ray Steps",
"type": "float",
"default": 77.0,
"min": 8.0,
"max": 77.0,
"step": 1.0
"default": 77,
"min": 8,
"max": 77,
"step": 1,
"description": "Raymarch iteration count; higher values increase detail and GPU cost."
},
{
"id": "intensity",
"label": "Intensity",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.1,
"max": 4.0,
"step": 0.01
"max": 4,
"step": 0.01,
"description": "Overall brightness of the accumulated raymarched light."
},
{
"id": "sourceMix",
"label": "Source Mix",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends the generated effect with the incoming video."
}
]
}

View File

@@ -9,34 +9,59 @@
"id": "lift",
"label": "Lift",
"type": "color",
"default": [0.5, 0.5, 0.5, 1.0]
"default": [
0.5,
0.5,
0.5,
1
],
"description": "Adds color mostly to shadows."
},
{
"id": "gamma",
"label": "Gamma",
"type": "color",
"default": [0.5, 0.5, 0.5, 1.0]
"default": [
0.5,
0.5,
0.5,
1
],
"description": "Balances midtone color response."
},
{
"id": "gain",
"label": "Gain",
"type": "color",
"default": [0.5, 0.5, 0.5, 1.0]
"default": [
0.5,
0.5,
0.5,
1
],
"description": "Scales highlights and overall channel intensity."
},
{
"id": "offset",
"label": "Offset",
"type": "color",
"default": [0.5, 0.5, 0.5, 1.0]
"default": [
0.5,
0.5,
0.5,
1
],
"description": "Adds a uniform color offset after lift/gamma/gain."
},
{
"id": "strength",
"label": "Strength",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends the grade with the original image."
}
]
}

View File

@@ -15,43 +15,48 @@
"id": "lutStrength",
"label": "LUT Strength",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends between the original image and the LUT result."
},
{
"id": "preExposure",
"label": "Pre Exposure",
"type": "float",
"default": 0.0,
"min": -4.0,
"max": 4.0,
"step": 0.01
"default": 0,
"min": -4,
"max": 4,
"step": 0.01,
"description": "Exposure offset applied before the LUT lookup."
},
{
"id": "postContrast",
"label": "Post Contrast",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 2,
"step": 0.01,
"description": "Contrast adjustment applied after the LUT lookup."
},
{
"id": "ditherAmount",
"label": "Output Dither",
"type": "float",
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Adds subtle output dither to reduce visible banding."
},
{
"id": "clampInput",
"label": "Clamp Input",
"type": "bool",
"default": true
"default": true,
"description": "Clamps colors to 0-1 before the LUT lookup."
}
]
}

View File

@@ -0,0 +1,49 @@
{
"id": "multipass-test",
"name": "Multipass Test",
"description": "Diagnostic two-pass shader that generates a mask in pass one, then samples that named intermediate in pass two.",
"category": "Utility",
"entryPoint": "shadeVideo",
"passes": [
{
"id": "mask",
"source": "shader.slang",
"entryPoint": "buildMask",
"inputs": [
"layerInput"
],
"output": "generatedMask"
},
{
"id": "final",
"source": "shader.slang",
"entryPoint": "applyMask",
"inputs": [
"generatedMask"
],
"output": "layerOutput"
}
],
"parameters": [
{
"id": "intensity",
"label": "Intensity",
"type": "float",
"default": 1,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Opacity of the second-pass diagnostic overlay."
},
{
"id": "scale",
"label": "Scale",
"type": "float",
"default": 10,
"min": 2,
"max": 32,
"step": 1,
"description": "Size of the generated test pattern."
}
]
}

View File

@@ -0,0 +1,37 @@
float ringMask(float2 uv)
{
float2 centered = uv * 2.0 - 1.0;
float radius = length(centered);
float ring = 1.0 - smoothstep(0.015, 0.035, abs(radius - 0.55));
float cross = 1.0 - smoothstep(0.006, 0.018, min(abs(centered.x), abs(centered.y)));
return saturate(max(ring, cross));
}
float gridMask(float2 uv)
{
float2 cell = abs(frac(uv * max(scale, 1.0)) - 0.5);
float line = 1.0 - smoothstep(0.455, 0.495, max(cell.x, cell.y));
return saturate(line * 0.55);
}
float4 buildMask(ShaderContext context)
{
float mask = saturate(max(ringMask(context.uv), gridMask(context.uv)));
return float4(context.sourceColor.rgb, mask);
}
float4 applyMask(ShaderContext context)
{
float4 generated = sampleVideo(context.uv);
float mask = generated.a;
float checker = step(0.5, frac((context.uv.x + context.uv.y) * max(scale, 1.0)));
float3 testColor = lerp(float3(0.0, 0.75, 1.0), float3(1.0, 0.1, 0.85), checker);
float3 base = generated.rgb;
float3 color = lerp(base, testColor, mask * saturate(intensity));
return float4(color, 1.0);
}
float4 shadeVideo(ShaderContext context)
{
return applyMask(context);
}

View File

@@ -9,25 +9,45 @@
"id": "pixelCount",
"label": "Pixel Count",
"type": "vec2",
"default": [96.0, 54.0],
"min": [2.0, 2.0],
"max": [1920.0, 1080.0],
"step": [1.0, 1.0]
"default": [
96,
54
],
"min": [
2,
2
],
"max": [
1920,
1080
],
"step": [
1,
1
],
"description": "Number of pixel blocks across X and Y."
},
{
"id": "gridAmount",
"label": "Grid",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Visibility of the block grid lines."
},
{
"id": "gridColor",
"label": "Grid Color",
"type": "color",
"default": [0.0, 0.0, 0.0, 1.0]
"default": [
0,
0,
0,
1
],
"description": "Color used for the pixel grid."
}
]
}

View File

@@ -5,32 +5,58 @@
"category": "Scopes & Guides",
"entryPoint": "shadeVideo",
"parameters": [
{ "id": "showActionSafe", "label": "Action Safe", "type": "bool", "default": true },
{ "id": "showTitleSafe", "label": "Title Safe", "type": "bool", "default": true },
{ "id": "showCenter", "label": "Center Marks", "type": "bool", "default": true },
{
"id": "showActionSafe",
"label": "Action Safe",
"type": "bool",
"default": true,
"description": "Shows the broadcast action-safe rectangle."
},
{
"id": "showTitleSafe",
"label": "Title Safe",
"type": "bool",
"default": true,
"description": "Shows the broadcast title-safe rectangle."
},
{
"id": "showCenter",
"label": "Center Marks",
"type": "bool",
"default": true,
"description": "Shows center marks for alignment."
},
{
"id": "lineColor",
"label": "Line Color",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0]
"default": [
1,
1,
1,
1
],
"description": "Color used for guide lines and marks."
},
{
"id": "lineOpacity",
"label": "Line Opacity",
"type": "float",
"default": 0.65,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Overall visibility of the guide lines."
},
{
"id": "lineThicknessPixels",
"label": "Line Thickness",
"type": "float",
"default": 2.0,
"default": 2,
"min": 0.5,
"max": 12.0,
"step": 0.1
"max": 12,
"step": 0.1,
"description": "Guide line width in output pixels."
},
{
"id": "aspectMode",
@@ -38,20 +64,34 @@
"type": "enum",
"default": "none",
"options": [
{ "value": "none", "label": "None" },
{ "value": "239", "label": "2.39:1" },
{ "value": "185", "label": "1.85:1" },
{ "value": "square", "label": "1:1" }
]
{
"value": "none",
"label": "None"
},
{
"value": "239",
"label": "2.39:1"
},
{
"value": "185",
"label": "1.85:1"
},
{
"value": "square",
"label": "1:1"
}
],
"description": "Adds an optional framing matte for common delivery ratios."
},
{
"id": "matteOpacity",
"label": "Matte Opacity",
"type": "float",
"default": 0.35,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Opacity of the aspect-ratio matte outside the active image."
}
]
}

View File

@@ -9,10 +9,11 @@
"id": "speed",
"label": "Speed",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 4,
"step": 0.01,
"description": "Animation speed multiplier; set to 0 to pause motion."
},
{
"id": "scale",
@@ -21,16 +22,18 @@
"default": 0.7,
"min": 0.25,
"max": 1.5,
"step": 0.01
"step": 0.01,
"description": "Overall size of the effect in the frame."
},
{
"id": "strength",
"label": "Gravity",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.1,
"max": 3.0,
"step": 0.01
"max": 3,
"step": 0.01,
"description": "Strength of the lensing/gravity distortion."
},
{
"id": "ringRadius",
@@ -39,7 +42,8 @@
"default": 0.7,
"min": 0.2,
"max": 1.4,
"step": 0.01
"step": 0.01,
"description": "Radius of the bright accretion ring."
},
{
"id": "tightness",
@@ -47,44 +51,61 @@
"type": "float",
"default": 1.35,
"min": 0.5,
"max": 3.0,
"step": 0.01
"max": 3,
"step": 0.01,
"description": "Concentration of the ring and spiral detail."
},
{
"id": "brightness",
"label": "Brightness",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.1,
"max": 4.0,
"step": 0.01
"max": 4,
"step": 0.01,
"description": "Adjusts the generated effect brightness."
},
{
"id": "colorShift",
"label": "Color Shift",
"type": "float",
"default": 1.0,
"min": -2.0,
"max": 2.0,
"step": 0.01
"default": 1,
"min": -2,
"max": 2,
"step": 0.01,
"description": "Cycles the generated color palette."
},
{
"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]
"default": [
0,
0
],
"min": [
-1,
-1
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Moves the black hole center in normalized coordinates."
},
{
"id": "sourceMix",
"label": "Source Mix",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends the generated effect with the incoming video."
}
]
}

View File

@@ -9,7 +9,13 @@
"id": "fillColor",
"label": "Fill",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0]
"default": [
1,
1,
1,
1
],
"description": "Frame fill color; alpha is preserved for key-capable outputs."
}
]
}

View File

@@ -15,33 +15,42 @@
"label": "Echo Amount",
"type": "float",
"default": 0.55,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Overall visibility of the temporal echoes."
},
{
"id": "decay",
"label": "Decay",
"type": "float",
"default": 0.72,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "How quickly older temporal echoes fade away."
},
{
"id": "frameStride",
"label": "Frame Stride",
"type": "float",
"default": 2.0,
"min": 1.0,
"max": 6.0,
"step": 1.0
"default": 2,
"min": 1,
"max": 6,
"step": 1,
"description": "Number of frames skipped between each echo sample."
},
{
"id": "echoTint",
"label": "Echo Tint",
"type": "color",
"default": [0.65, 0.85, 1.0, 1.0]
"default": [
0.65,
0.85,
1,
1
],
"description": "Tint applied to older echo frames."
}
]
}

View File

@@ -15,18 +15,20 @@
"label": "Current Mix",
"type": "float",
"default": 0.72,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Contribution of the current frame in the temporal blend."
},
{
"id": "trailMix",
"label": "Trail Mix",
"type": "float",
"default": 0.28,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Contribution of older frames in the temporal blend."
}
]
}

View File

@@ -14,19 +14,21 @@
"id": "holdFrames",
"label": "Hold Frames",
"type": "float",
"default": 3.0,
"min": 0.0,
"max": 7.0,
"step": 0.1
"default": 3,
"min": 0,
"max": 7,
"step": 0.1,
"description": "How many previous frames to hold for the low-FPS effect."
},
{
"id": "blendAmount",
"label": "Blend",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Mix amount for the processed result."
}
]
}

View File

@@ -17,16 +17,30 @@
"type": "text",
"default": "VIDEO SHADER",
"font": "roboto",
"maxLength": 64
"maxLength": 64,
"description": "Text string rendered into the SDF text texture."
},
{
"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]
"default": [
0.08,
0.12
],
"min": [
0,
0
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Normalized placement of the text block in the frame."
},
{
"id": "scale",
@@ -35,37 +49,52 @@
"default": 0.42,
"min": 0.1,
"max": 3,
"step": 0.01
"step": 0.01,
"description": "Text size multiplier."
},
{
"id": "fillColor",
"label": "Fill",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0]
"default": [
1,
1,
1,
1
],
"description": "Main text fill color and alpha."
},
{
"id": "outlineColor",
"label": "Outline",
"type": "color",
"default": [0.0, 0.0, 0.0, 0.8]
"default": [
0,
0,
0,
0.8
],
"description": "Text outline color and alpha."
},
{
"id": "outlineWidth",
"label": "Outline Width",
"type": "float",
"default": 0.12,
"min": 0.0,
"min": 0,
"max": 0.5,
"step": 0.01
"step": 0.01,
"description": "Width of the SDF outline around the text."
},
{
"id": "softness",
"label": "Softness",
"type": "float",
"default": 0.04,
"min": 0.0,
"min": 0,
"max": 0.3,
"step": 0.01
"step": 0.01,
"description": "Smoothness of the SDF text edge."
}
]
}

View File

@@ -8,25 +8,40 @@
{
"id": "drop",
"label": "Drop",
"type": "trigger"
"type": "trigger",
"description": "Momentary trigger that starts a new ripple from the selected center."
},
{
"id": "center",
"label": "Center",
"type": "vec2",
"default": [0.5, 0.5],
"min": [0.0, 0.0],
"max": [1.0, 1.0],
"step": [0.01, 0.01]
"default": [
0.5,
0.5
],
"min": [
0,
0
],
"max": [
1,
1
],
"step": [
0.01,
0.01
],
"description": "Origin of the triggered ripple in normalized coordinates."
},
{
"id": "strength",
"label": "Strength",
"type": "float",
"default": 0.12,
"min": 0.0,
"min": 0,
"max": 0.3,
"step": 0.001
"step": 0.001,
"description": "Amount of UV distortion caused by the ripple."
},
{
"id": "speed",
@@ -34,8 +49,9 @@
"type": "float",
"default": 0.3,
"min": 0.3,
"max": 5.0,
"step": 0.01
"max": 5,
"step": 0.01,
"description": "How long the ripple takes to expand and fade."
},
{
"id": "width",
@@ -44,7 +60,8 @@
"default": 0.09,
"min": 0.01,
"max": 0.25,
"step": 0.001
"step": 0.001,
"description": "Thickness of the travelling ripple ring."
},
{
"id": "damping",
@@ -52,8 +69,9 @@
"type": "float",
"default": 0.25,
"min": 0.05,
"max": 3.0,
"step": 0.05
"max": 3,
"step": 0.05,
"description": "How quickly the ripple fades as it expands."
}
]
}

View File

@@ -9,7 +9,8 @@
"id": "showLocalTime",
"label": "Show Local Time",
"type": "bool",
"default": true
"default": true,
"description": "Uses the PC UTC offset for local time; disable for pure UTC."
},
{
"id": "clockScale",
@@ -18,19 +19,32 @@
"default": 0.7,
"min": 0.25,
"max": 0.95,
"step": 0.01
"step": 0.01,
"description": "Size of the clock face relative to the frame."
},
{
"id": "faceColor",
"label": "Face Color",
"type": "color",
"default": [0.03, 0.04, 0.05, 0.82]
"default": [
0.03,
0.04,
0.05,
0.82
],
"description": "Clock face fill color and alpha."
},
{
"id": "accentColor",
"label": "Accent Color",
"type": "color",
"default": [0.1, 0.62, 0.86, 1.0]
"default": [
0.1,
0.62,
0.86,
1
],
"description": "Accent color for the seconds hand and glow."
}
]
}

View File

@@ -4,123 +4,156 @@
"description": "VHS with wiggle, smear, and YIQ-style color separation inspired by nostalgic analog references.",
"category": "Glitch",
"entryPoint": "shadeVideo",
"passes": [
{
"id": "tapeSmear",
"source": "shader.slang",
"entryPoint": "buildTapeSmear",
"inputs": [
"layerInput"
],
"output": "tapeSmear"
},
{
"id": "final",
"source": "shader.slang",
"entryPoint": "finishVhs",
"inputs": [
"tapeSmear"
],
"output": "layerOutput"
}
],
"parameters": [
{
"id": "wiggle",
"label": "Wiggle",
"type": "float",
"default": 0.03,
"min": 0.0,
"min": 0,
"max": 1.5,
"step": 0.01
"step": 0.01,
"description": "Horizontal tape wobble amount."
},
{
"id": "wiggleSpeed",
"label": "Wiggle Speed",
"type": "float",
"default": 25.0,
"min": 0.0,
"max": 100.0,
"step": 1.0
"default": 25,
"min": 0,
"max": 100,
"step": 1,
"description": "Speed of the tape wobble modulation."
},
{
"id": "smear",
"label": "Smear",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 2,
"step": 0.01,
"description": "Horizontal color/luma smear strength."
},
{
"id": "blurSamples",
"label": "Blur Samples",
"type": "float",
"default": 15.0,
"min": 3.0,
"max": 15.0,
"step": 1.0
"default": 15,
"min": 3,
"max": 15,
"step": 1,
"description": "Number of smear samples; higher values are smoother but heavier."
},
{
"id": "vignetteAmount",
"label": "Vignette",
"type": "float",
"default": 0.18,
"min": 0.0,
"min": 0,
"max": 0.6,
"step": 0.01
"step": 0.01,
"description": "Darkens and softens the frame edges."
},
{
"id": "aberrationAmount",
"label": "Aberration",
"type": "float",
"default": 0.75,
"min": 0.0,
"max": 3.0,
"step": 0.05
"min": 0,
"max": 3,
"step": 0.05,
"description": "Color-channel separation amount."
},
{
"id": "halationAmount",
"label": "Halation",
"type": "float",
"default": 0.12,
"min": 0.0,
"min": 0,
"max": 0.5,
"step": 0.01
"step": 0.01,
"description": "Red/orange glow around bright areas."
},
{
"id": "bloomAmount",
"label": "Bloom",
"type": "float",
"default": 0.18,
"min": 0.0,
"min": 0,
"max": 0.6,
"step": 0.01
"step": 0.01,
"description": "Soft glow strength from bright video regions."
},
{
"id": "fadeAmount",
"label": "Fade",
"type": "float",
"default": 0.22,
"min": 0.0,
"min": 0,
"max": 0.75,
"step": 0.01
"step": 0.01,
"description": "Washed-out tape fade amount."
},
{
"id": "noiseAmount",
"label": "Noise",
"type": "float",
"default": 0.055,
"min": 0.0,
"min": 0,
"max": 0.2,
"step": 0.005
"step": 0.005,
"description": "Fine grain/noise intensity."
},
{
"id": "staticAmount",
"label": "Analog Static",
"type": "float",
"default": 0.045,
"min": 0.0,
"min": 0,
"max": 0.25,
"step": 0.005
"step": 0.005,
"description": "Random bright static intensity."
},
{
"id": "staticLines",
"label": "Static Lines",
"type": "float",
"default": 0.65,
"min": 0.0,
"min": 0,
"max": 1.5,
"step": 0.01
"step": 0.01,
"description": "Horizontal static line visibility."
},
{
"id": "noiseSize",
"label": "Noise Size",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.25,
"max": 6.0,
"step": 0.05
"max": 6,
"step": 0.05,
"description": "Scale of the generated noise pattern."
}
]
}

View File

@@ -158,10 +158,15 @@ float3 blurVhs(float2 uv, float d, int sampleCount)
return sum;
}
float4 shadeVideo(ShaderContext context)
float distortedTapeTime(ShaderContext context)
{
return context.time + context.startupRandom * 113.0;
}
float4 buildTapeSmear(ShaderContext context)
{
float2 uv = context.uv;
float time = context.time + context.startupRandom * 113.0;
float time = distortedTapeTime(context);
float framecount = frac(time * wiggleSpeed / 7.0) * 7.0;
int sampleCount = int(clamp(blurSamples, 3.0, 15.0) + 0.5);
@@ -189,6 +194,13 @@ float4 shadeVideo(ShaderContext context)
float q = rgb2yiq(qBlur).b;
float3 color = yiq2rgb(float3(y, i, q)) - pow(s + e * 2.0, 3.0);
return float4(saturate(color), 1.0);
}
float4 finishVhs(ShaderContext context)
{
float time = distortedTapeTime(context);
float3 color = sampleVideo(context.uv).rgb;
float2 centered = context.uv * 2.0 - 1.0;
centered.x *= context.outputResolution.x / max(context.outputResolution.y, 1.0);
@@ -238,3 +250,8 @@ float4 shadeVideo(ShaderContext context)
return float4(saturate(color), 1.0);
}
float4 shadeVideo(ShaderContext context)
{
return finishVhs(context);
}

View File

@@ -9,10 +9,11 @@
"id": "spinSpeed",
"label": "Spin Speed",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01
"default": 1,
"min": 0,
"max": 4,
"step": 0.01,
"description": "Rotation speed of the cube."
},
{
"id": "cubeScale",
@@ -21,25 +22,28 @@
"default": 0.85,
"min": 0.3,
"max": 1.4,
"step": 0.01
"step": 0.01,
"description": "Size of the cube in the frame."
},
{
"id": "faceZoom",
"label": "Face Zoom",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.5,
"max": 2.0,
"step": 0.01
"max": 2,
"step": 0.01,
"description": "Zoom applied to the video on each cube face."
},
{
"id": "backgroundMix",
"label": "Background Mix",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Mixes the original video behind the generated cube."
}
]
}

View File

@@ -9,28 +9,43 @@
"id": "zoom",
"label": "Zoom",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.1,
"max": 8.0,
"step": 0.01
"max": 8,
"step": 0.01,
"description": "Scales the source image before edge handling is applied."
},
{
"id": "pan",
"label": "Pan",
"type": "vec2",
"default": [0.0, 0.0],
"min": [-1.0, -1.0],
"max": [1.0, 1.0],
"step": [0.001, 0.001]
"default": [
0,
0
],
"min": [
-1,
-1
],
"max": [
1,
1
],
"step": [
0.001,
0.001
],
"description": "Moves the source image in normalized frame units."
},
{
"id": "rotationDegrees",
"label": "Rotation",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
"default": 0,
"min": -180,
"max": 180,
"step": 0.1,
"description": "Rotates the source image around the frame center."
},
{
"id": "edgeMode",
@@ -38,17 +53,36 @@
"type": "enum",
"default": "black",
"options": [
{ "value": "black", "label": "Black" },
{ "value": "clamp", "label": "Clamp" },
{ "value": "wrap", "label": "Wrap" },
{ "value": "mirror", "label": "Mirror" }
]
{
"value": "black",
"label": "Black"
},
{
"value": "clamp",
"label": "Clamp"
},
{
"value": "wrap",
"label": "Wrap"
},
{
"value": "mirror",
"label": "Mirror"
}
],
"description": "Chooses how samples outside the source frame are filled."
},
{
"id": "outsideColor",
"label": "Outside Color",
"type": "color",
"default": [0.0, 0.0, 0.0, 1.0]
"default": [
0,
0,
0,
1
],
"description": "Color used where the remapped image samples outside the source frame."
}
]
}

View File

@@ -33,44 +33,61 @@
"type": "float",
"default": 0.4,
"min": 0.1,
"max": 1.0,
"step": 0.01
"max": 1,
"step": 0.01,
"description": "Size of the waveform panel."
},
{
"id": "overlayPosition",
"label": "Overlay Position",
"type": "vec2",
"default": [0.24, 0.76],
"min": [0.0, 0.0],
"max": [1.0, 1.0],
"step": [0.01, 0.01]
"default": [
0.24,
0.76
],
"min": [
0,
0
],
"max": [
1,
1
],
"step": [
0.01,
0.01
],
"description": "Normalized position of the waveform panel."
},
{
"id": "overlayPadding",
"label": "Overlay Padding",
"type": "float",
"default": 0.08,
"min": 0.0,
"min": 0,
"max": 0.25,
"step": 0.01
"step": 0.01,
"description": "Padding inside the waveform panel."
},
{
"id": "waveformOpacity",
"label": "Waveform Opacity",
"type": "float",
"default": 0.75,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Opacity of the waveform trace."
},
{
"id": "backgroundOpacity",
"label": "Background",
"type": "float",
"default": 0.75,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Opacity of the waveform background panel."
},
{
"id": "lineThickness",
@@ -78,50 +95,61 @@
"type": "float",
"default": 1.5,
"min": 0.5,
"max": 10.0,
"step": 0.1
"max": 10,
"step": 0.1,
"description": "Thickness of the waveform trace in pixels."
},
{
"id": "gridOpacity",
"label": "Grid Opacity",
"type": "float",
"default": 1,
"min": 0.0,
"max": 1.0,
"step": 0.01
"min": 0,
"max": 1,
"step": 0.01,
"description": "Opacity of the waveform grid and labels."
},
{
"id": "waveformSamples",
"label": "Waveform Samples",
"type": "float",
"default": 64.0,
"min": 8.0,
"max": 96.0,
"step": 1.0
"default": 64,
"min": 8,
"max": 96,
"step": 1,
"description": "Number of vertical samples used to build the waveform."
},
{
"id": "waveformGain",
"label": "Waveform Gain",
"type": "float",
"default": 12.0,
"min": 1.0,
"max": 32.0,
"step": 0.5
"default": 12,
"min": 1,
"max": 32,
"step": 0.5,
"description": "Brightness/intensity of waveform hits."
},
{
"id": "waveformNoiseReduction",
"label": "Noise Reduction",
"type": "float",
"default": 0.08,
"min": 0.0,
"min": 0,
"max": 0.6,
"step": 0.01
"step": 0.01,
"description": "Suppresses faint waveform speckle."
},
{
"id": "waveformColor",
"label": "Waveform Color",
"type": "color",
"default": [1.0, 1.0, 1.0, 1.0]
"default": [
1,
1,
1,
1
],
"description": "Color used for the waveform trace."
}
]
}

View File

@@ -9,10 +9,11 @@
"id": "patchCount",
"label": "Patch Count",
"type": "float",
"default": 15.0,
"min": 2.0,
"max": 21.0,
"step": 1.0
"default": 15,
"min": 2,
"max": 21,
"step": 1,
"description": "Number of exposure patches in the chart."
},
{
"id": "baseLevel",
@@ -21,25 +22,28 @@
"default": 0.00006103515625,
"min": 0.000001,
"max": 0.01,
"step": 0.000001
"step": 0.000001,
"description": "Brightness of the darkest patch before tone mapping."
},
{
"id": "peakLevel",
"label": "Peak Level",
"type": "float",
"default": 1.0,
"default": 1,
"min": 0.01,
"max": 1.0,
"step": 0.001
"max": 1,
"step": 0.001,
"description": "Brightness limit for the brightest patch."
},
{
"id": "gammaEncode",
"label": "Display Gamma",
"type": "float",
"default": 1.0,
"min": 1.0,
"default": 1,
"min": 1,
"max": 2.6,
"step": 0.01
"step": 0.01,
"description": "Gamma value used when Tone Curve is Display Gamma."
},
{
"id": "toneCurve",
@@ -59,7 +63,8 @@
"value": "rec709",
"label": "Rec.709"
}
]
],
"description": "Output encoding used for the chart patches."
},
{
"id": "chartScale",
@@ -67,56 +72,63 @@
"type": "float",
"default": 0.86,
"min": 0.25,
"max": 1.0,
"step": 0.01
"max": 1,
"step": 0.01,
"description": "Overall size of the chart in frame."
},
{
"id": "gapSize",
"label": "Gap Size",
"type": "float",
"default": 0.18,
"min": 0.0,
"min": 0,
"max": 0.45,
"step": 0.01
"step": 0.01,
"description": "Spacing between exposure patches."
},
{
"id": "vertical",
"label": "Vertical",
"type": "bool",
"default": false
"default": false,
"description": "Stacks patches vertically instead of horizontally."
},
{
"id": "reverseOrder",
"label": "Reverse Order",
"type": "bool",
"default": false
"default": false,
"description": "Reverses the dark-to-bright patch order."
},
{
"id": "backgroundLevel",
"label": "Background",
"type": "float",
"default": 0.0,
"min": 0.0,
"default": 0,
"min": 0,
"max": 0.2,
"step": 0.001
"step": 0.001,
"description": "Background brightness behind the chart."
},
{
"id": "borderLevel",
"label": "Border",
"type": "float",
"default": 0.08,
"min": 0.0,
"max": 1.0,
"step": 0.001
"min": 0,
"max": 1,
"step": 0.001,
"description": "Brightness of patch borders."
},
{
"id": "sourceMix",
"label": "Source Mix",
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"description": "Blends the generated effect with the incoming video."
}
]
}

View File

@@ -59,7 +59,7 @@ void TestValidManifest()
"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": "gain", "label": "Gain", "description": "Scales the output intensity.", "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" },
@@ -78,8 +78,37 @@ void TestValidManifest()
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() == 4, "parameters parse");
Expect(package.parameters[0].description == "Scales the output intensity.", "parameter descriptions parse");
Expect(package.parameters[1].type == ShaderParameterType::Text && package.parameters[1].defaultTextValue == "LIVE", "text parameter parses");
Expect(package.parameters[3].type == ShaderParameterType::Trigger, "trigger parameter parses");
Expect(package.passes.size() == 1 && package.passes[0].id == "main", "legacy manifests get an implicit main pass");
std::filesystem::remove_all(root);
}
void TestExplicitPassManifest()
{
const std::filesystem::path root = MakeTestRoot();
WriteShaderPackage(root, "multi", R"({
"id": "multi-pass",
"name": "Multi Pass",
"passes": [
{ "id": "blurX", "source": "blur-x.slang", "entryPoint": "blurHorizontal", "inputs": ["layerInput"], "output": "blurredX" },
{ "id": "final", "source": "final.slang", "entryPoint": "finish", "inputs": ["blurredX"], "output": "layerOutput" }
],
"parameters": []
})");
WriteFile(root / "multi" / "blur-x.slang", "float4 blurHorizontal(float2 uv) { return float4(uv, 0.0, 1.0); }\n");
WriteFile(root / "multi" / "final.slang", "float4 finish(float2 uv) { return float4(uv, 1.0, 1.0); }\n");
ShaderPackageRegistry registry(4);
ShaderPackage package;
std::string error;
Expect(registry.ParseManifest(root / "multi" / "shader.json", package, error), "explicit pass manifest parses");
Expect(package.passes.size() == 2, "explicit passes parse");
Expect(package.passes[0].id == "blurX" && package.passes[0].entryPoint == "blurHorizontal", "first pass metadata parses");
Expect(package.passes[0].inputNames.size() == 1 && package.passes[0].inputNames[0] == "layerInput", "pass inputs parse");
Expect(package.passes[1].outputName == "layerOutput", "pass output parses");
std::filesystem::remove_all(root);
}
@@ -231,6 +260,7 @@ void TestInvalidPackageDoesNotFailScan()
int main()
{
TestValidManifest();
TestExplicitPassManifest();
TestMissingFontAsset();
TestInvalidManifest();
TestInvalidTemporalSettings();

View File

@@ -81,15 +81,19 @@ int main()
if (packageIt == packagesById.end())
continue;
std::string fragmentShaderSource;
std::string compileError;
if (!compiler.BuildLayerFragmentShaderSource(packageIt->second, fragmentShaderSource, compileError))
const ShaderPackage& shaderPackage = packageIt->second;
for (const ShaderPassDefinition& pass : shaderPackage.passes)
{
Fail("Shader package '" + packageId + "' failed Slang validation: " + compileError);
continue;
std::string fragmentShaderSource;
std::string compileError;
if (!compiler.BuildPassFragmentShaderSource(shaderPackage, pass, fragmentShaderSource, compileError))
{
Fail("Shader package '" + packageId + "' pass '" + pass.id + "' failed Slang validation: " + compileError);
continue;
}
if (fragmentShaderSource.find("#version 430 core") == std::string::npos)
Fail("Shader package '" + packageId + "' pass '" + pass.id + "' generated GLSL without the expected patched GLSL version header.");
}
if (fragmentShaderSource.find("#version 430 core") == std::string::npos)
Fail("Shader package '" + packageId + "' generated GLSL without the expected patched GLSL version header.");
}
std::error_code removeError;

View File

@@ -31,7 +31,7 @@ public:
return true;
}
bool SelectPreferredFormats(const VideoFormatSelection&, std::string&) override
bool SelectPreferredFormats(const VideoFormatSelection&, bool, std::string&) override
{
mState.inputPixelFormat = VideoIOPixelFormat::Uyvy8;
mState.outputPixelFormat = VideoIOPixelFormat::Bgra8;
@@ -120,7 +120,7 @@ int main()
bool outputSeen = false;
Expect(device.DiscoverDevicesAndModes(selection, error), "fake discovery succeeds");
Expect(device.SelectPreferredFormats(selection, error), "fake format selection succeeds");
Expect(device.SelectPreferredFormats(selection, false, error), "fake format selection succeeds");
Expect(device.ConfigureInput([&](const VideoIOFrame& frame) {
inputSeen = frame.bytes != nullptr && frame.width == 1920 && frame.pixelFormat == VideoIOPixelFormat::Uyvy8;
}, selection.input, error), "fake input config succeeds");

View File

@@ -22,6 +22,7 @@ void TestPreferredFormatSelection()
Expect(ChoosePreferredVideoIOFormat(true) == VideoIOPixelFormat::V210, "10-bit is preferred when supported");
Expect(ChoosePreferredVideoIOFormat(false) == VideoIOPixelFormat::Uyvy8, "8-bit is used as fallback");
Expect(DeckLinkPixelFormatForVideoIO(VideoIOPixelFormat::V210) == bmdFormat10BitYUV, "v210 maps to DeckLink 10-bit YUV");
Expect(DeckLinkPixelFormatForVideoIO(VideoIOPixelFormat::Yuva10) == bmdFormat10BitYUVA, "Ay10 maps to DeckLink 10-bit YUVA");
Expect(DeckLinkPixelFormatForVideoIO(VideoIOPixelFormat::Uyvy8) == bmdFormat8BitYUV, "UYVY maps to DeckLink 8-bit YUV");
Expect(DeckLinkPixelFormatForVideoIO(VideoIOPixelFormat::Bgra8) == bmdFormat8BitBGRA, "BGRA maps to DeckLink 8-bit BGRA");
}
@@ -31,11 +32,15 @@ void TestRowByteHelpers()
Expect(MinimumV210RowBytes(1920) == 5120, "1920-wide v210 active row bytes");
Expect(MinimumV210RowBytes(1280) == 3424, "1280-wide v210 active row bytes rounds up to six-pixel group");
Expect(MinimumV210RowBytes(3840) == 10240, "3840-wide v210 active row bytes");
Expect(MinimumYuva10RowBytes(1920) == 7680, "1920-wide Ay10 row bytes");
Expect(MinimumYuva10RowBytes(1919) == 7680, "Ay10 row bytes round up to 64-pixel boundary");
Expect(MinimumYuva10RowBytes(3840) == 15360, "3840-wide Ay10 row bytes");
Expect(PackedTextureWidthFromRowBytes(5120) == 1280, "packed texture width is row bytes divided into RGBA byte texels");
Expect(ActiveV210WordsForWidth(1920) == 1280, "active v210 words match 1920 width");
Expect(VideoIORowBytes(VideoIOPixelFormat::Uyvy8, 1920) == 3840, "UYVY row bytes");
Expect(VideoIORowBytes(VideoIOPixelFormat::Bgra8, 1920) == 7680, "BGRA row bytes");
Expect(VideoIORowBytes(VideoIOPixelFormat::V210, 1920) == 5120, "v210 row bytes");
Expect(VideoIORowBytes(VideoIOPixelFormat::Yuva10, 1920) == 7680, "Ay10 row bytes");
}
void TestV210PackUnpack()

View File

@@ -1,9 +1,9 @@
import Wheel from "@uiw/react-color-wheel";
import { hsvaToRgba, rgbaToHsva } from "@uiw/color-convert";
import { Copy, RotateCcw, Zap } from "lucide-react";
import { RotateCcw, Zap } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
import { ParameterValueDisplay } from "./ParameterValueDisplay";
function valuesMatch(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
@@ -21,7 +21,10 @@ function ParameterHeader({ layer, parameter, onReset, resetDisabled }) {
return (
<div className="parameter__header">
<label>{parameter.label}</label>
<div className="parameter__title">
<label>{parameter.label}</label>
{parameter.description ? <p title={parameter.description}>{parameter.description}</p> : null}
</div>
<button
type="button"
className="parameter__osc"
@@ -29,8 +32,7 @@ function ParameterHeader({ layer, parameter, onReset, resetDisabled }) {
aria-label={`Copy OSC route ${oscRoute}`}
onClick={copyRoute}
>
<span>{oscRoute}</span>
<Copy size={13} strokeWidth={1.75} aria-hidden="true" />
<span aria-hidden="true">OSC</span>
</button>
<button
type="button"
@@ -88,12 +90,12 @@ function hsvaToColorValue(hsva, alpha) {
}
export function ParameterField({ layer, parameter, onParameterChange }) {
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorControlRef = useRef(null);
const {
appliedValue,
beginInteraction,
draftValue,
endInteraction,
isPending,
scheduleSendValue,
sendValue,
} = useThrottledParameterValue(parameter, onParameterChange);
@@ -105,6 +107,32 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
sendValue(defaultValue);
}
};
useEffect(() => {
if (!colorPickerOpen) {
return undefined;
}
function handlePointerDown(event) {
if (!colorControlRef.current || !colorControlRef.current.contains(event.target)) {
setColorPickerOpen(false);
}
}
function handleKeyDown(event) {
if (event.key === "Escape") {
setColorPickerOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [colorPickerOpen]);
const header = (
<ParameterHeader
layer={layer}
@@ -118,36 +146,37 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
return (
<section className="parameter">
{header}
<div className="parameter__pair">
<input
type="range"
min={parameter.min?.[0] ?? 0}
max={parameter.max?.[0] ?? 1}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onMouseDown={beginInteraction}
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => scheduleSendValue(Number(event.target.value))}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onBlur={endInteraction}
/>
<input
type="number"
min={parameter.min?.[0] ?? ""}
max={parameter.max?.[0] ?? ""}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(Number(event.target.value))}
onBlur={endInteraction}
/>
<div className="parameter__control">
<div className="parameter__pair">
<input
type="range"
min={parameter.min?.[0] ?? 0}
max={parameter.max?.[0] ?? 1}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onMouseDown={beginInteraction}
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => scheduleSendValue(Number(event.target.value))}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onBlur={endInteraction}
/>
<input
type="number"
min={parameter.min?.[0] ?? ""}
max={parameter.max?.[0] ?? ""}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(Number(event.target.value))}
onBlur={endInteraction}
/>
</div>
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
@@ -161,26 +190,28 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
return (
<section className="parameter">
{header}
<div className="parameter__pair">
{Array.from({ length: 2 }, (_, index) => (
<input
key={index}
type="number"
min={parameter.min?.[index] ?? ""}
max={parameter.max?.[index] ?? ""}
step={parameter.step?.[index] ?? 0.01}
value={values[index]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[index] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
))}
<div className="parameter__control">
<div className="parameter__pair parameter__pair--vec2">
{Array.from({ length: 2 }, (_, index) => (
<input
key={index}
type="number"
aria-label={`${parameter.label} ${index === 0 ? "X" : "Y"}`}
min={parameter.min?.[index] ?? ""}
max={parameter.max?.[index] ?? ""}
step={parameter.step?.[index] ?? 0.01}
value={values[index]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[index] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
))}
</div>
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
@@ -193,69 +224,84 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
const hsva = colorValueToHsva(values);
const wheelHsva = { ...hsva, v: 100 };
const sendHsva = (nextHsva) => scheduleSendValue(hsvaToColorValue(nextHsva, values[3]));
const updateColorComponent = (index, nextValue) => {
const next = [...values];
next[index] = Number(nextValue);
sendValue(next);
};
return (
<section className="parameter">
<section className="parameter parameter--color">
{header}
<div className="parameter__wheel-row">
<div className="parameter__color-stack">
<div
className="parameter__wheel"
onPointerDown={beginInteraction}
onPointerUp={endInteraction}
onPointerCancel={endInteraction}
onBlur={endInteraction}
<div className="parameter__control" ref={colorControlRef}>
<div className="parameter__color-compact">
<button
type="button"
className="parameter__swatch-button"
title={`Open ${parameter.label} color picker`}
aria-label={`Open ${parameter.label} color picker`}
onClick={() => setColorPickerOpen((open) => !open)}
>
<Wheel
color={wheelHsva}
width={196}
height={196}
onChange={(color) => sendHsva({ ...color.hsva, v: hsva.v })}
/>
<span className="parameter__swatch" style={{ background: colorValueToHex(values) }} aria-hidden="true" />
</button>
<div className="parameter__rgba-grid">
{["R", "G", "B", "A"].map((label, index) => (
<label key={label} className="parameter__rgba-field">
<span>{label}</span>
<input
type="number"
min={parameter.min?.[index] ?? 0}
max={parameter.max?.[index] ?? 1}
step={parameter.step?.[index] ?? 0.01}
value={values[index]}
onFocus={beginInteraction}
onChange={(event) => updateColorComponent(index, event.target.value)}
onBlur={endInteraction}
/>
</label>
))}
</div>
<label className="parameter__value-slider">
<input
type="range"
min={0}
max={100}
step={1}
value={Math.round(hsva.v)}
aria-label={`${parameter.label} value`}
onMouseDown={beginInteraction}
</div>
{colorPickerOpen ? (
<div className="parameter__color-popover">
<div
className="parameter__wheel"
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => sendHsva({ ...hsva, v: Number(event.target.value) })}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onPointerCancel={endInteraction}
onBlur={endInteraction}
/>
</label>
</div>
<div className="parameter__color-bottom">
<label className="parameter__alpha">
<span>Alpha</span>
<input
type="number"
min={parameter.min?.[3] ?? 0}
max={parameter.max?.[3] ?? 1}
step={parameter.step?.[3] ?? 0.01}
value={values[3]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[3] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
</label>
<div className="parameter__swatch" style={{ background: colorValueToHex(values) }} aria-hidden="true" />
</div>
>
<Wheel
color={wheelHsva}
width={196}
height={196}
onChange={(color) => sendHsva({ ...color.hsva, v: hsva.v })}
/>
</div>
<label className="parameter__value-slider">
<span>Value</span>
<input
type="range"
min={0}
max={100}
step={1}
value={Math.round(hsva.v)}
aria-label={`${parameter.label} value`}
onMouseDown={beginInteraction}
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => sendHsva({ ...hsva, v: Number(event.target.value) })}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onBlur={endInteraction}
/>
</label>
</div>
) : null}
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
@@ -264,17 +310,18 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
return (
<section className="parameter">
{header}
<label className="toggle toggle--field">
<input
type="checkbox"
checked={Boolean(draftValue)}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.checked)}
onBlur={endInteraction}
/>
<span>{draftValue ? "Enabled" : "Disabled"}</span>
</label>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
<div className="parameter__control">
<label className="toggle toggle--field">
<input
type="checkbox"
checked={Boolean(draftValue)}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.checked)}
onBlur={endInteraction}
/>
<span>{draftValue ? "Enabled" : "Disabled"}</span>
</label>
</div>
</section>
);
}
@@ -283,19 +330,20 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
return (
<section className="parameter">
{header}
<select
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.value)}
onBlur={endInteraction}
>
{parameter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
<div className="parameter__control">
<select
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.value)}
onBlur={endInteraction}
>
{parameter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</section>
);
}
@@ -304,16 +352,17 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
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} />
<div className="parameter__control">
<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}
/>
</div>
</section>
);
}
@@ -323,15 +372,16 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
return (
<section className="parameter">
{header}
<button
type="button"
className="button-with-icon parameter__trigger"
onClick={() => sendValue(triggerCount + 1)}
>
<Zap size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Trigger</span>
</button>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
<div className="parameter__control">
<button
type="button"
className="button-with-icon parameter__trigger"
onClick={() => sendValue(triggerCount + 1)}
>
<Zap size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Trigger</span>
</button>
</div>
</section>
);
}

View File

@@ -1,28 +0,0 @@
function formatNumber(value, digits = 3) {
return Number(value ?? 0).toFixed(digits);
}
export function formatParameterValue(parameterType, value) {
if (parameterType === "float") {
return formatNumber(value);
}
if (parameterType === "vec2" || parameterType === "color") {
return (value ?? []).map((item) => formatNumber(item)).join(", ");
}
if (parameterType === "bool") {
return value ? "Enabled" : "Disabled";
}
if (parameterType === "trigger") {
return `Triggered ${Number(value ?? 0)} time${Number(value ?? 0) === 1 ? "" : "s"}`;
}
return `${value ?? ""}`;
}
export function ParameterValueDisplay({ parameterType, value, pending }) {
const valueText = formatParameterValue(parameterType, value);
return (
<div className={`parameter__value${pending ? " parameter__value--pending" : ""}`}>
{pending ? `Applied: ${valueText}` : valueText}
</div>
);
}

View File

@@ -43,7 +43,7 @@ export function StackPresetToolbar({
onClick={() => postJson("/api/reload", {})}
>
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Reload shader</span>
<span>Reload shaders</span>
</button>
</div>
</div>

View File

@@ -700,8 +700,6 @@ pre {
.shader-picker__selected,
.shader-picker__meta,
.shader-picker__empty,
.parameter__value,
.parameter__alpha,
.parameter__osc,
.parameter__reset {
color: var(--app-muted);
@@ -876,31 +874,59 @@ pre {
}
.parameter-grid {
grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr));
gap: 0.625rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 32rem), 1fr));
gap: 0.5rem;
align-items: start;
}
.parameter {
padding: 0.75rem;
position: relative;
grid-template-columns: minmax(16rem, 0.9fr) minmax(18rem, 1.35fr);
gap: 0.625rem;
align-items: center;
padding: 0.55rem 0.65rem;
border: 1px solid var(--app-border);
background: #141a23;
}
.parameter__header {
display: grid;
grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr) auto;
gap: 0.5rem;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.35rem;
align-items: center;
min-width: 0;
}
.parameter__title {
min-width: 0;
}
.parameter__title label {
display: block;
overflow: hidden;
font-size: 0.9rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.parameter__title p {
margin: 0.12rem 0 0;
overflow: hidden;
color: var(--app-muted);
font-size: 0.76rem;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.parameter__osc,
.parameter__reset {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.375rem;
width: auto;
min-width: 0;
justify-content: center;
width: 24px;
min-width: 24px;
min-height: 24px;
padding: 0;
border: 0;
@@ -908,18 +934,16 @@ pre {
font-weight: 500;
}
.parameter__reset {
justify-content: center;
width: 24px;
min-width: 24px;
color: var(--app-muted);
.parameter__osc {
width: 34px;
min-width: 34px;
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.02em;
}
.parameter__osc span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.parameter__reset {
color: var(--app-muted);
}
.parameter__osc svg,
@@ -927,33 +951,89 @@ pre {
flex: 0 0 auto;
}
.parameter__value--pending {
color: var(--app-warning);
.parameter__control {
position: relative;
min-width: 0;
}
.parameter__pair {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(5.125rem, 1fr));
grid-template-columns: minmax(8rem, 1fr) minmax(5.25rem, 7rem);
gap: 0.5rem;
align-items: center;
}
.parameter__pair--vec2 {
grid-template-columns: repeat(2, minmax(5.25rem, 1fr));
}
.parameter__pair input[type="range"] {
min-width: 7.5rem;
}
.parameter__wheel-row {
.parameter__color-compact {
display: grid;
grid-template-columns: minmax(0, 196px);
gap: 0.625rem;
align-items: start;
justify-content: center;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.5rem;
align-items: end;
}
.parameter__color-stack {
.parameter__swatch-button {
width: 42px;
min-width: 42px;
height: 42px;
min-height: 42px;
padding: 0.25rem;
border-color: var(--app-border);
background: var(--app-surface-2);
}
.parameter__swatch-button:hover:not(:disabled) {
background: var(--app-surface-2);
border-color: rgba(26, 156, 219, 0.55);
}
.parameter__swatch {
display: block;
width: 100%;
min-height: 100%;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: var(--app-radius-sm);
}
.parameter__rgba-grid {
display: grid;
grid-template-columns: repeat(4, minmax(3.5rem, 1fr));
gap: 0.35rem;
}
.parameter__rgba-field {
display: grid;
gap: 0.15rem;
color: var(--app-muted);
font-size: 0.68rem;
font-weight: 700;
}
.parameter__rgba-field input {
min-height: 32px;
padding: 0.35rem 0.45rem;
font-size: 0.82rem;
}
.parameter__color-popover {
position: absolute;
z-index: 20;
top: calc(100% + 0.45rem);
left: 0;
display: grid;
gap: 0.625rem;
width: 196px;
width: 224px;
padding: 0.75rem;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: #10151d;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.parameter__wheel {
@@ -973,24 +1053,13 @@ pre {
display: block;
}
.parameter__color-bottom {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(4.5rem, 0.85fr);
gap: 0.625rem;
align-items: end;
width: 196px;
}
.parameter__swatch {
width: 100%;
min-height: 38px;
border: 1px solid var(--app-border);
border-radius: var(--app-radius-sm);
}
.parameter__value-slider {
display: block;
display: grid;
gap: 0.25rem;
width: 196px;
color: var(--app-muted);
font-size: 0.72rem;
font-weight: 700;
}
.parameter__value-slider input[type="range"] {
@@ -1041,18 +1110,8 @@ pre {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.65), 0 1px 4px rgba(0, 0, 0, 0.45);
}
.parameter__value-slider strong {
text-align: right;
min-height: 1rem;
color: var(--app-text);
font-size: 0.74rem;
line-height: 1;
}
.parameter__alpha {
display: grid;
gap: 0.25rem;
font-weight: 600;
.parameter__trigger {
min-width: 7rem;
}
.toggle {
@@ -1141,8 +1200,8 @@ pre {
.summary-grid,
.kv-rows,
.parameter-grid,
.parameter__header,
.parameter__wheel-row {
.parameter,
.parameter__header {
grid-template-columns: 1fr;
}
@@ -1154,12 +1213,20 @@ pre {
width: 24px;
}
.parameter__swatch {
grid-column: auto;
.parameter__pair,
.parameter__color-compact {
grid-template-columns: 1fr;
}
.parameter__color-stack,
.parameter__color-bottom,
.parameter__rgba-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.parameter__swatch-button {
width: 100%;
}
.parameter__color-popover,
.parameter__value-slider,
.parameter__value-slider input[type="range"] {
width: 100%;