CI/CD
Some checks failed
CI / Native Windows Build And Tests (push) Failing after 21s
CI / React UI Build (push) Has been cancelled

This commit is contained in:
2026-05-03 11:26:10 +10:00
parent ee929374a8
commit c39a1fd53c
12 changed files with 848 additions and 701 deletions

47
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,47 @@
name: CI
on:
push:
branches:
- main
- master
- develop
pull_request:
workflow_dispatch:
jobs:
native-windows:
name: Native Windows Build And Tests
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure Debug
shell: pwsh
run: cmake --preset vs2022-x64-debug
- name: Build Debug
shell: pwsh
run: cmake --build --preset build-debug
- name: Run Native Tests
shell: pwsh
run: cmake --build --preset build-debug --target RUN_TESTS
ui-ubuntu:
name: React UI Build
runs-on: nubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install UI Dependencies
working-directory: ui
run: npm ci --no-audit --no-fund
- name: Build UI
working-directory: ui
run: npm run build

View File

@@ -1,442 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { GripVertical, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
function KvList({ values }) {
return (
<dl className="kv">
{values.map(([key, value]) => (
<FragmentRow key={key} label={key} value={value} />
))}
</dl>
);
}
function FragmentRow({ label, value }) {
return (
<>
<dt>{label}</dt>
<dd>{value}</dd>
</>
);
}
function postJson(path, payload) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
function moveItem(array, fromIndex, toIndex) {
if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
return array;
}
const copy = [...array];
const [item] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, item);
return copy;
}
function formatNumber(value, digits = 3) {
return Number(value ?? 0).toFixed(digits);
}
function formatParameterValue(parameterType, value) {
if (parameterType === "float") {
return formatNumber(value);
}
if (parameterType === "vec2" || parameterType === "color") {
return (value ?? []).map((item) => formatNumber(item)).join(", ");
}
if (parameterType === "bool") {
return value ? "Enabled" : "Disabled";
}
return `${value ?? ""}`;
}
function valuesMatch(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function ParameterField({ parameter, onParameterChange }) {
const [draftValue, setDraftValue] = useState(parameter.value);
const [appliedValue, setAppliedValue] = useState(parameter.value);
const pendingTimeoutRef = useRef(null);
const latestDraftRef = useRef(parameter.value);
const lastSentAtRef = useRef(0);
const isInteractingRef = useRef(false);
const isDirtyRef = useRef(false);
useEffect(() => {
setDraftValue(parameter.value);
setAppliedValue(parameter.value);
latestDraftRef.current = parameter.value;
lastSentAtRef.current = 0;
isInteractingRef.current = false;
isDirtyRef.current = false;
}, [parameter.id]);
useEffect(() => {
setAppliedValue(parameter.value);
latestDraftRef.current = draftValue;
if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) {
isDirtyRef.current = false;
}
if (!isInteractingRef.current && !isDirtyRef.current) {
setDraftValue(parameter.value);
}
}, [draftValue, parameter.value]);
useEffect(() => {
return () => {
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
}
};
}, []);
const sendValue = (value) => {
setDraftValue(value);
latestDraftRef.current = value;
isDirtyRef.current = true;
lastSentAtRef.current = Date.now();
const request = onParameterChange(parameter.id, value);
if (request && typeof request.then === "function") {
request
.then((response) => {
if (response?.ok) {
setAppliedValue(value);
}
})
.catch(() => {
// Keep showing the last confirmed value if the update failed.
});
}
};
const scheduleSendValue = (value, immediate = false) => {
setDraftValue(value);
latestDraftRef.current = value;
isDirtyRef.current = true;
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
const now = Date.now();
const throttleMs = 90;
const elapsed = now - lastSentAtRef.current;
if (immediate || elapsed >= throttleMs) {
sendValue(value);
return;
}
pendingTimeoutRef.current = setTimeout(() => {
pendingTimeoutRef.current = null;
sendValue(latestDraftRef.current);
}, throttleMs - elapsed);
};
const flushScheduledValue = () => {
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
sendValue(latestDraftRef.current);
};
const beginInteraction = () => {
isInteractingRef.current = true;
};
const endInteraction = () => {
isInteractingRef.current = false;
flushScheduledValue();
};
const label = <label>{parameter.label}</label>;
const isPending = !valuesMatch(draftValue, appliedValue);
const appliedValueText = formatParameterValue(parameter.type, appliedValue);
if (parameter.type === "float") {
return (
<section className="parameter">
{label}
<div className="parameter__pair">
<input
type="range"
min={parameter.min?.[0] ?? 0}
max={parameter.max?.[0] ?? 1}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onMouseDown={beginInteraction}
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => scheduleSendValue(Number(event.target.value))}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onBlur={endInteraction}
/>
<input
type="number"
min={parameter.min?.[0] ?? ""}
max={parameter.max?.[0] ?? ""}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(Number(event.target.value))}
onBlur={endInteraction}
/>
</div>
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
</div>
</section>
);
}
if (parameter.type === "vec2" || parameter.type === "color") {
const componentCount = parameter.type === "color" ? 4 : 2;
const values = [...(draftValue ?? [])];
while (values.length < componentCount) {
values.push(0);
}
return (
<section className="parameter">
{label}
<div className="parameter__pair">
{Array.from({ length: componentCount }, (_, index) => (
<input
key={index}
type="number"
min={parameter.min?.[index] ?? ""}
max={parameter.max?.[index] ?? ""}
step={parameter.step?.[index] ?? 0.01}
value={values[index]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[index] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
))}
</div>
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
</div>
</section>
);
}
if (parameter.type === "bool") {
return (
<section className="parameter">
{label}
<label className="toggle toggle--field">
<input
type="checkbox"
checked={Boolean(draftValue)}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.checked)}
onBlur={endInteraction}
/>
<span>{draftValue ? "Enabled" : "Disabled"}</span>
</label>
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
</div>
</section>
);
}
if (parameter.type === "enum") {
return (
<section className="parameter">
{label}
<select
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.value)}
onBlur={endInteraction}
>
{parameter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
</div>
</section>
);
}
return null;
}
function LayerCard({
layer,
index,
shaders,
expanded,
isDragging,
isDropTarget,
onToggleExpanded,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onRemove,
onLayerParameterChange,
}) {
return (
<div
className={`layer-card${expanded ? " layer-card--expanded" : ""}${isDragging ? " layer-card--dragging" : ""}${isDropTarget ? " layer-card--drop-target" : ""}`}
onDragOver={(event) => {
event.preventDefault();
onDragOver(layer.id);
}}
onDrop={(event) => {
event.preventDefault();
onDrop(event, layer.id, index);
}}
>
<div className="layer-card__header">
<div className="layer-card__meta">
<button
type="button"
className="layer-card__drag-handle"
title="Drag to reorder"
aria-label="Drag to reorder"
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", layer.id);
event.stopPropagation();
onDragStart(layer.id);
}}
onDragEnd={(event) => {
event.stopPropagation();
onDragEnd();
}}
>
<GripVertical size={16} strokeWidth={1.75} />
</button>
<span className="layer-card__index">{index + 1}</span>
<button type="button" className="layer-card__title" onClick={() => onToggleExpanded(layer.id)}>
{layer.shaderName}
</button>
</div>
<div className="layer-card__actions">
<label className="toggle toggle--compact">
<input
type="checkbox"
checked={Boolean(layer.bypass)}
onChange={(event) =>
postJson("/api/layers/set-bypass", {
layerId: layer.id,
bypass: event.target.checked,
})
}
/>
<span>Bypass</span>
</label>
<button type="button" onClick={() => onToggleExpanded(layer.id)}>
{expanded ? "Hide" : "Controls"}
</button>
<button
type="button"
className="icon-button"
title="Remove layer"
aria-label="Remove layer"
onClick={() => onRemove(layer.id)}
>
<Trash2 size={16} strokeWidth={1.75} />
</button>
</div>
</div>
{expanded ? (
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor={`shader-${layer.id}`}>Shader</label>
<select
id={`shader-${layer.id}`}
value={layer.shaderId}
onChange={(event) =>
postJson("/api/layers/set-shader", {
layerId: layer.id,
shaderId: event.target.value,
})
}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
</div>
{layer.temporal?.enabled ? (
<div className="layer-card__field">
<label>Temporal</label>
<div className="muted">
{layer.temporal.historySource} history, requested {layer.temporal.requestedHistoryLength} frame{layer.temporal.requestedHistoryLength === 1 ? "" : "s"}, using {layer.temporal.effectiveHistoryLength}
</div>
</div>
) : (
<div className="layer-card__field">
<label>Temporal</label>
<div className="muted">Stateless shader</div>
</div>
)}
<div className="layer-card__subheader">
<h3>Parameters</h3>
<button
type="button"
disabled={layer.parameters.length === 0}
onClick={() => postJson("/api/layers/reset-parameters", { layerId: layer.id })}
>
Reset
</button>
</div>
{layer.parameters.length > 0 ? (
<div className="parameter-grid">
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}`}
parameter={parameter}
onParameterChange={(parameterId, value) => onLayerParameterChange(layer.id, parameterId, value)}
/>
))}
</div>
) : (
<p className="muted">This shader does not expose any user parameters.</p>
)}
</div>
) : null}
</div>
);
}
import { LayerStack } from "./components/LayerStack";
import { StackPresetToolbar } from "./components/StackPresetToolbar";
import { StatusPanels } from "./components/StatusPanels";
import { useRuntimeState } from "./hooks/useRuntimeState";
function App() {
const [appState, setAppState] = useState(null);
const [appState, setAppState] = useRuntimeState();
const [pendingShaderId, setPendingShaderId] = useState("");
const [presetName, setPresetName] = useState("");
const [selectedPresetName, setSelectedPresetName] = useState("");
@@ -444,54 +14,6 @@ function App() {
const [dragLayerId, setDragLayerId] = useState(null);
const [dropTargetLayerId, setDropTargetLayerId] = useState(null);
useEffect(() => {
let mounted = true;
let socket;
let retryTimer;
const connectWebSocket = () => {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
socket.onmessage = (event) => {
try {
const nextState = JSON.parse(event.data);
if (mounted) {
setAppState(nextState);
}
} catch (error) {
console.error("Failed to parse state update", error);
}
};
socket.onclose = () => {
if (mounted) {
retryTimer = window.setTimeout(connectWebSocket, 1000);
}
};
};
fetch("/api/state")
.then((response) => response.json())
.then((state) => {
if (mounted) {
setAppState(state);
connectWebSocket();
}
})
.catch((error) => {
console.error("Failed to load initial state", error);
});
return () => {
mounted = false;
if (retryTimer) {
window.clearTimeout(retryTimer);
}
if (socket) {
socket.close();
}
};
}, []);
const layers = appState?.layers ?? [];
const shaders = appState?.shaders ?? [];
const performance = appState?.performance ?? {};
@@ -521,60 +43,6 @@ function App() {
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
}, [layers]);
const expandedSet = useMemo(() => new Set(expandedLayerIds), [expandedLayerIds]);
function updateLayerParameterOptimistically(layerId, parameterId, value) {
return postJson("/api/layers/update-parameter", {
layerId,
parameterId,
value,
});
}
function toggleExpanded(layerId) {
setExpandedLayerIds((current) =>
current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId],
);
}
function removeLayer(layerId) {
setExpandedLayerIds((current) => current.filter((id) => id !== layerId));
postJson("/api/layers/remove", { layerId });
}
function handleDrop(event, targetLayerId, targetIndex) {
const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId;
if (!sourceLayerId || sourceLayerId === targetLayerId) {
setDragLayerId(null);
setDropTargetLayerId(null);
return;
}
setAppState((current) => {
if (!current?.layers) {
return current;
}
const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId);
const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId);
if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) {
return current;
}
return {
...current,
layers: moveItem(current.layers, sourceIndex, destinationIndex),
};
});
postJson("/api/layers/reorder", {
layerId: sourceLayerId,
targetIndex,
});
setDragLayerId(null);
setDropTargetLayerId(null);
}
if (!appState) {
return (
<main className="layout">
@@ -588,171 +56,29 @@ function App() {
return (
<main className="layout">
<section className="toolbar">
<div className="toolbar__group toolbar__group--wide">
<label htmlFor="preset-name">Save Stack</label>
<div className="toolbar__inline">
<input
id="preset-name"
type="text"
placeholder="Preset name"
value={presetName}
onChange={(event) => setPresetName(event.target.value)}
<StackPresetToolbar
presetName={presetName}
selectedPresetName={selectedPresetName}
stackPresets={stackPresets}
onPresetNameChange={setPresetName}
onSelectedPresetNameChange={setSelectedPresetName}
/>
<button
type="button"
disabled={!presetName.trim()}
onClick={() => {
const trimmedName = presetName.trim();
if (!trimmedName) {
return;
}
postJson("/api/stack-presets/save", { presetName: trimmedName });
setSelectedPresetName(trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
}}
>
Save
</button>
</div>
</div>
<div className="toolbar__group toolbar__group--wide">
<label htmlFor="preset-select">Recall Stack</label>
<div className="toolbar__inline">
<select
id="preset-select"
value={selectedPresetName}
onChange={(event) => setSelectedPresetName(event.target.value)}
>
{stackPresets.length === 0 ? <option value="">No presets</option> : null}
{stackPresets.map((preset) => (
<option key={preset} value={preset}>
{preset}
</option>
))}
</select>
<button
type="button"
disabled={!selectedPresetName}
onClick={() => {
if (selectedPresetName) {
postJson("/api/stack-presets/load", { presetName: selectedPresetName });
}
}}
>
Recall
</button>
</div>
</div>
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} />
<button type="button" onClick={() => postJson("/api/reload", {})}>
Reload Shader
</button>
</section>
<section className="status-grid">
<div className="panel">
<h2>Runtime</h2>
<KvList
values={[
["Layer Count", `${runtime.layerCount || 0}`],
["Auto Reload", app.autoReload ? "On" : "Off"],
["Temporal Cap", `${app.maxTemporalHistoryFrames ?? 0}`],
["Control URL", `http://127.0.0.1:${app.serverPort}`],
["Compile Status", runtime.compileSucceeded ? "Ready" : "Error"],
["Render Time", `${formatNumber(performance.renderMs, 2)} ms`],
["Smoothed Time", `${formatNumber(performance.smoothedRenderMs, 2)} ms`],
["Frame Budget", `${formatNumber(performance.frameBudgetMs, 2)} ms`],
["Budget Used", `${formatNumber(performance.budgetUsedPercent, 1)}%`],
]}
/>
</div>
<div className="panel">
<h2>Video</h2>
<KvList
values={[
["Signal", video.hasSignal ? "Present" : "Missing"],
["Mode", video.modeName || "Unknown"],
["Resolution", `${video.width || 0} x ${video.height || 0}`],
]}
/>
</div>
<div className="panel panel--full">
<h2>Compiler</h2>
<pre>{runtime.compileMessage || "No compiler output."}</pre>
</div>
</section>
<section className="panel">
<div className="panel__header">
<h2>Layers</h2>
<p className="muted">Drag layers to reorder them. Each layer processes the output of the one above it.</p>
</div>
<div className="layer-stack">
{layers.map((layer, index) => (
<LayerCard
key={layer.id}
layer={layer}
index={index}
<LayerStack
dragLayerId={dragLayerId}
dropTargetLayerId={dropTargetLayerId}
expandedLayerIds={expandedLayerIds}
layers={layers}
pendingShaderId={pendingShaderId}
setAppState={setAppState}
setDragLayerId={setDragLayerId}
setDropTargetLayerId={setDropTargetLayerId}
setExpandedLayerIds={setExpandedLayerIds}
setPendingShaderId={setPendingShaderId}
shaders={shaders}
expanded={expandedSet.has(layer.id)}
isDragging={dragLayerId === layer.id}
isDropTarget={dropTargetLayerId === layer.id}
onToggleExpanded={toggleExpanded}
onDragStart={setDragLayerId}
onDragEnd={() => {
setDragLayerId(null);
setDropTargetLayerId(null);
}}
onDragOver={setDropTargetLayerId}
onDrop={handleDrop}
onRemove={removeLayer}
onLayerParameterChange={updateLayerParameterOptimistically}
/>
))}
<div className="layer-card layer-card--add">
<div className="layer-card__header">
<div className="layer-card__meta">
<span className="layer-card__index">+</span>
<div className="layer-card__title layer-card__title--static">Add Layer</div>
</div>
<div className="layer-card__actions">
<button
type="button"
disabled={!pendingShaderId}
onClick={() => {
if (pendingShaderId) {
postJson("/api/layers/add", { shaderId: pendingShaderId });
}
}}
>
Add
</button>
</div>
</div>
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor="add-layer-select">Shader</label>
<select
id="add-layer-select"
value={pendingShaderId}
onChange={(event) => setPendingShaderId(event.target.value)}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
</div>
</div>
</div>
</div>
</section>
</main>
);
}

