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