From 8a6bb81a3797af406568f47cb5ff5f3f355d0900 Mon Sep 17 00:00:00 2001 From: Aiden Date: Wed, 3 Jun 2026 00:45:31 -0400 Subject: [PATCH] Font loading fixes --- shaders/vhs/shader.json | 5 + shaders/vhs/ui/7segment.ttf | Bin 0 -> 4268 bytes shaders/vhs/ui/controls.js | 698 +++++++++++++++++++ src/control/http/HttpControlServerRoutes.cpp | 8 + ui/src/components/LayerCard.jsx | 36 +- ui/src/components/ShaderCustomPanel.jsx | 23 +- ui/src/styles.css | 7 + 7 files changed, 753 insertions(+), 24 deletions(-) create mode 100644 shaders/vhs/ui/7segment.ttf create mode 100644 shaders/vhs/ui/controls.js 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 0000000000000000000000000000000000000000..8edad3c0c725a12dd7119d846c5b7bba651a7f1f GIT binary patch literal 4268 zcma)9TWl0n82-= z5EE|%6Juhc!ixzpJ`5BSjWKFsOb9%XsL?1y6d!!xfq3PjuHS#=3_IK1g0uPNKj+MU z{`22YOM-}|(S9<>=~+L2VdaG;DkOe_$6 zvEsmfrax~UKA1n3KO-&}Jgp$fL_JmX96Th;pxDq?zShYTd+NeX1r$Xk49@kNnUZZZx&@frBdmBB4Z`)`WY}rbK^dR+7Kkc9) zaEwrbVzgeZWbq!RQLGH$JxV!RO!M&@#oiI@8wUSYx|@12&tg3Rc`V7_x1Ltf8l2ce zD?vBl+Xy%Y!OeQKVrLv!KnpRxhwg>;32LWB_{M9C)vADTPQD8cKM$YgiQsF@0*~kX z6WBxfbK*RWiS6PES_>;u&^U&146_t|F&a~=F}%zDn1gqD{6P+u=700J)~XYjn}1{a zJXTA<>s6pr@WUg|^BYeGU}-D|jcP0gZPq}2AS*oF0(z%L0(7262sEZq33|5%XIUIr z1n~JRmjMsbQjIpyPK|a@)`KwVZ`n)u7$LT7Yx0M}zs- z0S^Lmfb|+Rpbu$G2YncL6kvZmroor1xlPC=4{XbJURbO7#t5* zV+m5A)&n{eS+Jj>#_+qvzUOu5%1~p(#^U(!I()%Ukz?z zQnk2$HAN!g%T1pyvaVh6YTIz=(;|M}KNC^}dL2J}z-}cqsrqT0B5KC9#u`TSoOj)d zPO=)=FiJ1bVLDDH=_~q${uWckt)gA56pq*>4vNEKTzoFR6&J-na=M%&<1!^T$|1Q| z9+Yp&arv42T3$5Bm}W$cMMk%=(HJuJ8ZQ}#jdzU?jFZMU#!tp?rfJrgO=gSPW-d3^ zn2(qPCTEyLUL2+Oa61{$i#kPX#7MM87KEMfYT&5jh;@zwFYzsbyos|F5pdYyu5es^ zp)q+xyn*E~g~v@15-q}RZd)Q6#CCV(Z2NSbJknh+NubxgA}+|w;ES9P6``rf##v8A6HTJ`| zsAe%pjyhkMhG*5OqT!w7oLbqiQhXe5yc%snBN3Dn>kuConH3%v zAyc%}sl#*0V;#w3Vt+gyC)@<6RdvHgRSrb?Vn7e!YL4O*e#7^O1?nT8SGKdCJU+>M zlgbG0M&*Eyy}`HnWM@gPFz}_|;le4tHqZci_#7lo1tbOa4f3&dlku@#QMT*4)G0XFG#Y>#Ie+_ z^6@SgX}c(Po=%0RmG@0iCGy`BMK$7i@Bhgt_90hgF#evTLY+;fO>yqtEYpkqj;d5q zyK((+j&lbqj;^Pf>RW*fnJB3c<8qpLF)588XGBo5QV!L_CY&p|Q>v`R?m)$PI+e;$ zrzpD$96^0g1-=zD^*K~`44lGU%I_1!F+p7`NkQN9?x2OmJK*mL)l{4-s-NPf^3U^f z1@d9N%A@Pi^0l43=NG&-S#|j`_ZIN zb+1ak2enOn*L(IBG)C7^t3mrGkBd43vK^X=Sm`H!8NJTVpLOvs$%n?jo#(5U#auDZ zr50EG6JHQ>C6AY3gg<;mdeCx(ow6*K(sstRdI#(b#&*&*(pxOoz$dpd?YNaZsKTJi zPC1U{nmiY2jA#X3Av>jJ$0{n)FFL7X)zwTK$g^CTwqA7Hu${`d5gY9V3g`bG+L`~9 JEMMm!{SU`9E(`zw literal 0 HcmV?d00001 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);