Working
This commit is contained in:
212
ui/app.js
Normal file
212
ui/app.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const shaderSelect = document.getElementById("shader-select");
|
||||
const mixSlider = document.getElementById("mix-slider");
|
||||
const bypassToggle = document.getElementById("bypass-toggle");
|
||||
const reloadButton = document.getElementById("reload-button");
|
||||
const runtimeStatus = document.getElementById("runtime-status");
|
||||
const videoStatus = document.getElementById("video-status");
|
||||
const compileStatus = document.getElementById("compile-status");
|
||||
const parameterForm = document.getElementById("parameter-form");
|
||||
|
||||
let appState = null;
|
||||
let websocket = null;
|
||||
|
||||
function createKv(target, values) {
|
||||
target.innerHTML = "";
|
||||
values.forEach(([key, value]) => {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = key;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value;
|
||||
target.append(dt, dd);
|
||||
});
|
||||
}
|
||||
|
||||
function postJson(path, payload) {
|
||||
return fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function renderParameters(shader) {
|
||||
parameterForm.innerHTML = "";
|
||||
if (!shader) {
|
||||
return;
|
||||
}
|
||||
|
||||
shader.parameters.forEach((parameter) => {
|
||||
const section = document.createElement("section");
|
||||
section.className = "parameter";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = parameter.label;
|
||||
section.appendChild(label);
|
||||
|
||||
const valueLabel = document.createElement("div");
|
||||
valueLabel.className = "parameter__value";
|
||||
|
||||
const sendValue = (value) => {
|
||||
postJson("/api/update-parameter", {
|
||||
shaderId: shader.id,
|
||||
parameterId: parameter.id,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
if (parameter.type === "float") {
|
||||
const range = document.createElement("input");
|
||||
range.type = "range";
|
||||
range.min = parameter.min?.[0] ?? 0;
|
||||
range.max = parameter.max?.[0] ?? 1;
|
||||
range.step = parameter.step?.[0] ?? 0.01;
|
||||
range.value = parameter.value;
|
||||
const number = document.createElement("input");
|
||||
number.type = "number";
|
||||
number.min = range.min;
|
||||
number.max = range.max;
|
||||
number.step = range.step;
|
||||
number.value = parameter.value;
|
||||
const pair = document.createElement("div");
|
||||
pair.className = "parameter__pair";
|
||||
pair.append(range, number);
|
||||
section.append(pair, valueLabel);
|
||||
const update = (value) => {
|
||||
valueLabel.textContent = Number(value).toFixed(3);
|
||||
range.value = value;
|
||||
number.value = value;
|
||||
};
|
||||
update(parameter.value);
|
||||
range.addEventListener("input", () => update(range.value));
|
||||
range.addEventListener("change", () => sendValue(Number(range.value)));
|
||||
number.addEventListener("change", () => {
|
||||
update(number.value);
|
||||
sendValue(Number(number.value));
|
||||
});
|
||||
} else if (parameter.type === "vec2" || parameter.type === "color") {
|
||||
const pair = document.createElement("div");
|
||||
pair.className = "parameter__pair";
|
||||
const values = parameter.value.slice();
|
||||
values.forEach((component, index) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = parameter.step?.[index] ?? 0.01;
|
||||
input.min = parameter.min?.[index] ?? "";
|
||||
input.max = parameter.max?.[index] ?? "";
|
||||
input.value = component;
|
||||
input.addEventListener("change", () => {
|
||||
values[index] = Number(input.value);
|
||||
valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", ");
|
||||
sendValue(values);
|
||||
});
|
||||
pair.appendChild(input);
|
||||
});
|
||||
valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", ");
|
||||
section.append(pair, valueLabel);
|
||||
} else if (parameter.type === "bool") {
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = parameter.value;
|
||||
valueLabel.textContent = parameter.value ? "Enabled" : "Disabled";
|
||||
toggle.addEventListener("change", () => {
|
||||
valueLabel.textContent = toggle.checked ? "Enabled" : "Disabled";
|
||||
sendValue(toggle.checked);
|
||||
});
|
||||
section.append(toggle, valueLabel);
|
||||
} else if (parameter.type === "enum") {
|
||||
const select = document.createElement("select");
|
||||
parameter.options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option.value;
|
||||
item.textContent = option.label;
|
||||
if (option.value === parameter.value) {
|
||||
item.selected = true;
|
||||
}
|
||||
select.appendChild(item);
|
||||
});
|
||||
valueLabel.textContent = parameter.value;
|
||||
select.addEventListener("change", () => {
|
||||
valueLabel.textContent = select.value;
|
||||
sendValue(select.value);
|
||||
});
|
||||
section.append(select, valueLabel);
|
||||
}
|
||||
|
||||
parameterForm.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
appState = state;
|
||||
const shaders = state.shaders || [];
|
||||
const activeShaderId = state.runtime.activeShaderId;
|
||||
const activeShader = shaders.find((shader) => shader.id === activeShaderId) || shaders[0];
|
||||
|
||||
shaderSelect.innerHTML = "";
|
||||
shaders.forEach((shader) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = shader.id;
|
||||
option.textContent = shader.name;
|
||||
if (shader.id === activeShaderId) {
|
||||
option.selected = true;
|
||||
}
|
||||
shaderSelect.appendChild(option);
|
||||
});
|
||||
|
||||
mixSlider.value = state.runtime.mixAmount ?? 1;
|
||||
bypassToggle.checked = Boolean(state.runtime.bypass);
|
||||
compileStatus.textContent = state.runtime.compileMessage || "No compiler output.";
|
||||
|
||||
createKv(runtimeStatus, [
|
||||
["Active Shader", activeShader?.name || "None"],
|
||||
["Auto Reload", state.app.autoReload ? "On" : "Off"],
|
||||
["Control URL", `http://127.0.0.1:${state.app.serverPort}`],
|
||||
["Compile Status", state.runtime.compileSucceeded ? "Ready" : "Error"],
|
||||
]);
|
||||
|
||||
createKv(videoStatus, [
|
||||
["Signal", state.video.hasSignal ? "Present" : "Missing"],
|
||||
["Mode", state.video.modeName || "Unknown"],
|
||||
["Resolution", `${state.video.width || 0} x ${state.video.height || 0}`],
|
||||
]);
|
||||
|
||||
renderParameters(activeShader);
|
||||
}
|
||||
|
||||
async function loadInitialState() {
|
||||
const response = await fetch("/api/state");
|
||||
renderState(await response.json());
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
websocket = new WebSocket(`${protocol}://${location.host}/ws`);
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
renderState(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
console.error("Failed to parse state update", error);
|
||||
}
|
||||
};
|
||||
websocket.onclose = () => {
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
shaderSelect.addEventListener("change", () => {
|
||||
postJson("/api/select-shader", { shaderId: shaderSelect.value });
|
||||
});
|
||||
|
||||
mixSlider.addEventListener("change", () => {
|
||||
postJson("/api/set-mix", { mixAmount: Number(mixSlider.value) });
|
||||
});
|
||||
|
||||
bypassToggle.addEventListener("change", () => {
|
||||
postJson("/api/set-bypass", { bypass: bypassToggle.checked });
|
||||
});
|
||||
|
||||
reloadButton.addEventListener("click", () => {
|
||||
postJson("/api/reload", {});
|
||||
});
|
||||
|
||||
loadInitialState().then(connectWebSocket);
|
||||
52
ui/index.html
Normal file
52
ui/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Video Shader Host</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<section class="toolbar">
|
||||
<div class="toolbar__group">
|
||||
<label for="shader-select">Shader</label>
|
||||
<select id="shader-select"></select>
|
||||
</div>
|
||||
<div class="toolbar__group toolbar__group--wide">
|
||||
<label for="mix-slider">Mix</label>
|
||||
<input id="mix-slider" type="range" min="0" max="1" step="0.01">
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input id="bypass-toggle" type="checkbox">
|
||||
<span>Bypass</span>
|
||||
</label>
|
||||
<button id="reload-button" type="button">Reload Shader</button>
|
||||
</section>
|
||||
|
||||
<section class="status-grid">
|
||||
<div class="panel">
|
||||
<h2>Runtime</h2>
|
||||
<dl id="runtime-status" class="kv"></dl>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>Video</h2>
|
||||
<dl id="video-status" class="kv"></dl>
|
||||
</div>
|
||||
<div class="panel panel--full">
|
||||
<h2>Compiler</h2>
|
||||
<pre id="compile-status"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__header">
|
||||
<h2>Parameters</h2>
|
||||
</div>
|
||||
<form id="parameter-form" class="parameter-grid"></form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
160
ui/styles.css
Normal file
160
ui/styles.css
Normal file
@@ -0,0 +1,160 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
background: #111318;
|
||||
color: #edf1f7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #111318;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.status-grid,
|
||||
.parameter-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: minmax(220px, 2fr) minmax(220px, 3fr) auto auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.toolbar__group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar__group--wide {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #181c24;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: #94a4c2;
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.parameter-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.parameter {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
background: #131720;
|
||||
}
|
||||
|
||||
.parameter__value {
|
||||
color: #94a4c2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.parameter__pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
pre {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select,
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #38445b;
|
||||
background: #0f131a;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #22314a;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #c9d5ea;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user