From c8a4bd4c7b2a39b0e8ce607ada15e529fc8c6583 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Sun, 10 May 2026 22:10:54 +1000 Subject: [PATCH] adjustments to control and stack saving --- .../gl/OpenGLComposite.cpp | 9 +- .../gl/OpenGLComposite.h | 3 +- .../gl/OpenGLCompositeRuntimeControls.cpp | 8 +- docs/SHADER_FEEDBACK_TARGET_IDEA.md | 201 --------------- shaders/white-balance-correction/shader.json | 49 ++++ shaders/white-balance-correction/shader.slang | 35 +++ shaders/white-match-probe/shader.json | 147 +++++++++++ shaders/white-match-probe/shader.slang | 235 ++++++++++++++++++ ui/src/components/StackPresetToolbar.jsx | 40 ++- ui/src/styles.css | 33 ++- 10 files changed, 539 insertions(+), 221 deletions(-) delete mode 100644 docs/SHADER_FEEDBACK_TARGET_IDEA.md create mode 100644 shaders/white-balance-correction/shader.json create mode 100644 shaders/white-balance-correction/shader.slang create mode 100644 shaders/white-match-probe/shader.json create mode 100644 shaders/white-match-probe/shader.slang diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp index fa00c79..f877a78 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp @@ -376,8 +376,9 @@ bool OpenGLComposite::Stop() return true; } -bool OpenGLComposite::ReloadShader() +bool OpenGLComposite::ReloadShader(bool preserveFeedbackState) { + mPreserveFeedbackOnNextShaderBuild = preserveFeedbackState; if (mRuntimeHost) { mRuntimeHost->SetCompileStatus(true, "Shader rebuild queued."); @@ -744,6 +745,7 @@ bool OpenGLComposite::ProcessRuntimePollResults() { mRuntimeHost->SetCompileStatus(false, compilerErrorMessage); mUseCommittedLayerStates = true; + mPreserveFeedbackOnNextShaderBuild = false; broadcastRuntimeState(); return false; } @@ -751,12 +753,15 @@ bool OpenGLComposite::ProcessRuntimePollResults() mUseCommittedLayerStates = false; mCachedLayerRenderStates = mShaderPrograms->CommittedLayerStates(); mShaderPrograms->ResetTemporalHistoryState(); - mShaderPrograms->ResetShaderFeedbackState(); + if (!mPreserveFeedbackOnNextShaderBuild) + mShaderPrograms->ResetShaderFeedbackState(); + mPreserveFeedbackOnNextShaderBuild = false; broadcastRuntimeState(); return true; } mRuntimeHost->SetCompileStatus(true, "Shader rebuild queued."); + mPreserveFeedbackOnNextShaderBuild = false; RequestShaderBuild(); broadcastRuntimeState(); return true; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h index 5ea3f2b..7f103f5 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h @@ -44,7 +44,7 @@ public: bool InitVideoIO(); bool Start(); bool Stop(); - bool ReloadShader(); + bool ReloadShader(bool preserveFeedbackState = false); std::string GetRuntimeStateJson() const; bool AddLayer(const std::string& shaderId, std::string& error); bool RemoveLayer(const std::string& layerId, std::string& error); @@ -110,6 +110,7 @@ private: std::atomic mUseCommittedLayerStates; std::atomic mScreenshotRequested; std::chrono::steady_clock::time_point mLastPreviewPresentTime; + bool mPreserveFeedbackOnNextShaderBuild = false; bool InitOpenGLState(); void renderEffect(); diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLCompositeRuntimeControls.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLCompositeRuntimeControls.cpp index 910a58c..4adc0ec 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLCompositeRuntimeControls.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLCompositeRuntimeControls.cpp @@ -41,7 +41,7 @@ bool OpenGLComposite::AddLayer(const std::string& shaderId, std::string& error) if (!mRuntimeHost->AddLayer(shaderId, error)) return false; - ReloadShader(); + ReloadShader(true); broadcastRuntimeState(); return true; } @@ -51,7 +51,7 @@ bool OpenGLComposite::RemoveLayer(const std::string& layerId, std::string& error if (!mRuntimeHost->RemoveLayer(layerId, error)) return false; - ReloadShader(); + ReloadShader(true); broadcastRuntimeState(); return true; } @@ -61,7 +61,7 @@ bool OpenGLComposite::MoveLayer(const std::string& layerId, int direction, std:: if (!mRuntimeHost->MoveLayer(layerId, direction, error)) return false; - ReloadShader(); + ReloadShader(true); broadcastRuntimeState(); return true; } @@ -71,7 +71,7 @@ bool OpenGLComposite::MoveLayerToIndex(const std::string& layerId, std::size_t t if (!mRuntimeHost->MoveLayerToIndex(layerId, targetIndex, error)) return false; - ReloadShader(); + ReloadShader(true); broadcastRuntimeState(); return true; } diff --git a/docs/SHADER_FEEDBACK_TARGET_IDEA.md b/docs/SHADER_FEEDBACK_TARGET_IDEA.md deleted file mode 100644 index f128778..0000000 --- a/docs/SHADER_FEEDBACK_TARGET_IDEA.md +++ /dev/null @@ -1,201 +0,0 @@ -# Shader Feedback Target Idea - -This note summarizes a possible feature where a shader can request a persistent render target for storing and reusing its own internal information across frames. - -## Goal - -Allow a shader to keep shader-local state without writing arbitrary values back into host-owned parameters. - -This is useful for cases like: - -- storing sampled color information across frames -- storing luminance or mask values per pixel -- keeping running filtered values -- tracking simple analysis data -- reserving a small texel region as an array-like metadata store - -## Core Idea - -A shader may opt in, via `shader.json`, to receive one persistent `RGBA16F` render target at client/output resolution. - -The intended model is: - -- the shader writes into the feedback target this frame -- the shader reads the previous frame’s feedback target on the next frame - -This makes it a shader-local “previous frame state” surface. - -## Why This Makes Sense - -This is a better fit than letting shaders push arbitrary values back into the host because: - -- state stays inside the render domain -- shaders remain render-focused rather than becoming host-state mutators -- host-owned parameters, UI state, and persistence remain predictable -- timing is easier to reason about -- it fits naturally with multipass and temporal rendering patterns - -## Recommended Behavior - -### 1. Make it opt-in - -Shaders should explicitly request this capability in `shader.json`. - -Reasons: - -- most shaders will not need it -- it avoids unnecessary VRAM/bandwidth cost -- it keeps shader capabilities explicit -- it avoids silently changing the contract for every shader - -The runtime should only allocate/bind the feedback surface for shaders that request it. - -### 2. Start with previous-frame feedback only - -The first version should expose only one frame of history: - -- current frame writes -- next frame reads previous state - -Reasons: - -- simpler mental model -- lower memory cost -- easier to document -- enough for many practical use cases - -If a shader wants longer memory, it can accumulate or encode that over time into the same persistent surface. - -### 3. Keep it shader-local - -The feedback target should be treated as internal shader state, not as host-visible parameter state. - -That means: - -- it does not automatically update exposed parameters -- it does not automatically show up in the UI -- it does not automatically persist to runtime state - -### 4. Keep it separate from normal multipass chaining - -This feedback target should not replace or blur the meaning of the existing multipass system. - -The clean model is: - -- normal multipass outputs are for same-frame chaining -- the feedback target is for previous-frame persistent state - -In other words: - -- pass A can write an output that pass B reads later in the same frame -- the feedback target written during frame `N` is read back during frame `N + 1` - -That means the feedback target should be thought of as a separate cross-frame resource, not as “another pass output.” - -Recommended behavior for multipass shaders that request feedback: - -- all passes in the shader may read the same previous-frame feedback surface -- one designated pass should produce the next feedback surface for the following frame -- feedback writes should not be interpreted as same-frame pass-to-pass communication - -This avoids ambiguity such as: - -- whether pass 2 sees pass 1’s feedback writes from the same frame -- whether multiple passes are racing to write the persistent surface -- whether feedback is supposed to mean same-frame scratch space or next-frame state - -The intended separation is: - -- use named pass outputs and `previousPass` for same-frame chaining -- use the feedback target for persistent previous-frame state - -## What It Could Store - -Because the target would be a full-resolution `RGBA16F` texture, a shader could use it in a few ways. - -### Full-frame per-pixel storage - -Examples: - -- luminance per pixel -- confidence/mask values -- filtered or decayed image information -- rolling per-pixel state used by a temporal effect - -### Small array-like metadata regions - -A shader could reserve a few texels or a small block as a logical data region. - -Example: - -- pixel `(0, 0)` stores value 0 -- pixel `(1, 0)` stores value 1 -- pixel `(2, 0)` stores value 2 - -Because each texel is `RGBA16F`, one texel can hold up to four scalar values. - -This makes it possible to emulate a small array-like structure inside the texture. - -## Important Caveats - -This is not a true random-access structured buffer. It is still a texture-backed GPU surface. - -That means: - -- it is best suited to texel- or pixel-oriented storage -- per-pixel “write to your own location” patterns are natural -- many-to-one reductions or arbitrary scatter writes are harder -- precision is limited to half-float storage - -So the main question is usually not “can the shader store this?” but “can the shader update it cleanly with fragment-style GPU access?” - -## Example Use Case - -For a greenscreen workflow, a shader could: - -- sample a small box region of the input -- compute an average or representative screen color -- store that color in reserved texels of the feedback target -- reuse that stored value next frame as its internal key color - -This would let the shader maintain its own sampled screen color over time without mutating the exposed host-side `screenColor` parameter. - -## Multipass Interaction Summary - -For a multipass shader, the most sensible mental model is: - -- same-frame intermediate images still flow through the existing pass system -- previous-frame persistent state flows through the feedback target - -So if a shader has multiple passes: - -- pass outputs are still used for within-frame work -- the feedback target is read as last frame’s stored state -- the feedback target is written once for use on the next frame - -This keeps the feature understandable and prevents the feedback surface from becoming a confusing second pass graph. - -## Recommended First Version - -The simplest strong first version would be: - -- opt-in via `shader.json` -- one persistent `RGBA16F` target -- full client/output resolution -- shader reads previous frame’s feedback -- shader writes current frame’s feedback -- no deeper history at first -- no automatic host writeback - -## Summary - -This feature would give shaders a safe, GPU-native way to hold internal state across frames. - -The recommended approach is: - -- make it opt-in per shader -- keep it shader-local -- expose only previous-frame feedback initially -- treat it as a persistent render-state surface, not host parameter state - -That keeps the design powerful without crossing the architectural boundary into shader-driven host mutation. diff --git a/shaders/white-balance-correction/shader.json b/shaders/white-balance-correction/shader.json new file mode 100644 index 0000000..5b6ad07 --- /dev/null +++ b/shaders/white-balance-correction/shader.json @@ -0,0 +1,49 @@ +{ + "id": "white-balance-correction", + "name": "White Balance Correction", + "description": "Operator-friendly tint, color balance, and exposure correction intended to pair with the white match probe.", + "category": "Color", + "entryPoint": "shadeVideo", + "parameters": [ + { + "id": "warmCool", + "label": "Warm / Cool", + "type": "float", + "default": 0.0, + "min": -1.0, + "max": 1.0, + "step": 0.001, + "description": "Moves the image cooler at negative values and warmer at positive values." + }, + { + "id": "greenMagenta", + "label": "Green / Magenta", + "type": "float", + "default": 0.0, + "min": -1.0, + "max": 1.0, + "step": 0.001, + "description": "Moves the image toward magenta at negative values and toward green at positive values." + }, + { + "id": "exposure", + "label": "Exposure", + "type": "float", + "default": 0.0, + "min": -4.0, + "max": 4.0, + "step": 0.01, + "description": "Exposure offset in stop units, using a Blender-style 2^exposure brightness scale." + }, + { + "id": "strength", + "label": "Strength", + "type": "float", + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Blends the correction with the original image." + } + ] +} diff --git a/shaders/white-balance-correction/shader.slang b/shaders/white-balance-correction/shader.slang new file mode 100644 index 0000000..6f28eaa --- /dev/null +++ b/shaders/white-balance-correction/shader.slang @@ -0,0 +1,35 @@ +float3 applyWhiteBalanceOnly(float3 color) +{ + float warmAmount = clamp(warmCool, -1.0, 1.0); + float tintAmount = clamp(greenMagenta, -1.0, 1.0); + + // Warm/cool pivots red against blue while keeping green more stable. + float3 warmCoolGain = float3( + exp2(warmAmount * 0.35), + exp2(-abs(warmAmount) * 0.08), + exp2(-warmAmount * 0.35)); + + // Green/magenta pivots green against the average of red and blue. + float3 tintGain = float3( + exp2(-tintAmount * 0.22), + exp2(tintAmount * 0.35), + exp2(-tintAmount * 0.22)); + + return color * warmCoolGain * tintGain; +} + +float3 applyExposureLikeBlender(float3 color) +{ + // Match the compositor-style exposure model: every +1.0 stop doubles the + // image and every -1.0 stop halves it. + return color * exp2(exposure); +} + +float4 shadeVideo(ShaderContext context) +{ + float4 source = context.sourceColor; + float3 balanced = applyWhiteBalanceOnly(source.rgb); + float3 corrected = applyExposureLikeBlender(balanced); + source.rgb = lerp(source.rgb, corrected, saturate(strength)); + return source; +} diff --git a/shaders/white-match-probe/shader.json b/shaders/white-match-probe/shader.json new file mode 100644 index 0000000..11572f9 --- /dev/null +++ b/shaders/white-match-probe/shader.json @@ -0,0 +1,147 @@ +{ + "id": "white-match-probe", + "name": "White Match Probe", + "description": "Samples a movable box, stores a reference color on trigger using shader-local feedback, and compares the current sample against the held reference for camera matching.", + "category": "Utility", + "entryPoint": "storeReferenceState", + "passes": [ + { + "id": "store", + "source": "shader.slang", + "entryPoint": "storeReferenceState", + "output": "referenceState" + }, + { + "id": "display", + "source": "shader.slang", + "entryPoint": "displayReferenceCompare", + "inputs": [ + "referenceState" + ], + "output": "layerOutput" + } + ], + "feedback": { + "enabled": true, + "writePass": "store" + }, + "parameters": [ + { + "id": "referenceSource", + "label": "Reference Source", + "type": "enum", + "default": "captured", + "options": [ + { + "value": "captured", + "label": "Captured Sample" + }, + { + "value": "manual", + "label": "Manual Color" + } + ], + "description": "Choose whether the probe compares against a captured screen sample or a manually selected reference color." + }, + { + "id": "captureReference", + "label": "Capture Reference", + "type": "trigger", + "description": "Stores the current sample box average as the held reference." + }, + { + "id": "sampleCenter", + "label": "Sample Center", + "type": "vec2", + "default": [ + 0.5, + 0.5 + ], + "min": [ + 0.0, + 0.0 + ], + "max": [ + 1.0, + 1.0 + ], + "step": [ + 0.001, + 0.001 + ], + "description": "Center of the sample box in normalized coordinates." + }, + { + "id": "sampleSize", + "label": "Sample Size", + "type": "vec2", + "default": [ + 0.14, + 0.14 + ], + "min": [ + 0.02, + 0.02 + ], + "max": [ + 0.5, + 0.5 + ], + "step": [ + 0.001, + 0.001 + ], + "description": "Width and height of the sample box." + }, + { + "id": "manualReference", + "label": "Manual Reference", + "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 + ], + "description": "Manual reference color used when Reference Source is set to Manual Color." + }, + { + "id": "overlayOpacity", + "label": "Overlay Opacity", + "type": "float", + "default": 0.9, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Strength of the swatch and box overlay." + }, + { + "id": "differenceGain", + "label": "Difference Gain", + "type": "float", + "default": 2.0, + "min": 0.0, + "max": 8.0, + "step": 0.01, + "description": "Scales the displayed reference-vs-current difference." + } + ] +} diff --git a/shaders/white-match-probe/shader.slang b/shaders/white-match-probe/shader.slang new file mode 100644 index 0000000..9aeaa58 --- /dev/null +++ b/shaders/white-match-probe/shader.slang @@ -0,0 +1,235 @@ +static const int kReferenceCellIndex = 0; +static const int kMetadataCellIndex = 1; + +float2 cellCenterPixelForIndex(int index) +{ + return float2(1.0 + float(index) * 3.0, 1.0); +} + +float2 cellCenterUvForIndex(ShaderContext context, int index) +{ + return (cellCenterPixelForIndex(index) + 0.5) / context.outputResolution; +} + +bool pixelIsInsideCell(float2 pixelCoord, int index) +{ + float minX = float(index) * 3.0; + float maxX = minX + 3.0; + return pixelCoord.x >= minX && pixelCoord.x < maxX && pixelCoord.y >= 0.0 && pixelCoord.y < 3.0; +} + +float4 readStoredCell(ShaderContext context, int index) +{ + if (context.feedbackAvailable <= 0) + return float4(0.0, 0.0, 0.0, 0.0); + return sampleFeedback(cellCenterUvForIndex(context, index)); +} + +float3 sampleProbeAverage(ShaderContext context) +{ + float2 clampedSize = clamp(sampleSize, float2(0.001, 0.001), float2(1.0, 1.0)); + float2 halfSize = clampedSize * 0.5; + float2 minUv = clamp(sampleCenter - halfSize, float2(0.0, 0.0), float2(1.0, 1.0)); + float2 maxUv = clamp(sampleCenter + halfSize, float2(0.0, 0.0), float2(1.0, 1.0)); + + float3 total = float3(0.0, 0.0, 0.0); + float weight = 0.0; + for (int y = 0; y < 3; ++y) + { + for (int x = 0; x < 3; ++x) + { + float2 t = float2((float(x) + 0.5) / 3.0, (float(y) + 0.5) / 3.0); + float2 uv = lerp(minUv, maxUv, t); + total += sampleLayerInput(uv).rgb; + weight += 1.0; + } + } + + return total / max(weight, 0.0001); +} + +float4 storeReferenceState(ShaderContext context) +{ + float2 pixelCoord = floor(context.uv * context.outputResolution); + float3 currentSample = sampleProbeAverage(context); + + float previousTriggerCount = context.feedbackAvailable > 0 + ? readStoredCell(context, kMetadataCellIndex).r + : -1.0; + float currentTriggerCount = float(captureReference); + bool captureNow = context.feedbackAvailable <= 0 || currentTriggerCount > previousTriggerCount + 0.5; + + float3 storedReference = context.feedbackAvailable > 0 + ? readStoredCell(context, kReferenceCellIndex).rgb + : currentSample; + if (captureNow) + storedReference = currentSample; + + float4 metadata = float4(currentTriggerCount, captureReferenceTime, 0.0, 1.0); + if (!captureNow && context.feedbackAvailable > 0) + metadata = readStoredCell(context, kMetadataCellIndex); + + if (pixelIsInsideCell(pixelCoord, kReferenceCellIndex)) + return float4(storedReference, 1.0); + if (pixelIsInsideCell(pixelCoord, kMetadataCellIndex)) + return metadata; + + return float4(0.0, 0.0, 0.0, 1.0); +} + +float rectMask(float2 uv, float2 minUv, float2 maxUv) +{ + if (uv.x < minUv.x || uv.x > maxUv.x) + return 0.0; + if (uv.y < minUv.y || uv.y > maxUv.y) + return 0.0; + return 1.0; +} + +float borderMask(float2 uv, float2 minUv, float2 maxUv, float thickness) +{ + float outer = rectMask(uv, minUv, maxUv); + float inner = rectMask(uv, minUv + thickness, maxUv - thickness); + return saturate(outer - inner); +} + +float luminance(float3 color) +{ + return dot(color, float3(0.2126, 0.7152, 0.0722)); +} + +float3 activeReferenceColor(float3 capturedReference) +{ + // Enum parameters are exposed as their zero-based option index. + // 0 = captured sample, 1 = manual color. + return referenceSource == 1 ? manualReference.rgb : capturedReference; +} + +float4 displayReferenceCompare(ShaderContext context) +{ + float3 liveColor = sampleLayerInput(context.uv).rgb; + float3 currentSample = sampleProbeAverage(context); + float3 capturedReference = sampleVideo(cellCenterUvForIndex(context, kReferenceCellIndex)).rgb; + float3 storedReference = activeReferenceColor(capturedReference); + float3 delta = currentSample - storedReference; + float3 absoluteDelta = abs(delta); + float differenceMagnitude = max(absoluteDelta.r, max(absoluteDelta.g, absoluteDelta.b)); + + float3 displayColor = liveColor; + float opacity = saturate(overlayOpacity); + + float2 halfSize = clamp(sampleSize, float2(0.001, 0.001), float2(1.0, 1.0)) * 0.5; + float2 boxMin = clamp(sampleCenter - halfSize, float2(0.0, 0.0), float2(1.0, 1.0)); + float2 boxMax = clamp(sampleCenter + halfSize, float2(0.0, 0.0), float2(1.0, 1.0)); + float pixelThickness = 2.0 / max(min(context.outputResolution.x, context.outputResolution.y), 1.0); + float outerOutline = borderMask(context.uv, boxMin - pixelThickness, boxMax + pixelThickness, pixelThickness); + float innerOutline = borderMask(context.uv, boxMin, boxMax, pixelThickness); + if (outerOutline > 0.5) + displayColor = float3(0.0, 0.0, 0.0); + if (innerOutline > 0.5) + displayColor = lerp(displayColor, float3(1.0, 0.0, 0.0), opacity); + + float2 swatchSize = float2(0.06, 0.07); + float2 panelOrigin = float2(0.03, 0.04); + float2 gap = float2(0.075, 0.0); + float2 refMin = panelOrigin; + float2 curMin = panelOrigin + gap; + float2 diffMin = panelOrigin + gap * 2.0; + float2 refMax = refMin + swatchSize; + float2 curMax = curMin + swatchSize; + float2 diffMax = diffMin + swatchSize; + float swatchBorder = min(swatchSize.x, swatchSize.y) * 0.08; + + float refFill = rectMask(context.uv, refMin, refMax); + float curFill = rectMask(context.uv, curMin, curMax); + float diffFill = rectMask(context.uv, diffMin, diffMax); + float refOutline = borderMask(context.uv, refMin, refMax, swatchBorder); + float curOutline = borderMask(context.uv, curMin, curMax, swatchBorder); + float diffOutline = borderMask(context.uv, diffMin, diffMax, swatchBorder); + + if (refFill > 0.5) + displayColor = storedReference; + if (curFill > 0.5) + displayColor = currentSample; + if (diffFill > 0.5) + { + float3 neutralBase = float3(0.5, 0.5, 0.5); + float3 signedDeltaDisplay = saturate(neutralBase + delta * max(differenceGain, 0.0) * 0.5); + displayColor = signedDeltaDisplay; + } + if (refOutline > 0.5 || curOutline > 0.5 || diffOutline > 0.5) + displayColor = float3(0.0, 0.0, 0.0); + + // Approximate the difference in two operator-friendly axes: + // warm/cool leans red versus blue, and green/magenta leans green versus + // the average of red and blue. Centered bars make "match" obvious. + float warmCool = clamp((delta.r - delta.b) * max(differenceGain, 0.0), -1.0, 1.0); + float greenMagenta = clamp((delta.g - (delta.r + delta.b) * 0.5) * max(differenceGain, 0.0), -1.0, 1.0); + float brightnessDelta = clamp((luminance(currentSample) - luminance(storedReference)) * max(differenceGain, 0.0) * 1.5, -1.0, 1.0); + + float barWidth = 0.18; + float barHeight = 0.018; + float halfBarWidth = barWidth * 0.5; + float2 warmCoolMin = float2(0.03, 0.13); + float2 warmCoolMax = warmCoolMin + float2(barWidth, barHeight); + float2 tintMin = float2(0.03, 0.157); + float2 tintMax = tintMin + float2(barWidth, barHeight); + float2 brightnessMin = float2(0.03, 0.184); + float2 brightnessMax = brightnessMin + float2(barWidth, barHeight); + float centerX = warmCoolMin.x + halfBarWidth; + + float warmCoolFill = rectMask(context.uv, warmCoolMin, warmCoolMax); + float tintFill = rectMask(context.uv, tintMin, tintMax); + float brightnessFill = rectMask(context.uv, brightnessMin, brightnessMax); + float warmCoolOutline = borderMask(context.uv, warmCoolMin, warmCoolMax, barHeight * 0.12); + float tintOutline = borderMask(context.uv, tintMin, tintMax, barHeight * 0.12); + float brightnessOutline = borderMask(context.uv, brightnessMin, brightnessMax, barHeight * 0.12); + float targetHalfWidth = 0.0015; + float warmCoolCenter = rectMask(context.uv, float2(centerX - targetHalfWidth, warmCoolMin.y), float2(centerX + targetHalfWidth, warmCoolMax.y)); + float tintCenter = rectMask(context.uv, float2(centerX - targetHalfWidth, tintMin.y), float2(centerX + targetHalfWidth, tintMax.y)); + float brightnessCenter = rectMask(context.uv, float2(centerX - targetHalfWidth, brightnessMin.y), float2(centerX + targetHalfWidth, brightnessMax.y)); + + float warmCoolPosition = centerX + warmCool * halfBarWidth; + float tintPosition = centerX + greenMagenta * halfBarWidth; + float brightnessPosition = centerX + brightnessDelta * halfBarWidth; + float indicatorHalfWidth = 0.0018; + float warmCoolIndicator = rectMask(context.uv, float2(warmCoolPosition - indicatorHalfWidth, warmCoolMin.y), float2(warmCoolPosition + indicatorHalfWidth, warmCoolMax.y)); + float tintIndicator = rectMask(context.uv, float2(tintPosition - indicatorHalfWidth, tintMin.y), float2(tintPosition + indicatorHalfWidth, tintMax.y)); + float brightnessIndicator = rectMask(context.uv, float2(brightnessPosition - indicatorHalfWidth, brightnessMin.y), float2(brightnessPosition + indicatorHalfWidth, brightnessMax.y)); + + if (warmCoolFill > 0.5) + { + float gradientT = saturate((context.uv.x - warmCoolMin.x) / max(barWidth, 0.0001)); + float3 coolColor = float3(0.18, 0.52, 1.0); + float3 warmColor = float3(1.0, 0.48, 0.10); + float3 gradientColor = gradientT <= 0.5 + ? lerp(coolColor, float3(1.0, 1.0, 1.0), gradientT * 2.0) + : lerp(float3(1.0, 1.0, 1.0), warmColor, (gradientT - 0.5) * 2.0); + displayColor = gradientColor; + } + if (tintFill > 0.5) + { + float gradientT = saturate((context.uv.x - tintMin.x) / max(barWidth, 0.0001)); + float3 magentaColor = float3(1.0, 0.25, 0.75); + float3 greenColor = float3(0.18, 0.92, 0.32); + float3 gradientColor = gradientT <= 0.5 + ? lerp(magentaColor, float3(1.0, 1.0, 1.0), gradientT * 2.0) + : lerp(float3(1.0, 1.0, 1.0), greenColor, (gradientT - 0.5) * 2.0); + displayColor = gradientColor; + } + if (brightnessFill > 0.5) + { + float gradientT = saturate((context.uv.x - brightnessMin.x) / max(barWidth, 0.0001)); + float3 darkColor = float3(0.18, 0.18, 0.18); + float3 gradientColor = lerp(darkColor, float3(1.0, 1.0, 1.0), gradientT); + displayColor = gradientColor; + } + if (warmCoolCenter > 0.5 || tintCenter > 0.5 || brightnessCenter > 0.5) + displayColor = float3(0.12, 0.45, 1.0); + if (warmCoolIndicator > 0.5 || tintIndicator > 0.5 || brightnessIndicator > 0.5) + displayColor = float3(1.0, 0.0, 0.0); + if (warmCoolOutline > 0.5 || tintOutline > 0.5 || brightnessOutline > 0.5) + displayColor = float3(0.0, 0.0, 0.0); + + return float4(saturate(displayColor), 1.0); +} diff --git a/ui/src/components/StackPresetToolbar.jsx b/ui/src/components/StackPresetToolbar.jsx index 2ad1936..9aa3818 100644 --- a/ui/src/components/StackPresetToolbar.jsx +++ b/ui/src/components/StackPresetToolbar.jsx @@ -11,6 +11,12 @@ export function StackPresetToolbar({ onSelectedPresetNameChange, }) { const [screenshotQueued, setScreenshotQueued] = useState(false); + const trimmedPresetName = presetName.trim(); + const normalizedPresetName = trimmedPresetName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const willOverwrite = normalizedPresetName ? stackPresets.includes(normalizedPresetName) : false; async function requestScreenshot() { setScreenshotQueued(true); @@ -61,23 +67,34 @@ export function StackPresetToolbar({ /> + {trimmedPresetName ? ( +

+ {willOverwrite + ? `This will overwrite the existing preset "${normalizedPresetName}".` + : `This will save as "${normalizedPresetName}".`} +

+ ) : ( + + )}
@@ -109,6 +126,9 @@ export function StackPresetToolbar({ Recall
+ diff --git a/ui/src/styles.css b/ui/src/styles.css index 9057d1d..481518d 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -515,19 +515,46 @@ pre { .toolbar__group { display: grid; + grid-template-rows: auto auto auto; + align-content: start; gap: 0.5rem; min-width: 0; } .toolbar__inline { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) 9.5rem; align-items: stretch; gap: 0.5rem; } +.toolbar__inline input, +.toolbar__inline select, .toolbar__inline button { - width: auto; - min-width: var(--button-min-width); + min-height: 3rem; +} + +.toolbar__inline button { + width: 100%; + min-width: 9.5rem; +} + +.stack-panel__save--overwrite { + background: #b42318; + border-color: #8f1d13; + color: #fff7f5; +} + +.stack-panel__save--overwrite:hover:not(:disabled) { + background: #912018; +} + +.toolbar__status { + min-height: 1.6rem; + margin: 0; +} + +.toolbar__status--placeholder { + visibility: hidden; } .layer-stack {