diff --git a/shaders/vhs/shader.json b/shaders/vhs/shader.json
index 978a0e4..d56696a 100644
--- a/shaders/vhs/shader.json
+++ b/shaders/vhs/shader.json
@@ -24,6 +24,11 @@
"output": "layerOutput"
}
],
+ "ui": {
+ "type": "webComponent",
+ "entry": "ui/controls.js",
+ "tag": "vhs-controls"
+ },
"parameters": [
{
"id": "wiggle",
diff --git a/shaders/vhs/ui/7segment.ttf b/shaders/vhs/ui/7segment.ttf
new file mode 100644
index 0000000..8edad3c
Binary files /dev/null and b/shaders/vhs/ui/7segment.ttf differ
diff --git a/shaders/vhs/ui/controls.js b/shaders/vhs/ui/controls.js
new file mode 100644
index 0000000..1c07cb9
--- /dev/null
+++ b/shaders/vhs/ui/controls.js
@@ -0,0 +1,698 @@
+const PARAMETER_GROUPS = [
+ {
+ title: "Tape Transport",
+ note: "Deck instability and horizontal smear.",
+ parameters: ["wiggle", "wiggleSpeed", "smear", "blurSamples", "sharpnessDrift"],
+ },
+ {
+ title: "Picture Wear",
+ note: "Copied-tape softness, glow, fade, and edge falloff.",
+ parameters: ["generationLoss", "fadeAmount", "vignetteAmount", "halationAmount", "bloomAmount", "aberrationAmount"],
+ },
+ {
+ title: "Signal Noise",
+ note: "Static, grain, scanlines, and chroma shimmer.",
+ parameters: ["noiseAmount", "noiseSize", "staticAmount", "staticLines", "scanlineAmount", "chromaCrawlAmount"],
+ },
+];
+
+const PRESETS = [
+ {
+ label: "Clean SP",
+ values: {
+ wiggle: 0.02,
+ wiggleSpeed: 18,
+ smear: 0.45,
+ blurSamples: 9,
+ generationLoss: 0.06,
+ fadeAmount: 0.08,
+ noiseAmount: 0.025,
+ staticAmount: 0.01,
+ staticLines: 0.18,
+ scanlineAmount: 0.04,
+ chromaCrawlAmount: 0.015,
+ sharpnessDrift: 0.04,
+ },
+ },
+ {
+ label: "Rental Copy",
+ values: {
+ wiggle: 0.18,
+ wiggleSpeed: 31,
+ smear: 1.2,
+ blurSamples: 15,
+ generationLoss: 0.36,
+ fadeAmount: 0.33,
+ noiseAmount: 0.075,
+ staticAmount: 0.06,
+ staticLines: 0.85,
+ scanlineAmount: 0.12,
+ chromaCrawlAmount: 0.065,
+ sharpnessDrift: 0.22,
+ },
+ },
+ {
+ label: "Tracking Lost",
+ values: {
+ wiggle: 0.72,
+ wiggleSpeed: 64,
+ smear: 1.75,
+ blurSamples: 15,
+ generationLoss: 0.58,
+ fadeAmount: 0.48,
+ noiseAmount: 0.12,
+ staticAmount: 0.16,
+ staticLines: 1.2,
+ scanlineAmount: 0.2,
+ chromaCrawlAmount: 0.14,
+ sharpnessDrift: 0.45,
+ },
+ },
+];
+
+const SEGMENT_FONT_FAMILY = "VhsSevenSegment";
+const SEGMENT_FONT_URL = new URL("./7segment.ttf", import.meta.url).href;
+let segmentFontLoadPromise = null;
+
+function ensureSegmentFontLoaded() {
+ if (segmentFontLoadPromise || !("FontFace" in window) || !document.fonts) {
+ return segmentFontLoadPromise;
+ }
+
+ const font = new FontFace(SEGMENT_FONT_FAMILY, `url("${SEGMENT_FONT_URL}")`);
+ segmentFontLoadPromise = font.load()
+ .then((loadedFont) => {
+ document.fonts.add(loadedFont);
+ return loadedFont;
+ })
+ .catch(() => null);
+ return segmentFontLoadPromise;
+}
+
+function numberValue(parameter) {
+ const value = Number(parameter?.value ?? parameter?.defaultValue ?? 0);
+ return Number.isFinite(value) ? value : 0;
+}
+
+function formatValue(value, step) {
+ if (step >= 1) {
+ return String(Math.round(value));
+ }
+ if (step >= 0.05) {
+ return value.toFixed(2);
+ }
+ return value.toFixed(3);
+}
+
+class VhsControls extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this._parameters = [];
+ this._parameterMap = new Map();
+ this._values = {};
+ this._setParameter = null;
+ }
+
+ set layer(value) {
+ this._layer = value;
+ }
+
+ set parameters(value) {
+ this._parameters = Array.isArray(value) ? value : [];
+ this._parameterMap = new Map(this._parameters.map((parameter) => [parameter.id, parameter]));
+ this.render();
+ }
+
+ set values(value) {
+ this._values = value && typeof value === "object" ? value : {};
+ this.render();
+ }
+
+ set setParameter(callback) {
+ this._setParameter = typeof callback === "function" ? callback : null;
+ }
+
+ set requestReset(callback) {
+ this._requestReset = typeof callback === "function" ? callback : null;
+ }
+
+ connectedCallback() {
+ ensureSegmentFontLoaded();
+ this.render();
+ }
+
+ updateParameter(parameterId, value) {
+ if (this._setParameter) {
+ this._setParameter(parameterId, value);
+ return;
+ }
+ this.dispatchEvent(new CustomEvent("shader-parameter-change", {
+ detail: { parameterId, value },
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ applyPreset(preset) {
+ for (const [parameterId, value] of Object.entries(preset.values)) {
+ if (this._parameterMap.has(parameterId)) {
+ this.updateParameter(parameterId, value);
+ }
+ }
+ }
+
+ renderSlider(parameter) {
+ const value = numberValue(parameter);
+ const min = Number(parameter.min?.[0] ?? 0);
+ const max = Number(parameter.max?.[0] ?? 1);
+ const step = Number(parameter.step?.[0] ?? 0.01);
+ const safeMin = Number.isFinite(min) ? min : 0;
+ const safeMax = Number.isFinite(max) && max > safeMin ? max : safeMin + 1;
+ const safeStep = Number.isFinite(step) && step > 0 ? step : 0.01;
+ const percent = Math.max(0, Math.min(100, ((value - safeMin) / (safeMax - safeMin)) * 100));
+ const angle = -135 + percent * 2.7;
+
+ return `
+
+ `;
+ }
+
+ render() {
+ if (!this.shadowRoot) {
+ return;
+ }
+
+ const groups = PARAMETER_GROUPS.map((group) => {
+ const controls = group.parameters
+ .map((parameterId) => this._parameterMap.get(parameterId))
+ .filter(Boolean)
+ .map((parameter) => this.renderSlider(parameter))
+ .join("");
+ if (!controls) {
+ return "";
+ }
+ return `
+ ${group.note}${group.title}
+
Remote Control Panel
+This shader does not expose any user parameters.
); + useEffect(() => { + setUseDefaultControls(false); + }, [customUi?.assetUrl, customUi?.tag]); + return (Custom controls unavailable; default controls shown.
: null} {children}