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