CI/CD
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user