Intial somewhat working version

This commit is contained in:
2026-05-02 14:20:38 +10:00
commit a76d37c2e8
28 changed files with 2572 additions and 0 deletions

92
web/app.js Normal file
View File

@@ -0,0 +1,92 @@
const state = {
shaders: []
};
const el = (id) => document.getElementById(id);
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "content-type": "application/json" },
...options
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
return response.json();
}
function renderStatus(status) {
el("device").textContent = status.deviceName || "No DeckLink device selected";
el("running").textContent = status.running ? "Running" : "Stopped";
el("mode").textContent = status.mode || "No signal";
el("outputFormat").textContent = status.outputFormat || "Unavailable";
el("frames").textContent = `${status.framesCaptured ?? 0} / ${status.framesOutput ?? 0}`;
el("frameRate").textContent = Number(status.frameRate || 0).toFixed(2);
el("dropped").textContent = status.framesDropped ?? 0;
el("error").textContent = status.error || "";
}
function renderShaders(payload) {
state.shaders = payload.shaders || [];
const host = el("shaders");
host.innerHTML = "";
for (const shader of state.shaders) {
const card = document.createElement("div");
card.className = "shader";
const amount = shader.parameters.find((p) => p.id === "amount");
card.innerHTML = `
<header>
<strong>${shader.name}</strong>
<span>${shader.type}</span>
</header>
<label>
<span>${amount.label}</span>
<input type="range" min="${amount.min}" max="${amount.max}" step="0.01" value="${amount.value}">
<output>${Number(amount.value).toFixed(2)}</output>
</label>
`;
const input = card.querySelector("input");
const output = card.querySelector("output");
input.addEventListener("input", async () => {
output.textContent = Number(input.value).toFixed(2);
await api(`/api/shaders/${shader.id}/parameters`, {
method: "PATCH",
body: JSON.stringify({ amount: Number(input.value) })
});
});
host.appendChild(card);
}
}
async function refresh() {
renderStatus(await api("/api/status"));
renderShaders(await api("/api/shaders"));
}
function connectWs() {
const ws = new WebSocket(`ws://${location.host}/ws`);
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "state") {
renderStatus(message.status);
renderShaders(message.shaders);
}
});
ws.addEventListener("close", () => setTimeout(connectWs, 1000));
}
el("start").addEventListener("click", async () => {
try {
renderStatus(await api("/api/pipeline/start", { method: "POST" }));
} catch (error) {
el("error").textContent = error.message;
}
});
el("stop").addEventListener("click", async () => {
renderStatus(await api("/api/pipeline/stop", { method: "POST" }));
});
refresh();
connectWs();

60
web/index.html Normal file
View File

@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>video-shader</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<section class="topbar">
<div>
<h1>video-shader</h1>
<p id="device">DeckLink device</p>
</div>
<div class="actions">
<button id="start">Start</button>
<button id="stop">Stop</button>
</div>
</section>
<section class="status-grid">
<article>
<span>State</span>
<strong id="running">Stopped</strong>
</article>
<article>
<span>Mode</span>
<strong id="mode">No signal</strong>
</article>
<article>
<span>Output</span>
<strong id="outputFormat">Unavailable</strong>
</article>
<article>
<span>Frames</span>
<strong id="frames">0 / 0</strong>
</article>
</section>
<section class="workbench">
<div class="shader-list">
<h2>Shader Stack</h2>
<div id="shaders"></div>
</div>
<div class="meter">
<h2>Runtime</h2>
<dl>
<dt>Frame rate</dt>
<dd id="frameRate">0.00</dd>
<dt>Dropped</dt>
<dd id="dropped">0</dd>
</dl>
<p id="error"></p>
</div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

175
web/styles.css Normal file
View File

@@ -0,0 +1,175 @@
:root {
color-scheme: dark;
font-family: "Segoe UI", Arial, sans-serif;
background: #141717;
color: #eef3ef;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.shell {
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 0 22px;
border-bottom: 1px solid #33403b;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 28px;
font-weight: 650;
}
h2 {
font-size: 16px;
font-weight: 650;
margin-bottom: 14px;
}
.topbar p,
article span,
dt {
color: #aab8b0;
}
.actions {
display: flex;
gap: 10px;
}
button {
appearance: none;
border: 1px solid #60736a;
border-radius: 6px;
background: #24302b;
color: #f3fff7;
min-width: 84px;
height: 38px;
font: inherit;
cursor: pointer;
}
button:hover {
background: #314139;
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 22px 0;
}
article,
.shader-list,
.meter {
border: 1px solid #33403b;
border-radius: 8px;
background: #1b211f;
}
article {
padding: 16px;
}
article span {
display: block;
font-size: 12px;
margin-bottom: 8px;
}
article strong {
display: block;
min-height: 26px;
font-size: 20px;
font-weight: 650;
overflow-wrap: anywhere;
}
.workbench {
display: grid;
grid-template-columns: 1fr 340px;
gap: 12px;
}
.shader-list,
.meter {
padding: 18px;
}
.shader {
display: grid;
gap: 14px;
padding: 14px;
border: 1px solid #3c4b45;
border-radius: 6px;
background: #202925;
}
.shader header {
display: flex;
justify-content: space-between;
gap: 12px;
}
label {
display: grid;
grid-template-columns: 120px 1fr 52px;
align-items: center;
gap: 12px;
color: #cdd8d1;
}
input[type="range"] {
width: 100%;
}
dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px 12px;
margin: 0;
}
dd {
margin: 0;
font-weight: 650;
}
#error {
min-height: 24px;
margin-top: 18px;
color: #ffb4a8;
overflow-wrap: anywhere;
}
@media (max-width: 820px) {
.topbar,
.workbench {
grid-template-columns: 1fr;
display: grid;
}
.status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}