CI/CD
This commit is contained in:
47
.gitea/workflows/ci.yml
Normal file
47
.gitea/workflows/ci.yml
Normal 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
|
||||||
728
ui/src/App.jsx
728
ui/src/App.jsx
@@ -1,442 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { GripVertical, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
function KvList({ values }) {
|
import { LayerStack } from "./components/LayerStack";
|
||||||
return (
|
import { StackPresetToolbar } from "./components/StackPresetToolbar";
|
||||||
<dl className="kv">
|
import { StatusPanels } from "./components/StatusPanels";
|
||||||
{values.map(([key, value]) => (
|
import { useRuntimeState } from "./hooks/useRuntimeState";
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [appState, setAppState] = useState(null);
|
const [appState, setAppState] = useRuntimeState();
|
||||||
const [pendingShaderId, setPendingShaderId] = useState("");
|
const [pendingShaderId, setPendingShaderId] = useState("");
|
||||||
const [presetName, setPresetName] = useState("");
|
const [presetName, setPresetName] = useState("");
|
||||||
const [selectedPresetName, setSelectedPresetName] = useState("");
|
const [selectedPresetName, setSelectedPresetName] = useState("");
|
||||||
@@ -444,54 +14,6 @@ function App() {
|
|||||||
const [dragLayerId, setDragLayerId] = useState(null);
|
const [dragLayerId, setDragLayerId] = useState(null);
|
||||||
const [dropTargetLayerId, setDropTargetLayerId] = 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 layers = appState?.layers ?? [];
|
||||||
const shaders = appState?.shaders ?? [];
|
const shaders = appState?.shaders ?? [];
|
||||||
const performance = appState?.performance ?? {};
|
const performance = appState?.performance ?? {};
|
||||||
@@ -521,60 +43,6 @@ function App() {
|
|||||||
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
|
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
|
||||||
}, [layers]);
|
}, [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) {
|
if (!appState) {
|
||||||
return (
|
return (
|
||||||
<main className="layout">
|
<main className="layout">
|
||||||
@@ -588,171 +56,29 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="layout">
|
<main className="layout">
|
||||||
<section className="toolbar">
|
<StackPresetToolbar
|
||||||
<div className="toolbar__group toolbar__group--wide">
|
presetName={presetName}
|
||||||
<label htmlFor="preset-name">Save Stack</label>
|
selectedPresetName={selectedPresetName}
|
||||||
<div className="toolbar__inline">
|
stackPresets={stackPresets}
|
||||||
<input
|
onPresetNameChange={setPresetName}
|
||||||
id="preset-name"
|
onSelectedPresetNameChange={setSelectedPresetName}
|
||||||
type="text"
|
/>
|
||||||
placeholder="Preset name"
|
|
||||||
value={presetName}
|
|
||||||
onChange={(event) => setPresetName(event.target.value)}
|
|
||||||
/>
|
|
||||||
<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">
|
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<button type="button" onClick={() => postJson("/api/reload", {})}>
|
<LayerStack
|
||||||
Reload Shader
|
dragLayerId={dragLayerId}
|
||||||
</button>
|
dropTargetLayerId={dropTargetLayerId}
|
||||||
</section>
|
expandedLayerIds={expandedLayerIds}
|
||||||
|
layers={layers}
|
||||||
<section className="status-grid">
|
pendingShaderId={pendingShaderId}
|
||||||
<div className="panel">
|
setAppState={setAppState}
|
||||||
<h2>Runtime</h2>
|
setDragLayerId={setDragLayerId}
|
||||||
<KvList
|
setDropTargetLayerId={setDropTargetLayerId}
|
||||||
values={[
|
setExpandedLayerIds={setExpandedLayerIds}
|
||||||
["Layer Count", `${runtime.layerCount || 0}`],
|
setPendingShaderId={setPendingShaderId}
|
||||||
["Auto Reload", app.autoReload ? "On" : "Off"],
|
shaders={shaders}
|
||||||
["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}
|
|
||||||
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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
ui/src/api/controlApi.js
Normal file
7
ui/src/api/controlApi.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function postJson(path, payload) {
|
||||||
|
return fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
18
ui/src/components/KvList.jsx
Normal file
18
ui/src/components/KvList.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
ui/src/components/LayerCard.jsx
Normal file
154
ui/src/components/LayerCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
ui/src/components/LayerStack.jsx
Normal file
152
ui/src/components/LayerStack.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
ui/src/components/ParameterField.jsx
Normal file
130
ui/src/components/ParameterField.jsx
Normal 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;
|
||||||
|
}
|
||||||
25
ui/src/components/ParameterValueDisplay.jsx
Normal file
25
ui/src/components/ParameterValueDisplay.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
ui/src/components/StackPresetToolbar.jsx
Normal file
75
ui/src/components/StackPresetToolbar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
ui/src/components/StatusPanels.jsx
Normal file
44
ui/src/components/StatusPanels.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
ui/src/hooks/useRuntimeState.js
Normal file
55
ui/src/hooks/useRuntimeState.js
Normal 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];
|
||||||
|
}
|
||||||
114
ui/src/hooks/useThrottledParameterValue.js
Normal file
114
ui/src/hooks/useThrottledParameterValue.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user