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.title}

+

${group.note}

+
+
${controls}
+
+ `; + }).join(""); + + this.shadowRoot.innerHTML = ` + +
+
+
+

TX-VHS

+ Video Tape Remote +

Remote Control Panel

+
+ +
+
+
TRACK 03.7
+ +
+
+ ${PRESETS.map((preset, index) => ``).join("")} +
+
+ ${groups} +
+
+ `; + + this.shadowRoot.querySelectorAll("input[data-parameter]").forEach((input) => { + input.addEventListener("input", (event) => { + const target = event.currentTarget; + const parameterId = target.dataset.parameter; + const value = Number(target.value); + const percent = Math.max(0, Math.min(100, ((value - Number(target.min)) / (Number(target.max) - Number(target.min))) * 100)); + const control = target.closest(".control"); + if (control) { + control.style.setProperty("--angle", `${-135 + percent * 2.7}deg`); + control.style.setProperty("--arc", `${percent * 2.7}deg`); + const output = control.querySelector("output"); + if (output) { + output.textContent = formatValue(value, Number(target.step)); + } + } + this.updateParameter(parameterId, value); + }); + }); + + this.shadowRoot.querySelectorAll("button[data-preset]").forEach((button) => { + button.addEventListener("click", () => { + const preset = PRESETS[Number(button.dataset.preset)]; + if (preset) { + this.applyPreset(preset); + } + }); + }); + } +} + +customElements.define("vhs-controls", VhsControls); diff --git a/src/control/http/HttpControlServerRoutes.cpp b/src/control/http/HttpControlServerRoutes.cpp index 1f910fc..f30bb70 100644 --- a/src/control/http/HttpControlServerRoutes.cpp +++ b/src/control/http/HttpControlServerRoutes.cpp @@ -102,6 +102,14 @@ std::string HttpControlServer::GuessContentType(const std::filesystem::path& pat return "image/x-icon"; if (extension == ".map") return "application/json"; + if (extension == ".ttf") + return "font/ttf"; + if (extension == ".otf") + return "font/otf"; + if (extension == ".woff") + return "font/woff"; + if (extension == ".woff2") + return "font/woff2"; return "text/plain"; } diff --git a/ui/src/components/LayerCard.jsx b/ui/src/components/LayerCard.jsx index a106a10..051da18 100644 --- a/ui/src/components/LayerCard.jsx +++ b/ui/src/components/LayerCard.jsx @@ -1,4 +1,5 @@ import { EyeOff, GripVertical, RotateCcw, SlidersHorizontal, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; import { postJson } from "../api/controlApi"; import { ParameterField } from "./ParameterField"; @@ -22,6 +23,7 @@ export function LayerCard({ const selectedShader = shaders.find((shader) => shader.id === layer.shaderId); const customUi = layer.ui?.type === "webComponent" ? layer.ui : selectedShader?.ui; const hasCustomUi = customUi?.type === "webComponent" && customUi.assetUrl && customUi.tag; + const [useDefaultControls, setUseDefaultControls] = useState(false); const updateParameter = (parameterId, value) => onLayerParameterChange(layer.id, parameterId, value); const resetParameters = () => postJson("/api/layers/reset-parameters", { layerId: layer.id }); const parameterControls = layer.parameters.length > 0 ? ( @@ -39,6 +41,10 @@ export function LayerCard({

This shader does not expose any user parameters.

); + useEffect(() => { + setUseDefaultControls(false); + }, [customUi?.assetUrl, customUi?.tag]); + return (

Parameters

- +
+ {hasCustomUi ? ( + + ) : null} + +
{hasCustomUi ? ( diff --git a/ui/src/components/ShaderCustomPanel.jsx b/ui/src/components/ShaderCustomPanel.jsx index 20d3394..463c388 100644 --- a/ui/src/components/ShaderCustomPanel.jsx +++ b/ui/src/components/ShaderCustomPanel.jsx @@ -23,8 +23,14 @@ function parameterMap(parameters) { return Object.fromEntries((parameters ?? []).map((parameter) => [parameter.id, parameter.value])); } -export function ShaderCustomPanel({ layer, ui, onParameterChange, onResetParameters, children }) { - const [useDefaultControls, setUseDefaultControls] = useState(false); +export function ShaderCustomPanel({ + layer, + ui, + useDefaultControls, + onParameterChange, + onResetParameters, + children, +}) { const [loadError, setLoadError] = useState(""); const [element, setElement] = useState(null); const values = useMemo(() => parameterMap(layer.parameters), [layer.parameters]); @@ -39,7 +45,6 @@ export function ShaderCustomPanel({ layer, ui, onParameterChange, onResetParamet let customElement = null; setLoadError(""); setElement(null); - setUseDefaultControls(false); loadCustomElement(ui) .then(() => { @@ -113,13 +118,6 @@ export function ShaderCustomPanel({ layer, ui, onParameterChange, onResetParamet if (useDefaultControls || loadError) { return (
- {!loadError ? ( -
- -
- ) : null} {loadError ?

Custom controls unavailable; default controls shown.

: null} {children}
@@ -128,11 +126,6 @@ export function ShaderCustomPanel({ layer, ui, onParameterChange, onResetParamet return (
-
- -
{ diff --git a/ui/src/styles.css b/ui/src/styles.css index 74563c5..a073466 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -788,6 +788,7 @@ pre { .layer-card__header, .layer-card__meta, .layer-card__actions, +.layer-card__subheader-actions, .layer-card__subheader { display: flex; align-items: center; @@ -810,7 +811,13 @@ pre { align-self: flex-start; } +.layer-card__subheader-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + .layer-card__actions button, +.layer-card__subheader-actions button, .layer-card__subheader button { width: auto; min-width: var(--button-min-width);