7
ui/src/api/controlApi.js Normal file
View File

@@ -0,0 +1,7 @@
export function postJson(path, payload) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,18 @@
export function KvList({ values }) {
return (
<dl className="kv">
{values.map(([key, value]) => (
<FragmentRow key={key} label={key} value={value} />
))}
</dl>
);
}
function FragmentRow({ label, value }) {
return (
<>
<dt>{label}</dt>
<dd>{value}</dd>
</>
);
}

View File

@@ -0,0 +1,154 @@
import { GripVertical, Trash2 } from "lucide-react";
import { postJson } from "../api/controlApi";
import { ParameterField } from "./ParameterField";
export function LayerCard({
layer,
index,
shaders,
expanded,
isDragging,
isDropTarget,
onToggleExpanded,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onRemove,
onLayerParameterChange,
}) {
return (
<div
className={`layer-card${expanded ? " layer-card--expanded" : ""}${isDragging ? " layer-card--dragging" : ""}${isDropTarget ? " layer-card--drop-target" : ""}`}
onDragOver={(event) => {
event.preventDefault();
onDragOver(layer.id);
}}
onDrop={(event) => {
event.preventDefault();
onDrop(event, layer.id, index);
}}
>
<div className="layer-card__header">
<div className="layer-card__meta">
<button
type="button"
className="layer-card__drag-handle"
title="Drag to reorder"
aria-label="Drag to reorder"
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", layer.id);
event.stopPropagation();
onDragStart(layer.id);
}}
onDragEnd={(event) => {
event.stopPropagation();
onDragEnd();
}}
>
<GripVertical size={16} strokeWidth={1.75} />
</button>
<span className="layer-card__index">{index + 1}</span>
<button type="button" className="layer-card__title" onClick={() => onToggleExpanded(layer.id)}>
{layer.shaderName}
</button>
</div>
<div className="layer-card__actions">
<label className="toggle toggle--compact">
<input
type="checkbox"
checked={Boolean(layer.bypass)}
onChange={(event) =>
postJson("/api/layers/set-bypass", {
layerId: layer.id,
bypass: event.target.checked,
})
}
/>
<span>Bypass</span>
</label>
<button type="button" onClick={() => onToggleExpanded(layer.id)}>
{expanded ? "Hide" : "Controls"}
</button>
<button
type="button"
className="icon-button"
title="Remove layer"
aria-label="Remove layer"
onClick={() => onRemove(layer.id)}
>
<Trash2 size={16} strokeWidth={1.75} />
</button>
</div>
</div>
{expanded ? (
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor={`shader-${layer.id}`}>Shader</label>
<select
id={`shader-${layer.id}`}
value={layer.shaderId}
onChange={(event) =>
postJson("/api/layers/set-shader", {
layerId: layer.id,
shaderId: event.target.value,
})
}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
</div>
{layer.temporal?.enabled ? (
<div className="layer-card__field">
<label>Temporal</label>
<div className="muted">
{layer.temporal.historySource} history, requested {layer.temporal.requestedHistoryLength} frame{layer.temporal.requestedHistoryLength === 1 ? "" : "s"}, using {layer.temporal.effectiveHistoryLength}
</div>
</div>
) : (
<div className="layer-card__field">
<label>Temporal</label>
<div className="muted">Stateless shader</div>
</div>
)}
<div className="layer-card__subheader">
<h3>Parameters</h3>
<button
type="button"
disabled={layer.parameters.length === 0}
onClick={() => postJson("/api/layers/reset-parameters", { layerId: layer.id })}
>
Reset
</button>
</div>
{layer.parameters.length > 0 ? (
<div className="parameter-grid">
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}`}
parameter={parameter}
onParameterChange={(parameterId, value) => onLayerParameterChange(layer.id, parameterId, value)}
/>
))}
</div>
) : (
<p className="muted">This shader does not expose any user parameters.</p>
)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { postJson } from "../api/controlApi";
import { LayerCard } from "./LayerCard";
function moveItem(array, fromIndex, toIndex) {
if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
return array;
}
const copy = [...array];
const [item] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, item);
return copy;
}
export function LayerStack({
dragLayerId,
dropTargetLayerId,
expandedLayerIds,
layers,
pendingShaderId,
setAppState,
setDragLayerId,
setDropTargetLayerId,
setExpandedLayerIds,
setPendingShaderId,
shaders,
}) {
const expandedSet = new Set(expandedLayerIds);
function updateLayerParameterOptimistically(layerId, parameterId, value) {
return postJson("/api/layers/update-parameter", {
layerId,
parameterId,
value,
});
}
function toggleExpanded(layerId) {
setExpandedLayerIds((current) =>
current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId],
);
}
function removeLayer(layerId) {
setExpandedLayerIds((current) => current.filter((id) => id !== layerId));
postJson("/api/layers/remove", { layerId });
}
function handleDrop(event, targetLayerId, targetIndex) {
const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId;
if (!sourceLayerId || sourceLayerId === targetLayerId) {
setDragLayerId(null);
setDropTargetLayerId(null);
return;
}
setAppState((current) => {
if (!current?.layers) {
return current;
}
const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId);
const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId);
if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) {
return current;
}
return {
...current,
layers: moveItem(current.layers, sourceIndex, destinationIndex),
};
});
postJson("/api/layers/reorder", {
layerId: sourceLayerId,
targetIndex,
});
setDragLayerId(null);
setDropTargetLayerId(null);
}
return (
<section className="panel">
<div className="panel__header">
<h2>Layers</h2>
<p className="muted">Drag layers to reorder them. Each layer processes the output of the one above it.</p>
</div>
<div className="layer-stack">
{layers.map((layer, index) => (
<LayerCard
key={layer.id}
layer={layer}
index={index}
shaders={shaders}
expanded={expandedSet.has(layer.id)}
isDragging={dragLayerId === layer.id}
isDropTarget={dropTargetLayerId === layer.id}
onToggleExpanded={toggleExpanded}
onDragStart={setDragLayerId}
onDragEnd={() => {
setDragLayerId(null);
setDropTargetLayerId(null);
}}
onDragOver={setDropTargetLayerId}
onDrop={handleDrop}
onRemove={removeLayer}
onLayerParameterChange={updateLayerParameterOptimistically}
/>
))}
<div className="layer-card layer-card--add">
<div className="layer-card__header">
<div className="layer-card__meta">
<span className="layer-card__index">+</span>
<div className="layer-card__title layer-card__title--static">Add Layer</div>
</div>
<div className="layer-card__actions">
<button
type="button"
disabled={!pendingShaderId}
onClick={() => {
if (pendingShaderId) {
postJson("/api/layers/add", { shaderId: pendingShaderId });
}
}}
>
Add
</button>
</div>
</div>
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor="add-layer-select">Shader</label>
<select
id="add-layer-select"
value={pendingShaderId}
onChange={(event) => setPendingShaderId(event.target.value)}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,130 @@
import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
import { ParameterValueDisplay } from "./ParameterValueDisplay";
export function ParameterField({ parameter, onParameterChange }) {
const {
appliedValue,
beginInteraction,
draftValue,
endInteraction,
isPending,
scheduleSendValue,
sendValue,
} = useThrottledParameterValue(parameter, onParameterChange);
const label = <label>{parameter.label}</label>;
if (parameter.type === "float") {
return (
<section className="parameter">
{label}
<div className="parameter__pair">
<input
type="range"
min={parameter.min?.[0] ?? 0}
max={parameter.max?.[0] ?? 1}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onMouseDown={beginInteraction}
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => scheduleSendValue(Number(event.target.value))}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onBlur={endInteraction}
/>
<input
type="number"
min={parameter.min?.[0] ?? ""}
max={parameter.max?.[0] ?? ""}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(Number(event.target.value))}
onBlur={endInteraction}
/>
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
if (parameter.type === "vec2" || parameter.type === "color") {
const componentCount = parameter.type === "color" ? 4 : 2;
const values = [...(draftValue ?? [])];
while (values.length < componentCount) {
values.push(0);
}
return (
<section className="parameter">
{label}
<div className="parameter__pair">
{Array.from({ length: componentCount }, (_, index) => (
<input
key={index}
type="number"
min={parameter.min?.[index] ?? ""}
max={parameter.max?.[index] ?? ""}
step={parameter.step?.[index] ?? 0.01}
value={values[index]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[index] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
))}
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
if (parameter.type === "bool") {
return (
<section className="parameter">
{label}
<label className="toggle toggle--field">
<input
type="checkbox"
checked={Boolean(draftValue)}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.checked)}
onBlur={endInteraction}
/>
<span>{draftValue ? "Enabled" : "Disabled"}</span>
</label>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
if (parameter.type === "enum") {
return (
<section className="parameter">
{label}
<select
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.value)}
onBlur={endInteraction}
>
{parameter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
return null;
}

View File

@@ -0,0 +1,25 @@
function formatNumber(value, digits = 3) {
return Number(value ?? 0).toFixed(digits);
}
export function formatParameterValue(parameterType, value) {
if (parameterType === "float") {
return formatNumber(value);
}
if (parameterType === "vec2" || parameterType === "color") {
return (value ?? []).map((item) => formatNumber(item)).join(", ");
}
if (parameterType === "bool") {
return value ? "Enabled" : "Disabled";
}
return `${value ?? ""}`;
}
export function ParameterValueDisplay({ parameterType, value, pending }) {
const valueText = formatParameterValue(parameterType, value);
return (
<div className={`parameter__value${pending ? " parameter__value--pending" : ""}`}>
{pending ? `Applied: ${valueText}` : valueText}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { postJson } from "../api/controlApi";
export function StackPresetToolbar({
presetName,
selectedPresetName,
stackPresets,
onPresetNameChange,
onSelectedPresetNameChange,
}) {
return (
<section className="toolbar">
<div className="toolbar__group toolbar__group--wide">
<label htmlFor="preset-name">Save Stack</label>
<div className="toolbar__inline">
<input
id="preset-name"
type="text"
placeholder="Preset name"
value={presetName}
onChange={(event) => onPresetNameChange(event.target.value)}
/>
<button
type="button"
disabled={!presetName.trim()}
onClick={() => {
const trimmedName = presetName.trim();
if (!trimmedName) {
return;
}
postJson("/api/stack-presets/save", { presetName: trimmedName });
onSelectedPresetNameChange(
trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
);
}}
>
Save
</button>
</div>
</div>
<div className="toolbar__group toolbar__group--wide">
<label htmlFor="preset-select">Recall Stack</label>
<div className="toolbar__inline">
<select
id="preset-select"
value={selectedPresetName}
onChange={(event) => onSelectedPresetNameChange(event.target.value)}
>
{stackPresets.length === 0 ? <option value="">No presets</option> : null}
{stackPresets.map((preset) => (
<option key={preset} value={preset}>
{preset}
</option>
))}
</select>
<button
type="button"
disabled={!selectedPresetName}
onClick={() => {
if (selectedPresetName) {
postJson("/api/stack-presets/load", { presetName: selectedPresetName });
}
}}
>
Recall
</button>
</div>
</div>
<button type="button" onClick={() => postJson("/api/reload", {})}>
Reload Shader
</button>
</section>
);
}

View File

@@ -0,0 +1,44 @@
import { KvList } from "./KvList";
function formatNumber(value, digits = 3) {
return Number(value ?? 0).toFixed(digits);
}
export function StatusPanels({ app, performance, runtime, video }) {
return (
<section className="status-grid">
<div className="panel">
<h2>Runtime</h2>
<KvList
values={[
["Layer Count", `${runtime.layerCount || 0}`],
["Auto Reload", app.autoReload ? "On" : "Off"],
["Temporal Cap", `${app.maxTemporalHistoryFrames ?? 0}`],
["Control URL", `http://127.0.0.1:${app.serverPort}`],
["Compile Status", runtime.compileSucceeded ? "Ready" : "Error"],
["Render Time", `${formatNumber(performance.renderMs, 2)} ms`],
["Smoothed Time", `${formatNumber(performance.smoothedRenderMs, 2)} ms`],
["Frame Budget", `${formatNumber(performance.frameBudgetMs, 2)} ms`],
["Budget Used", `${formatNumber(performance.budgetUsedPercent, 1)}%`],
]}
/>
</div>
<div className="panel">
<h2>Video</h2>
<KvList
values={[
["Signal", video.hasSignal ? "Present" : "Missing"],
["Mode", video.modeName || "Unknown"],
["Resolution", `${video.width || 0} x ${video.height || 0}`],
]}
/>
</div>
<div className="panel panel--full">
<h2>Compiler</h2>
<pre>{runtime.compileMessage || "No compiler output."}</pre>
</div>
</section>
);
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
export function useRuntimeState() {
const [appState, setAppState] = useState(null);
useEffect(() => {
let mounted = true;
let socket;
let retryTimer;
const connectWebSocket = () => {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
socket.onmessage = (event) => {
try {
const nextState = JSON.parse(event.data);
if (mounted) {
setAppState(nextState);
}
} catch (error) {
console.error("Failed to parse state update", error);
}
};
socket.onclose = () => {
if (mounted) {
retryTimer = window.setTimeout(connectWebSocket, 1000);
}
};
};
fetch("/api/state")
.then((response) => response.json())
.then((state) => {
if (mounted) {
setAppState(state);
connectWebSocket();
}
})
.catch((error) => {
console.error("Failed to load initial state", error);
});
return () => {
mounted = false;
if (retryTimer) {
window.clearTimeout(retryTimer);
}
if (socket) {
socket.close();
}
};
}, []);
return [appState, setAppState];
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from "react";
function valuesMatch(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
export function useThrottledParameterValue(parameter, onParameterChange) {
const [draftValue, setDraftValue] = useState(parameter.value);
const [appliedValue, setAppliedValue] = useState(parameter.value);
const pendingTimeoutRef = useRef(null);
const latestDraftRef = useRef(parameter.value);
const lastSentAtRef = useRef(0);
const isInteractingRef = useRef(false);
const isDirtyRef = useRef(false);
useEffect(() => {
setDraftValue(parameter.value);
setAppliedValue(parameter.value);
latestDraftRef.current = parameter.value;
lastSentAtRef.current = 0;
isInteractingRef.current = false;
isDirtyRef.current = false;
}, [parameter.id]);
useEffect(() => {
setAppliedValue(parameter.value);
latestDraftRef.current = draftValue;
if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) {
isDirtyRef.current = false;
}
if (!isInteractingRef.current && !isDirtyRef.current) {
setDraftValue(parameter.value);
}
}, [draftValue, parameter.value]);
useEffect(() => {
return () => {
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
}
};
}, []);
const sendValue = (value) => {
setDraftValue(value);
latestDraftRef.current = value;
isDirtyRef.current = true;
lastSentAtRef.current = Date.now();
const request = onParameterChange(parameter.id, value);
if (request && typeof request.then === "function") {
request
.then((response) => {
if (response?.ok) {
setAppliedValue(value);
}
})
.catch(() => {
// Keep showing the last confirmed value if the update failed.
});
}
};
const scheduleSendValue = (value, immediate = false) => {
setDraftValue(value);
latestDraftRef.current = value;
isDirtyRef.current = true;
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
const now = Date.now();
const throttleMs = 90;
const elapsed = now - lastSentAtRef.current;
if (immediate || elapsed >= throttleMs) {
sendValue(value);
return;
}
pendingTimeoutRef.current = setTimeout(() => {
pendingTimeoutRef.current = null;
sendValue(latestDraftRef.current);
}, throttleMs - elapsed);
};
const flushScheduledValue = () => {
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
sendValue(latestDraftRef.current);
};
const beginInteraction = () => {
isInteractingRef.current = true;
};
const endInteraction = () => {
isInteractingRef.current = false;
flushScheduledValue();
};
return {
appliedValue,
beginInteraction,
draftValue,
endInteraction,
isPending: !valuesMatch(draftValue, appliedValue),
scheduleSendValue,
sendValue,
};
}