From 2a0eea936dc44e11f570a6e4d04b4717da01fd3c Mon Sep 17 00:00:00 2001 From: Aiden Date: Sat, 2 May 2026 18:40:13 +1000 Subject: [PATCH] Additons --- shaders/vhs/shader.json | 9 +++ shaders/vhs/shader.slang | 34 ++++++---- ui/src/App.jsx | 130 +++++++++++++++++++++++++++++++++++---- 3 files changed, 149 insertions(+), 24 deletions(-) diff --git a/shaders/vhs/shader.json b/shaders/vhs/shader.json index 4049603..cdfc3d4 100644 --- a/shaders/vhs/shader.json +++ b/shaders/vhs/shader.json @@ -94,6 +94,15 @@ "min": 0.0, "max": 0.2, "step": 0.005 + }, + { + "id": "noiseSize", + "label": "Noise Size", + "type": "float", + "default": 1.0, + "min": 0.25, + "max": 6.0, + "step": 0.05 } ] } diff --git a/shaders/vhs/shader.slang b/shaders/vhs/shader.slang index 785f807..1e8a523 100644 --- a/shaders/vhs/shader.slang +++ b/shaders/vhs/shader.slang @@ -44,13 +44,23 @@ float noiseHash(float2 p) return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123); } -float3 chromaSpeckle(float2 uv, float framecount) +float grainScalar(float2 uv) { - float2 coarseUv = floor(uv); - float r = noiseHash(coarseUv + float2(framecount * 19.0, framecount * 11.0)); - float g = noiseHash(coarseUv + float2(framecount * 23.0 + 17.0, framecount * 7.0 + 31.0)); - float b = noiseHash(coarseUv + float2(framecount * 13.0 + 47.0, framecount * 29.0 + 9.0)); - return float3(r, g, b) - 0.5; + return frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453); +} + +float3 animatedChromaGrain(float2 uv, float time, float2 outputResolution, float grainSize) +{ + float safeGrainSize = max(grainSize, 0.001); + float2 baseUv = uv * outputResolution * float2(0.85, 0.95) / safeGrainSize; + float2 grainUv = floor(baseUv) + 0.5; + float2 drift = float2(time * 19.7, time * 23.3); + + float r = grainScalar(grainUv + drift + float2(13.1, 71.7)); + float g = grainScalar(grainUv * float2(1.03, 0.97) + drift * 1.11 + float2(47.2, 19.4)); + float b = grainScalar(grainUv * float2(0.96, 1.05) + drift * 0.91 + float2(83.6, 53.8)); + + return float3(r, g, b) * 2.0 - 1.0; } float3 softBloom(float2 uv, float2 outputResolution, float radius) @@ -145,14 +155,14 @@ float4 shadeVideo(ShaderContext context) color = lerp(color, bloomSource, bloomAmount * 0.18); color += bloomSource * float3(1.0, 0.96, 0.92) * bloomMask * 0.24; - float2 noiseUv = context.uv * context.outputResolution * float2(0.55, 1.1); - float3 speckle = chromaSpeckle(noiseUv, framecount); + float3 speckle = animatedChromaGrain(context.uv, context.time, context.outputResolution, noiseSize); float luma = dot(color, float3(0.299, 0.587, 0.114)); float noiseMask = lerp(0.65, 1.0, 1.0 - saturate(luma)); - float3 chromaNoise = float3(speckle.x * 1.1, speckle.y * 0.45, speckle.z * 1.2); - color += chromaNoise * noiseAmount * noiseMask; - color.rg = lerp(color.rg, float2(color.r, color.g) + speckle.xy * noiseAmount * 0.18, 0.35); - color.b = lerp(color.b, color.b + speckle.z * noiseAmount * 0.22, 0.45); + float chunkiness = lerp(1.0, 2.4, saturate((noiseSize - 1.0) / 5.0)); + float3 chromaNoise = float3(speckle.x * 1.2, speckle.y * 0.28, speckle.z * 1.35); + color += chromaNoise * noiseAmount * noiseMask * chunkiness; + color.rg = lerp(color.rg, float2(color.r, color.g) + speckle.xy * noiseAmount * 0.2 * chunkiness, 0.35); + color.b = lerp(color.b, color.b + speckle.z * noiseAmount * 0.28 * chunkiness, 0.5); float3 grayscale = float3(luma, luma, luma); color = lerp(color, grayscale, fadeAmount * 0.18); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 6095320..3e8d599 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { GripVertical, Trash2 } from "lucide-react"; function KvList({ values }) { @@ -62,19 +62,105 @@ function valuesMatch(left, right) { function ParameterField({ parameter, onParameterChange }) { const [draftValue, setDraftValue] = useState(parameter.value); + const [appliedValue, setAppliedValue] = useState(parameter.value); + const pendingTimeoutRef = useRef(null); + const latestDraftRef = useRef(parameter.value); + const lastSentAtRef = useRef(0); + const isInteractingRef = useRef(false); + const isDirtyRef = useRef(false); useEffect(() => { setDraftValue(parameter.value); - }, [parameter.value]); + setAppliedValue(parameter.value); + latestDraftRef.current = parameter.value; + lastSentAtRef.current = 0; + isInteractingRef.current = false; + isDirtyRef.current = false; + }, [parameter.id]); + + useEffect(() => { + setAppliedValue(parameter.value); + latestDraftRef.current = draftValue; + if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) { + isDirtyRef.current = false; + } + if (!isInteractingRef.current && !isDirtyRef.current) { + setDraftValue(parameter.value); + } + }, [draftValue, parameter.value]); + + useEffect(() => { + return () => { + if (pendingTimeoutRef.current) { + clearTimeout(pendingTimeoutRef.current); + } + }; + }, []); const sendValue = (value) => { setDraftValue(value); - onParameterChange(parameter.id, value); + latestDraftRef.current = value; + isDirtyRef.current = true; + lastSentAtRef.current = Date.now(); + const request = onParameterChange(parameter.id, value); + if (request && typeof request.then === "function") { + request + .then((response) => { + if (response?.ok) { + setAppliedValue(value); + } + }) + .catch(() => { + // Keep showing the last confirmed value if the update failed. + }); + } + }; + + const scheduleSendValue = (value, immediate = false) => { + setDraftValue(value); + latestDraftRef.current = value; + isDirtyRef.current = true; + + if (pendingTimeoutRef.current) { + clearTimeout(pendingTimeoutRef.current); + pendingTimeoutRef.current = null; + } + + const now = Date.now(); + const throttleMs = 90; + const elapsed = now - lastSentAtRef.current; + + if (immediate || elapsed >= throttleMs) { + sendValue(value); + return; + } + + pendingTimeoutRef.current = setTimeout(() => { + pendingTimeoutRef.current = null; + sendValue(latestDraftRef.current); + }, throttleMs - elapsed); + }; + + const flushScheduledValue = () => { + if (pendingTimeoutRef.current) { + clearTimeout(pendingTimeoutRef.current); + pendingTimeoutRef.current = null; + } + sendValue(latestDraftRef.current); + }; + + const beginInteraction = () => { + isInteractingRef.current = true; + }; + + const endInteraction = () => { + isInteractingRef.current = false; + flushScheduledValue(); }; const label = ; - const isPending = !valuesMatch(draftValue, parameter.value); - const appliedValueText = formatParameterValue(parameter.type, parameter.value); + const isPending = !valuesMatch(draftValue, appliedValue); + const appliedValueText = formatParameterValue(parameter.type, appliedValue); if (parameter.type === "float") { return ( @@ -87,7 +173,16 @@ function ParameterField({ parameter, onParameterChange }) { max={parameter.max?.[0] ?? 1} step={parameter.step?.[0] ?? 0.01} value={draftValue} - onChange={(event) => sendValue(Number(event.target.value))} + 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} /> sendValue(Number(event.target.value))} + onBlur={endInteraction} />
@@ -124,11 +221,13 @@ function ParameterField({ parameter, onParameterChange }) { 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} /> ))}
@@ -147,7 +246,9 @@ function ParameterField({ parameter, onParameterChange }) { sendValue(event.target.checked)} + onBlur={endInteraction} /> {draftValue ? "Enabled" : "Disabled"} @@ -162,7 +263,12 @@ function ParameterField({ parameter, onParameterChange }) { return (
{label} - sendValue(event.target.value)} + onBlur={endInteraction} + > {parameter.options.map((option) => (