This commit is contained in:
2026-05-02 16:40:21 +10:00
parent 8d01ea4a3c
commit 1a4c33b9dc
23 changed files with 3725 additions and 401 deletions

212
ui/app.js Normal file
View 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
View 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
View 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;
}
}