OSC updates and video resolution fixes
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
94
ui/src/components/ShaderPicker.jsx
Normal file
94
ui/src/components/ShaderPicker.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user