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>

View File

@@ -48,6 +48,15 @@ input[type="range"] {
width: 100%;
}
input[type="color"] {
width: 100%;
min-height: 38px;
border-radius: 6px;
border: 1px solid #303a4d;
background: #101722;
padding: 4px;
}
input[type="number"],
input[type="text"],
select,
@@ -347,6 +356,128 @@ pre {
gap: 8px;
}
.shader-picker {
display: grid;
gap: 8px;
min-width: 0;
}
.shader-picker__topline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.shader-picker__selected {
min-width: 0;
color: #98aad0;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shader-picker__search {
position: relative;
display: flex;
align-items: center;
}
.shader-picker__search svg {
position: absolute;
left: 10px;
color: #98aad0;
pointer-events: none;
}
.shader-picker__search input {
padding-left: 34px;
}
.shader-picker__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 54px;
padding: 8px 10px;
text-align: left;
background: #121b28;
border-color: #26364e;
}
.shader-picker__trigger > span {
display: grid;
gap: 2px;
min-width: 0;
}
.shader-picker__trigger svg {
flex: 0 0 auto;
color: #98aad0;
}
.shader-picker__popover {
display: grid;
gap: 8px;
padding: 8px;
border: 1px solid #273246;
border-radius: 8px;
background: #0d141f;
}
.shader-picker__list {
display: grid;
gap: 6px;
max-height: 220px;
overflow-y: auto;
padding: 6px;
border: 1px solid #273246;
border-radius: 6px;
background: #0c121b;
}
.shader-picker__option {
display: grid;
gap: 2px;
min-height: 58px;
padding: 8px 10px;
text-align: left;
background: #121b28;
border-color: #26364e;
align-content: center;
line-height: 1.25;
}
.shader-picker__option--selected {
background: #233b5f;
border-color: #6d95d8;
box-shadow: inset 0 0 0 1px rgba(109, 149, 216, 0.25);
}
.shader-picker__name,
.shader-picker__meta {
min-width: 0;
overflow: hidden;
overflow-wrap: anywhere;
}
.shader-picker__name {
font-weight: 700;
}
.shader-picker__meta,
.shader-picker__empty {
color: #98aad0;
font-size: 12px;
}
.shader-picker__empty {
padding: 10px;
}
.layer-card__subheader button {
width: auto;
min-width: 96px;
@@ -354,19 +485,58 @@ pre {
.parameter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 10px;
}
.parameter {
display: grid;
gap: 8px;
padding: 12px;
gap: 7px;
padding: 10px;
border: 1px solid #273246;
border-radius: 8px;
background: #0f151f;
}
.parameter__header {
display: grid;
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.parameter__osc {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: auto;
min-width: 0;
min-height: 24px;
padding: 2px 0;
border: 0;
background: transparent;
color: #98aad0;
font-size: 11px;
font-weight: 500;
}
.parameter__osc:hover:not(:disabled) {
background: transparent;
color: #c4d6f7;
}
.parameter__osc span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parameter__osc svg {
flex: 0 0 auto;
}
.parameter__value {
color: #98aad0;
font-size: 12px;
@@ -378,8 +548,28 @@ pre {
.parameter__pair {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
gap: 8px;
align-items: center;
}
.parameter__pair input[type="range"] {
min-width: 120px;
}
.parameter__color-row {
display: grid;
grid-template-columns: minmax(92px, 0.42fr) minmax(120px, 0.58fr);
gap: 8px;
align-items: end;
}
.parameter__alpha {
display: grid;
gap: 4px;
color: #98aad0;
font-size: 11px;
font-weight: 600;
}
.toggle {