adjustments to control and stack saving
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 2m28s
CI / Windows Release Package (push) Successful in 2m44s

This commit is contained in:
Aiden
2026-05-10 22:10:54 +10:00
parent 46129a6044
commit c8a4bd4c7b
10 changed files with 539 additions and 221 deletions

View File

@@ -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;

View File

@@ -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<bool> mUseCommittedLayerStates;
std::atomic<bool> mScreenshotRequested;
std::chrono::steady_clock::time_point mLastPreviewPresentTime;
bool mPreserveFeedbackOnNextShaderBuild = false;
bool InitOpenGLState();
void renderEffect();

View File

@@ -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;
}

View File

@@ -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 frames 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 1s 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 frames 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 frames feedback
- shader writes current frames 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.

View File

@@ -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."
}
]
}

View File

@@ -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;
}

View File

@@ -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."
}
]
}

View File

@@ -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);
}

View File

@@ -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({
/>
<button
type="button"
className="button-with-icon"
disabled={!presetName.trim()}
onClick={() => {
const trimmedName = presetName.trim();
if (!trimmedName) {
className={`button-with-icon${willOverwrite ? " stack-panel__save--overwrite" : ""}`}
disabled={!trimmedPresetName}
onClick={async () => {
if (!trimmedPresetName) {
return;
}
postJson("/api/stack-presets/save", { presetName: trimmedName });
onSelectedPresetNameChange(
trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
);
const response = await postJson("/api/stack-presets/save", { presetName: trimmedPresetName });
if (response?.ok) {
onSelectedPresetNameChange(normalizedPresetName);
onPresetNameChange("");
}
}}
>
<Save size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Save</span>
<span>{willOverwrite ? "Overwrite" : "Save"}</span>
</button>
</div>
{trimmedPresetName ? (
<p className="muted toolbar__status" role="status">
{willOverwrite
? `This will overwrite the existing preset "${normalizedPresetName}".`
: `This will save as "${normalizedPresetName}".`}
</p>
) : (
<p className="muted toolbar__status toolbar__status--placeholder" aria-hidden="true">
Preset status
</p>
)}
</div>
<div className="toolbar__group">
@@ -109,6 +126,9 @@ export function StackPresetToolbar({
<span>Recall</span>
</button>
</div>
<p className="muted toolbar__status toolbar__status--placeholder" aria-hidden="true">
Preset status
</p>
</div>
</div>
</div>

View File

@@ -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 {