OSC updates and video resolution fixes
Some checks failed
CI / Native Windows Build And Tests (push) Failing after 7s
CI / React UI Build (push) Has been cancelled
CI / Windows Release Package (push) Has been cancelled

This commit is contained in:
2026-05-03 14:33:33 +10:00
parent bfc12a1aea
commit 7dc4b552a5
20 changed files with 842 additions and 124 deletions

View File

@@ -2,6 +2,7 @@ import { GripVertical, Trash2 } from "lucide-react";
import { postJson } from "../api/controlApi";
import { ParameterField } from "./ParameterField";
import { ShaderPicker } from "./ShaderPicker";
export function LayerCard({
layer,
@@ -90,23 +91,17 @@ export function LayerCard({
{expanded ? (
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor={`shader-${layer.id}`}>Shader</label>
<select
<ShaderPicker
id={`shader-${layer.id}`}
shaders={shaders}
value={layer.shaderId}
onChange={(event) =>
onChange={(shaderId) =>
postJson("/api/layers/set-shader", {
layerId: layer.id,
shaderId: event.target.value,
shaderId,
})
}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
/>
</div>
{layer.temporal?.enabled ? (
@@ -139,6 +134,7 @@ export function LayerCard({
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}`}
layer={layer}
parameter={parameter}
onParameterChange={(parameterId, value) => onLayerParameterChange(layer.id, parameterId, value)}
/>

View File

@@ -1,5 +1,6 @@
import { postJson } from "../api/controlApi";
import { LayerCard } from "./LayerCard";
import { ShaderPicker } from "./ShaderPicker";
function moveItem(array, fromIndex, toIndex) {
if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
@@ -131,18 +132,12 @@ export function LayerStack({
</div>
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor="add-layer-select">Shader</label>
<select
id="add-layer-select"
<ShaderPicker
id="add-layer"
shaders={shaders}
value={pendingShaderId}
onChange={(event) => setPendingShaderId(event.target.value)}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
onChange={setPendingShaderId}
/>
</div>
</div>
</div>

View File

@@ -1,7 +1,64 @@
import { Copy } from "lucide-react";
import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
import { ParameterValueDisplay } from "./ParameterValueDisplay";
export function ParameterField({ parameter, onParameterChange }) {
function ParameterHeader({ layer, parameter }) {
const layerKey = layer.shaderId || layer.shaderName || layer.id;
const oscRoute = `/VideoShaderToys/${layerKey}/${parameter.id}`;
function copyRoute() {
if (navigator.clipboard) {
navigator.clipboard.writeText(oscRoute);
}
}
return (
<div className="parameter__header">
<label>{parameter.label}</label>
<button
type="button"
className="parameter__osc"
title="Copy OSC route"
aria-label={`Copy OSC route ${oscRoute}`}
onClick={copyRoute}
>
<span>{oscRoute}</span>
<Copy size={13} strokeWidth={1.75} aria-hidden="true" />
</button>
</div>
);
}
function clamp01(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function colorComponentToHex(value) {
return Math.round(clamp01(value) * 255)
.toString(16)
.padStart(2, "0");
}
function colorValueToHex(value) {
const values = [...(value ?? [])];
while (values.length < 3) {
values.push(0);
}
return `#${colorComponentToHex(values[0])}${colorComponentToHex(values[1])}${colorComponentToHex(values[2])}`;
}
function hexToColorValue(hex, alpha) {
const sanitized = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex.slice(1) : "000000";
return [
parseInt(sanitized.slice(0, 2), 16) / 255,
parseInt(sanitized.slice(2, 4), 16) / 255,
parseInt(sanitized.slice(4, 6), 16) / 255,
clamp01(alpha ?? 1),
];
}
export function ParameterField({ layer, parameter, onParameterChange }) {
const {
appliedValue,
beginInteraction,
@@ -12,12 +69,12 @@ export function ParameterField({ parameter, onParameterChange }) {
sendValue,
} = useThrottledParameterValue(parameter, onParameterChange);
const label = <label>{parameter.label}</label>;
const header = <ParameterHeader layer={layer} parameter={parameter} />;
if (parameter.type === "float") {
return (
<section className="parameter">
{label}
{header}
<div className="parameter__pair">
<input
type="range"
@@ -52,18 +109,17 @@ export function ParameterField({ parameter, onParameterChange }) {
);
}
if (parameter.type === "vec2" || parameter.type === "color") {
const componentCount = parameter.type === "color" ? 4 : 2;
if (parameter.type === "vec2") {
const values = [...(draftValue ?? [])];
while (values.length < componentCount) {
while (values.length < 2) {
values.push(0);
}
return (
<section className="parameter">
{label}
{header}
<div className="parameter__pair">
{Array.from({ length: componentCount }, (_, index) => (
{Array.from({ length: 2 }, (_, index) => (
<input
key={index}
type="number"
@@ -86,10 +142,50 @@ export function ParameterField({ parameter, onParameterChange }) {
);
}
if (parameter.type === "color") {
const values = [...(draftValue ?? [])];
while (values.length < 4) {
values.push(values.length === 3 ? 1 : 0);
}
return (
<section className="parameter">
{header}
<div className="parameter__color-row">
<input
type="color"
value={colorValueToHex(values)}
onFocus={beginInteraction}
onChange={(event) => sendValue(hexToColorValue(event.target.value, values[3]))}
onBlur={endInteraction}
/>
<label className="parameter__alpha">
<span>Alpha</span>
<input
type="number"
min={parameter.min?.[3] ?? 0}
max={parameter.max?.[3] ?? 1}
step={parameter.step?.[3] ?? 0.01}
value={values[3]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[3] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
</label>
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
if (parameter.type === "bool") {
return (
<section className="parameter">
{label}
{header}
<label className="toggle toggle--field">
<input
type="checkbox"
@@ -108,7 +204,7 @@ export function ParameterField({ parameter, onParameterChange }) {
if (parameter.type === "enum") {
return (
<section className="parameter">
{label}
{header}
<select
value={draftValue}
onFocus={beginInteraction}

View File

@@ -0,0 +1,94 @@
import { ChevronDown, Search } from "lucide-react";
import { useMemo, useState } from "react";
function matchesShader(shader, query) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return true;
}
return [shader.name, shader.id, shader.category, shader.description]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedQuery));
}
export function ShaderPicker({ id, label = "Shader", shaders, value, onChange }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const filteredShaders = useMemo(
() => shaders.filter((shader) => matchesShader(shader, query)),
[query, shaders],
);
const selectedShader = shaders.find((shader) => shader.id === value);
return (
<div className="shader-picker">
<div className="shader-picker__topline">
<label id={`${id}-label`}>{label}</label>
{selectedShader ? <span className="shader-picker__selected">{selectedShader.name}</span> : null}
</div>
<button
type="button"
className="shader-picker__trigger"
aria-labelledby={`${id}-label`}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span>
<span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span>
<span className="shader-picker__meta">
{selectedShader
? `${selectedShader.category ? `${selectedShader.category} / ` : ""}${selectedShader.id}`
: "Search available shaders"}
</span>
</span>
<ChevronDown size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
{open ? (
<div className="shader-picker__popover">
<div className="shader-picker__search">
<Search size={16} strokeWidth={1.75} aria-hidden="true" />
<input
id={`${id}-search`}
type="text"
value={query}
placeholder="Search shaders"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="shader-picker__list" role="listbox" aria-label={label}>
{filteredShaders.length > 0 ? (
filteredShaders.map((shader) => (
<button
key={shader.id}
type="button"
className={`shader-picker__option${shader.id === value ? " shader-picker__option--selected" : ""}`}
role="option"
aria-selected={shader.id === value}
onClick={() => {
onChange(shader.id);
setOpen(false);
setQuery("");
}}
>
<span className="shader-picker__name">{shader.name}</span>
<span className="shader-picker__meta">
{shader.category ? `${shader.category} / ` : ""}
{shader.id}
</span>
</button>
))
) : (
<div className="shader-picker__empty">No shaders found</div>
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -29,8 +29,9 @@ export function StatusPanels({ app, performance, runtime, video }) {
<KvList
values={[
["Signal", video.hasSignal ? "Present" : "Missing"],
["Mode", video.modeName || "Unknown"],
["Resolution", `${video.width || 0} x ${video.height || 0}`],
["Input Mode", video.modeName || "Unknown"],
["Input Resolution", `${video.width || 0} x ${video.height || 0}`],
["Output Mode", `${app.outputVideoFormat || "Unknown"}${app.outputFrameRate ? ` ${app.outputFrameRate}` : ""}`],
]}
/>
</div>