diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1261fc0..3a50303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: node --experimental-strip-types scripts/check-dme.ts node --experimental-strip-types scripts/check-fader.ts node --experimental-strip-types scripts/check-preview.ts + node --experimental-strip-types scripts/check-tbar.ts engine: name: Engine unit tests diff --git a/CHANGELOG.md b/CHANGELOG.md index f0612da..651ec75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ in `docs/platform-integration-plan.md` §8. ## Unreleased +### Changed: the T-bar is a real lever (ping-pong), T-bar widget + +- GUI: the T-bar stays at the end a transition took it to; the next transition is made by moving + it back (bottom to top, then down again). Before, it jumped back to the top at the bottom, and a + mouse at the end of travel could start the next transition and flicker. Now the drag follows the + pointer only (from where the handle was grabbed), sends one position at a time and in order, is + latched after reaching the end until released, starts nothing within 2 % of the resting end, and + the browser cannot cancel it with a text drag. An Auto runs the handle to the other end; a Cut of + a held transition counts as done. +- API (compatible): `POST /mixer/position` takes `lever` (0 top … 1 bottom, where the lever is) + instead of `position`; the panel's `tbar_end` (`top`/`bottom`, saved) says where the lever rests + and flips with each transition the T-bar or an Auto completes; `GET /console` → `tbar` per ME + (`end`, `position`, `lever`). `position` keeps its meaning. `position: 1` straight from rest now + completes the transition (it answered 500). With `from` (the end the drag began at) a drag from + an end that another console's Auto or Cut changed is ignored; a lever without it (a hardware + panel; several told apart by `source`) is picked up at the resting end first: at start, after an + engine restart or a loaded state and after each change of the end. Ignored values answer + `status: "ignored"` with a `reason`. A held lever snaps within 2 % of either end; + with a stinger or BKGD off it fires one Auto per drag; an Auto that fails leaves the lever. +- Widget `tbar` (`/widget/tbar[?me=][&buttons=false]`): the ME's T-bar lever with Cut and Auto. + ### Added: Program streams, multiview widget - Engine: each ME's Program as a WebRTC stream of its own (`/me-pgm`, NVENC H.264, diff --git a/README.md b/README.md index 753372f..2568bc6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ The GUI is a **separate React service** (Vite + TypeScript). Every control on th - **File** takes the mixer on-air or off-air and opens the **clip and still library** and the stinger library. While a file goes up its bar shows the bytes sent (within each 8 MiB chunk too), then each conversion under way has a bar with the item's name and Cancel; both stay at the top of the library while it scrolls, and a converting item's card has its own bar. - **Settings → Console layout…** (`PUT /workspace`): video format (off-air), source tiles 16:9 or 9:16 (display only, also on air), the number of sources, MEs, stinger slots, stinger players and keyers. What the production sets through the environment is greyed out. - The gear on a **source** (`PATCH /inputs/{id}`): name, kind (`mxl_live`, `clip_player`, `black`), flows of a live input, the clip or still of a clip player with loop and play on cut, and the auto-stinger. A live input's **Audio** section (`PATCH /audio/inputs/{id}`): its delay (±1 ms, ±1 frame) and channel map (per PGM channel the input channel or silent; 1:1, Mono), for every ME. -- **Transition section** (per ME): the type (MIX, WIPE, STING) and the rate in frames (`PATCH /panels/{id}/transition`); the next transition's elements **BKGD**, **KEY 1** … (lit when tied; a keyer on air has a red underline; the last one lit stays) and **GRAB** (ME Program to the library); for WIPE the pattern picker (icons, the SMPTE code as a tooltip), reverse, flip-flop (the next wipe goes back), soft edge and border (0–100 % of the pattern's travel) and the border colour; for STING the slot. **CUT**, **AUTO** (`/mixer/auto`: the ME's transition) and **FTB** (lit while the output is black) map to `/mixer/cut`, `/mixer/auto`, `/mixer/fade-to-black`; the **T-bar** lever holds the transition at its position (`/mixer/position`) and follows an Auto. +- **Transition section** (per ME): the type (MIX, WIPE, STING) and the rate in frames (`PATCH /panels/{id}/transition`); the next transition's elements **BKGD**, **KEY 1** … (lit when tied; a keyer on air has a red underline; the last one lit stays) and **GRAB** (ME Program to the library); for WIPE the pattern picker (icons, the SMPTE code as a tooltip), reverse, flip-flop (the next wipe goes back), soft edge and border (0–100 % of the pattern's travel) and the border colour; for STING the slot. **CUT**, **AUTO** (`/mixer/auto`: the ME's transition) and **FTB** (lit while the output is black) map to `/mixer/cut`, `/mixer/auto`, `/mixer/fade-to-black`; the **T-bar** is a real lever (ping-pong): drag the handle from the end it rests at to the other end to make the transition; it stays there, and the next transition goes back the other way; back to its start ends it without the swap. An Auto runs the handle to the other end at the ME's rate; a Cut of a held transition counts as done. The mixer keeps the lever's end, so every console, the T-bar widget and a hardware panel agree. While dragging, the handle follows only the pointer (from where it was grabbed; a click on the track does nothing), and after reaching the end it is latched until released (no flicker at the end of travel). - **AUDIO** (in the keyer row) opens and closes the active ME's audio strip under the deck (remembered in the browser; P5, `docs/engine-p5.md`): one column per strip (live inputs, clip players, the Programs of higher MEs, the stinger) with its meter (up to 2 bars: with more channels the loudest odd and even channels; a 1 s peak hold and a red over mark), a fader (+10 … −∞; double click: 0 dB; the values go to the mixer while it moves), **MUTE**, **AFV** (audio follows video; off: always in the mix), a red dot while the strip is heard in PGM and a delayed input's delay; then the master: the PGM meter (the true peak as a line, OVER with a count), the loudness of channels 1–2 (M, S, I in LUFS), **RESET** (integrated from now on) and the master fader. Meters poll at 10 Hz only while it is open. No audio preview. - **SNAP** (in the keyer row) opens the snapshots ([docs/snapshots.md](docs/snapshots.md)): the active ME's, then those of all MEs, each with its number and name (double click renames; START marks the start-up snapshot), **PGM** and **AUD** (its recall's defaults: Program/on-air and audio), **RECALL** and **⋯** (recall once with other options, rename, store the current look over it, recall when the mixer starts, delete). **STORE** stores the active ME, or all MEs, into the lowest free number. - **Frame grab:** right click on a source tile or on the Preview or Program monitor: *Grab still*, or *Grab to * (the frame memory); a toast names the new still. @@ -109,11 +109,12 @@ Operator screens (the platform's production designer) frame single controls of F | `keys` | `me` (required) | 240×150 | that ME's upstream keyers and DSKs with On/Off and Auto | | `dme` | `me`, `keyer` (required: upstream keyer 1..8) | 320×120 | that keyer's box: On/Off, Auto (a fly with FLY), Run back and forth, Stop and the presets; editing stays in the console | | `audio` | `me` (required), `faders` (default true) | 480×240 | that ME's audio strips (meter, MUTE, AFV and, unless `faders=false`, the fader) and its master meter with the loudness (M, S, I) and RESET; meters at 10 Hz | +| `tbar` | `me` (optional, default 1), `buttons` (default true) | 120×240 | that ME's T-bar lever, the same as the console's (it stays at the end a transition took it to) and, unless `buttons=false`, Cut and Auto | | `multiview` | `me` (optional, default 1: whose tally), `pvw` (default false) | 480×270 | a control-room wall over WebRTC: each ME's Program (its own stream), with `pvw=true` each ME's Preview, then the live inputs and clip players (mosaic tiles), each with its label; red border while it is on air on ME `me` through any path (the console's `on_air` when the mixer has it, else Program), green while it is on the Preview of any ME (every ME's Preview picture is green; red wins). The grid follows the mixer (sources and MEs added or removed) and fills the frame with the largest 16:9 pictures that fit | | `snapshots` | `me` (optional), `all` (default true) | 240×120 | a recall button per snapshot of that ME and, unless `all=false`, of all MEs (without `me`: those only), with its PGM / AUD defaults; the last recall is lit | - `GET /widgets` lists them with a JSON schema of their parameters (from the mixer's `GET /api/v1/widgets`); it answers an `Origin` that `WIDGET_FRAME_ANCESTORS` names (or `*`) with `Access-Control-Allow-Origin`. -- `GET /widget/bus?me=[&transition=false][&theme=dark|light|transparent]` (and `/widget/keys?me=`, `/widget/dme?me=&keyer=`, `/widget/audio?me=[&faders=false]`, `/widget/snapshots[?me=][&all=false]`, `/widget/multiview[?me=][&pvw=true]`) is the page without the console around it, on the GUI's own origin (its `/api`). A bad parameter answers 400, an unknown widget 404. Only these routes carry `Content-Security-Policy: frame-ancestors `; none carries `X-Frame-Options`. The page posts `{type: "widget-ready"}` and `{type: "widget-size", w, h}` to its parent. +- `GET /widget/bus?me=[&transition=false][&theme=dark|light|transparent]` (and `/widget/keys?me=`, `/widget/dme?me=&keyer=`, `/widget/audio?me=[&faders=false]`, `/widget/snapshots[?me=][&all=false]`, `/widget/multiview[?me=][&pvw=true]`, `/widget/tbar[?me=][&buttons=false]`) is the page without the console around it, on the GUI's own origin (its `/api`). A bad parameter answers 400, an unknown widget 404. Only these routes carry `Content-Security-Policy: frame-ancestors `; none carries `X-Frame-Options`. The page posts `{type: "widget-ready"}` and `{type: "widget-size", w, h}` to its parent. - `WIDGET_FRAME_ANCESTORS` is set on the **gui** container: a CSP source list, default `'self'` (an invalid value stops nginx with exit code 78). ## API @@ -147,7 +148,7 @@ If `FLOWXER_API_TOKEN` is set, calls to **9610** need `Authorization: Bearer … | Audio of an ME | `PATCH /audio/panels/{id}` (`master_db`); `PATCH /audio/panels/{id}/strips/{strip}` (`fader_db` −90 … +10, `mute`, `afv`; `strip`: an input or clip player id, a higher panel id (re-entry) or `stinger`, which has no `afv`); `POST /audio/panels/{id}/loudness/reset` (integrated loudness from now on) | | Audio meters (10 Hz) | `GET /audio/meters`: the last 100 ms block; `sources` (live inputs and clip players by id, `stinger-`: `peak_dbfs`, `rms_dbfs` per channel), `panels` (`peak_dbfs`, `true_peak_dbtp`, `rms_dbfs` per channel, `loudness` per pair: `momentary`, `short_term`, `integrated` LUFS; `overs`; `strips`: `gain_db`, `heard`). Values below −120 dB are −120 | | SNAP (snapshots) | `GET /snapshots` (also `GET /console` `snapshots`); `POST /snapshots` (`panel_id`: `me-` or `all`, `number` 1–99, default the lowest free; `name`; `options`: `program`, `audio`): store the current state; `GET`, `PATCH` (`name`, `options`), `DELETE /snapshots/{number}`; `POST /snapshots/{number}/recall` (`program`, `audio` override its defaults; answers what it `skipped`); `PUT /snapshots/startup` (`number` or null). See [docs/snapshots.md](docs/snapshots.md) | -| T-bar lever | `POST /mixer/position` (`panel_id`, `position` 0–1): off 0 starts the ME's transition and holds it, 1 completes it, 0 ends it without the swap; AUTO runs the rest; a stinger runs as an Auto | +| T-bar lever | `POST /mixer/position` (`panel_id` and either `position` 0–1: off 0 starts the ME's transition and holds it, 1 completes it, 0 ends it without the swap; or `lever` 0 top … 1 bottom: where a physical lever is, counted from the end it rests at, see below); AUTO runs the rest; a stinger runs as an Auto. The lever rests at `tbar_end` of the panel (`top` or `bottom`, saved); each transition the T-bar or an Auto completes (or a Cut of a held one) moves it to the other end, so the next transition goes back the other way (ping-pong). With `lever`, nothing starts within 2 % of the resting end, and a held transition snaps within 2 % of either end (back without the swap, or complete). When another console's Auto or Cut moves the resting end: a lever request with `from` (`top`/`bottom`, the end its drag began at; the GUI sends it) from an end that is no longer the resting one is ignored, so a drag cannot take the shot back; a lever without `from` (a hardware panel) counts only once it has come within 2 % of the resting end (pickup): at start, after the engine restarts or a state is loaded, and after each change of the end; until then it is ignored, also while a console holds a transition. One hardware lever per ME without `from`, or give each its own `source` (each picks up on its own). An ignored value answers 200 with `status: "ignored"` and `reason` (`stale_from` or `pickup`). With a stinger or BKGD off the lever fires one Auto, then waits for the next drag (or its pickup at the other end). `GET /console` → `tbar` per ME: `end`, `position` (of a held transition), `lever` | | CUT / AUTO / FTB | `POST /mixer/cut`, `/mixer/auto` (`panel_id`, `frames`), `/mixer/fade-to-black`. Kept for API clients: `/mixer/fade` (a mix), `/mixer/wipe` (arms Wipe), `/mixer/take` (`transition`: `cut`, `mix`, `wipe`, `stinger`; `frames` or `duration_ms`) | | Stinger chip / ⚙ | `POST /stinger/play`, `PATCH /stinger-slots/{id}` | | GRAB | `POST /library/grab` (`source`: an input or clip player id, a panel id for its Program, `:pvw` for its Preview; `player`: a clip player to load the still into; `name`, default `Grab `): the new still (kind `still`, source `grab`, tag `grab`), ready at once | diff --git a/gui/nginx.conf b/gui/nginx.conf index 3890b44..4b5c188 100644 --- a/gui/nginx.conf +++ b/gui/nginx.conf @@ -108,6 +108,20 @@ server { try_files /widget.html =404; } + location = /widget/tbar { + if ($arg_me !~ "^(|[1-4])$") { + return 400 "me must be an ME 1..4\n"; + } + if ($arg_buttons !~ "^(|true|false)$") { + return 400 "buttons must be true or false\n"; + } + if ($arg_theme !~ "^(|dark|light|transparent)$") { + return 400 "theme must be dark, light or transparent\n"; + } + include /etc/nginx/conf.d/flowxer-widgets-csp.inc; + try_files /widget.html =404; + } + location = /widget/multiview { if ($arg_me !~ "^(|[1-4])$") { return 400 "me must be an ME 1..4\n"; diff --git a/gui/scripts/check-tbar.ts b/gui/scripts/check-tbar.ts new file mode 100644 index 0000000..e4512db --- /dev/null +++ b/gui/scripts/check-tbar.ts @@ -0,0 +1,119 @@ +// The T-bar lever (src/tbar.ts): a drag that completes a transition and stays at the end, the +// reverse drag, no flicker at the end of travel, the deadband, back to the start without the swap. +// Run: node --experimental-strip-types scripts/check-tbar.ts +import { createSender, dragStep, endChanged, startDrag, travelSeconds, type Drag, type TbarState } from "../src/tbar.ts"; + +let failed = 0; +const check = (ok: boolean, what: string) => { + if (!ok) { + failed += 1; + console.error(`FAILED ${what}`); + } +}; + +/** A drag through `pointer` positions: what it sent and where it drew the handle last. */ +function drag(state: TbarState, pointer: number[]): { sent: number[]; shown: number; drag: Drag } { + let d = startDrag(state); + const sent: number[] = []; + let shown = state.lever; + for (const p of pointer) { + const step = dragStep(d, p); + d = step.drag; + shown = step.shown; + if (step.send !== null) sent.push(step.send); + } + return { sent, shown, drag: d }; +} + +const top: TbarState = { end: "top", position: 0, lever: 0 }; +const bottom: TbarState = { end: "bottom", position: 0, lever: 1 }; + +// A drag down completes at the bottom (the last 1 % snaps), then nothing until release: the handle +// stays at the bottom while the pointer wiggles there (the old console jumped back to the top). +const down = drag(top, [0.0, 0.01, 0.3, 0.6, 0.995, 0.97, 1.0, 0.98]); +check(JSON.stringify(down.sent) === JSON.stringify([0.3, 0.6, 1]), `drag down sends ${JSON.stringify(down.sent)}`); +check(down.shown === 1 && down.drag.latched, "the handle stays at the bottom, latched"); + +// The reverse drag from the bottom: up to the top completes the next transition. +const up = drag(bottom, [1.0, 0.99, 0.985, 0.7, 0.2, 0.004]); +check(JSON.stringify(up.sent) === JSON.stringify([0.7, 0.2, 0]), `drag up sends ${JSON.stringify(up.sent)}`); +check(up.shown === 0 && up.drag.latched, "the handle stays at the top"); + +// Within the deadband of the resting end nothing starts (no flicker at the end of travel). +check(drag(bottom, [0.995, 0.985, 0.99]).sent.length === 0, "deadband at the bottom"); +check(drag(top, [0.005, 0.015, 0.0]).sent.length === 0, "deadband at the top"); + +// Back to the resting end ends the held transition without the swap (once). +const back = drag(top, [0.4, 0.1, 0.0, 0.005, 0.0]); +check(JSON.stringify(back.sent) === JSON.stringify([0.4, 0.1, 0]) && !back.drag.latched, `back to the top: ${JSON.stringify(back.sent)}`); + +// Picking up a transition another console holds: the drag continues it (active from the start). +const held = drag({ end: "top", position: 0.5, lever: 0.5 }, [0.5, 0.01]); +check(JSON.stringify(held.sent) === JSON.stringify([0.5, 0]), `held elsewhere: ${JSON.stringify(held.sent)}`); + +// The same value is not sent twice; an Auto's lever travel takes the ME's rate. +check(drag(top, [0.3, 0.3, 0.3]).sent.length === 1, "no repeats"); +check(travelSeconds(0, 1, 25, 50) === 0.5 && travelSeconds(0.6, 1, 25, 50) === 0.2, "travel at the rate"); + +// A stinger or BKGD off: the first send fires the Auto, then the drag is latched (one Auto per drag). +{ + let d = startDrag(top, true); + const sent: number[] = []; + for (const p of [0.01, 0.05, 0.3, 0.6, 1.0]) { + const step = dragStep(d, p); + d = step.drag; + if (step.send !== null) sent.push(step.send); + } + check(JSON.stringify(sent) === JSON.stringify([0.05]) && d.latched, `one shot: ${JSON.stringify(sent)}`); +} + +// Another console's Auto flips the resting end under a drag: the drag is latched (no take back). +{ + let d = startDrag(top); + d = dragStep(d, 0.4).drag; + d = endChanged(d, { end: "bottom", position: 0, lever: 1 }); + check(d.latched && dragStep(d, 0.5).send === null, "latched after the end changed under the drag"); + // Its own held transition (end unchanged) does not latch it. + check(!endChanged(startDrag(top), { end: "top", position: 0.4, lever: 0.4 }).latched, "own drag not latched"); +} + +// The send queue: one request at a time, a value waiting while one is in flight is replaced by a +// newer one (newest wins), the order is kept, and a failed request does not stop it. +{ + const calls: number[] = []; + const pending: (() => void)[] = []; + const send = createSender((value) => { + calls.push(value); + return new Promise((resolve, reject) => pending.push(value === 0.2 ? () => reject(new Error("x")) : resolve)); + }); + send(0.2); + send(0.4); + send(0.6); + send(1); + check(JSON.stringify(calls) === JSON.stringify([0.2]), "one at a time"); + pending.shift()!(); + await new Promise((r) => setTimeout(r, 0)); + check(JSON.stringify(calls) === JSON.stringify([0.2, 1]), `newest wins after a failure: ${JSON.stringify(calls)}`); + pending.shift()!(); + await new Promise((r) => setTimeout(r, 0)); + send(0.5); + check(JSON.stringify(calls) === JSON.stringify([0.2, 1, 0.5]), "idle again: sent at once"); +} + +// A queued value keeps the end its own drag began at (not the end of the time it is sent). +{ + const calls: string[] = []; + const pending: (() => void)[] = []; + const send = createSender((value: { lever: number; from: string }) => { + calls.push(`${value.lever}@${value.from}`); + return new Promise((resolve) => pending.push(resolve)); + }); + send({ lever: 0.5, from: "top" }); + send({ lever: 1, from: "top" }); + pending.shift()!(); + await new Promise((r) => setTimeout(r, 0)); + check(JSON.stringify(calls) === JSON.stringify(["0.5@top", "1@top"]), `from per value: ${JSON.stringify(calls)}`); +} + +console.log(`t-bar: ${failed ? `${failed} failed` : "ok"}`); +if (failed) process.exit(1); diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 6e6bbf4..d868ec2 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -363,7 +363,8 @@ export default function App() { keyers={snapshot.keyers.filter((keyer) => keyer.panel_id === panel.id && keyer.stage === "upstream")} stingerSlots={snapshot.stinger_slots} fps={fps} - onPosition={(position) => void api.position(panel.id, position).catch((err: Error) => setError(err.message))} + tbar={snapshot.tbar?.[panel.id]} + onLever={(lever, from) => api.lever(panel.id, lever, from).catch((err: Error) => setError(err.message))} onChange={(patch) => void command(() => api.transition(panel.id, patch))} onCut={() => void command(() => api.cut(panel.id))} onAuto={() => void command(() => api.auto(panel.id))} diff --git a/gui/src/api.ts b/gui/src/api.ts index 637a576..d3000cf 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,4 +1,5 @@ import type { PreviewMap } from "./preview"; +import type { TbarState } from "./tbar"; export type InputKind = "mxl_live" | "clip_player" | "black"; export type ClipEnd = "off" | "cut" | "auto" | "stinger"; @@ -360,6 +361,8 @@ export interface SnapshotRecallResult { } export interface ConsoleState { + /** Per ME the T-bar: where the lever rests, the progress of a held transition, where it is. */ + tbar?: Record; /** Per ME id what is visible on its Program through any path (keys, pips, nested MEs): red tally. */ on_air?: Record; workspace: WorkspaceConfig; @@ -459,6 +462,14 @@ export const api = { headers: jsonHeaders, body: JSON.stringify({ panel_id }), }).then((r) => parse(r)), + /** The T-bar lever where it physically is (0 top .. 1 bottom): the mixer counts the transition + * from the end the lever rests at (ping-pong). */ + lever: (panel_id: string, lever: number, from?: "top" | "bottom") => + fetch("/api/v1/mixer/position", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ panel_id, lever, from }), + }).then((r) => parse(r)), /** The T-bar: hold the ME's transition at a position (0..1); 1 completes it, 0 ends it. */ position: (panel_id: string, position: number) => fetch("/api/v1/mixer/position", { diff --git a/gui/src/components/TBar.tsx b/gui/src/components/TBar.tsx new file mode 100644 index 0000000..8ef2546 --- /dev/null +++ b/gui/src/components/TBar.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from "react"; +import { createSender, dragStep, endChanged, startDrag, travelSeconds, type Drag, type TbarState } from "../tbar"; + +/** The T-bar lever (src/tbar.ts): top 0, bottom 1, resting at the end the mixer says. A drag + * holds the ME's transition; the other end completes it and the lever stays there (the next + * transition goes back). While the operator drags, the mixer's state does not move the handle; + * otherwise the handle follows it, and runs to the other end over the ME's rate when an Auto (or + * another console) moved it there. */ +export function TBar({ + state, + rateFrames, + fps, + oneShot = false, + onLever, +}: { + state: TbarState; + rateFrames: number; + fps: number; + /** A stinger or BKGD off: the lever fires an Auto (no scrubbing), once per drag. */ + oneShot?: boolean; + /** Sends the lever and the end its drag began at to the mixer; the component sends one at a + * time, in order (newest wins), so a completion is never overtaken by an older position. */ + onLever: (lever: number, from: TbarState["end"]) => Promise; +}) { + const track = useRef(null); + const drag = useRef(null); + const animation = useRef(0); + const [shown, setShown] = useState(state.lever); + const shownRef = useRef(shown); + shownRef.current = shown; + const stateRef = useRef(state); + stateRef.current = state; + + // Follow the mixer when not dragging: a jump of the resting end (an Auto) runs at the ME's rate. + useEffect(() => { + if (drag.current) return; + window.cancelAnimationFrame(animation.current); + const from = shownRef.current; + const to = state.lever; + const seconds = state.position === 0 ? travelSeconds(from, to, rateFrames, fps) : 0; + if (seconds <= 0.02) { + setShown(to); + return; + } + const start = performance.now(); + const step = (now: number) => { + const k = Math.min(1, (now - start) / 1000 / seconds); + setShown(from + (to - from) * k); + if (k < 1) animation.current = window.requestAnimationFrame(step); + }; + animation.current = window.requestAnimationFrame(step); + }, [state.end, state.lever, state.position, rateFrames, fps]); + useEffect(() => () => window.cancelAnimationFrame(animation.current), []); + + // One request at a time, newest wins (src/tbar.ts createSender). + const onLeverRef = useRef(onLever); + onLeverRef.current = onLever; + // Each value carries the end its drag began at (a value waiting in the queue keeps its own). + const sender = useRef<((value: { lever: number; from: TbarState["end"] }) => void) | null>(null); + const from = useRef(state.end); + if (!sender.current) sender.current = createSender((value: { lever: number; from: TbarState["end"] }) => onLeverRef.current(value.lever, value.from)); + const send = (lever: number) => sender.current?.({ lever, from: from.current }); + // The resting end changed under a drag (another console's Auto or Cut): the drag stops. + useEffect(() => { + if (drag.current) drag.current = endChanged(drag.current, state); + }, [state.end, state.position]); + + const at = (event: React.PointerEvent) => { + const rect = track.current?.getBoundingClientRect(); + return rect ? (event.clientY - rect.top) / rect.height : 0; + }; + // The handle moves with the pointer from where it was grabbed (a click on the track does not make + // it jump to the far end and complete a transition). + const grab = useRef(0); + const move = (event: React.PointerEvent) => { + if (!drag.current) return; + const step = dragStep(drag.current, at(event) - grab.current); + drag.current = step.drag; + setShown(step.shown); + if (step.send !== null) send(step.send); + }; + // The drag ends (released, cancelled or its capture lost): the handle goes to where the mixer + // has the lever (unchanged after a completion or a held position; corrected after a latched drag + // or a failed request). + const resync = useRef(0); + const end = () => { + const ended = drag.current; + if (!ended) return; + drag.current = null; + if (ended.completed) return; + // Once the mixer's state has caught up (the console polls every second). + window.clearTimeout(resync.current); + resync.current = window.setTimeout(() => { + if (!drag.current) setShown(stateRef.current.lever); + }, 1200); + }; + useEffect(() => () => window.clearTimeout(resync.current), []); + const release = (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + end(); + }; + return ( +
event.preventDefault()} + onPointerDown={(event) => { + // No text selection or native drag: the browser would cancel the pointer mid-drag. + event.preventDefault(); + window.cancelAnimationFrame(animation.current); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = startDrag(state, oneShot); + from.current = state.end; + grab.current = at(event) - shownRef.current; + }} + onPointerMove={(event) => { + if (event.buttons) move(event); + }} + onPointerUp={release} + onPointerCancel={release} + onLostPointerCapture={end} + > +
+
+
+ ); +} diff --git a/gui/src/components/TbarWidget.tsx b/gui/src/components/TbarWidget.tsx new file mode 100644 index 0000000..df7a07b --- /dev/null +++ b/gui/src/components/TbarWidget.tsx @@ -0,0 +1,50 @@ +import { api } from "../api"; +import { TBar } from "./TBar"; +import { useWidget } from "./useWidget"; + +/** The `tbar` widget: ME `me`'s T-bar lever (a real lever: it stays at the end a transition took + * it to, src/tbar.ts) and, unless `buttons` is false, Cut and Auto as in the bus widget. */ +export function TbarWidget({ me, buttons = true }: { me: number; buttons?: boolean }) { + const { snapshot, error, setError, command, root } = useWidget(); + const panel = snapshot?.panels[me - 1]; + const format = snapshot?.formats.find((item) => item.id === snapshot.workspace.format_id); + const [num, den] = String(format?.frame_rate ?? "50/1").split("/").map(Number); + const fps = num > 0 ? num / (den > 0 ? den : 1) : 50; + return ( +
+ {!snapshot || !panel ? ( +
{!snapshot ? error ?? "Connecting to the vision mixer…" : `No ME ${me}: the mixer has ${snapshot.panels.length}.`}
+ ) : ( + <> +
{panel.label}
+ api.lever(panel.id, lever, from).catch((err: Error) => setError(err.message))} + /> + {buttons ? ( +
+ + +
+ ) : null} + + )} + {error && snapshot ? ( +
setError(null)}> + {error} +
+ ) : null} +
+ ); +} diff --git a/gui/src/components/TransitionSection.tsx b/gui/src/components/TransitionSection.tsx index 9a795de..94ab5be 100644 --- a/gui/src/components/TransitionSection.tsx +++ b/gui/src/components/TransitionSection.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import type { Keyer, MixerPanel, PanelTransition, StingerSlot, TransitionKind } from "../api"; +import type { TbarState } from "../tbar"; import { WIPE_PATTERNS, wipePattern } from "../wipe"; +import { TBar } from "./TBar"; const TYPES: { type: TransitionKind; label: string }[] = [ { type: "mix", label: "Mix" }, @@ -30,33 +32,6 @@ function WipeIcon({ code, reverse }: { code: number; reverse: boolean }) { return ; } -/** The T-bar: a vertical lever (top 0, bottom 1). Dragging holds the ME's transition there; the - * bottom completes it, the top ends it without the swap. */ -function TBar({ value, onMove }: { value: number; onMove: (position: number) => void }) { - const track = useRef(null); - const drag = (event: React.PointerEvent) => { - const rect = track.current?.getBoundingClientRect(); - if (!rect) return; - onMove(Math.round(Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)) * 100) / 100); - }; - return ( -
{ - event.currentTarget.setPointerCapture(event.pointerId); - drag(event); - }} - onPointerMove={(event) => { - if (event.buttons) drag(event); - }} - > -
-
-
- ); -} /** The next transition's elements: BKGD and the ME's upstream keyers (KEY 1 .. K), each lit when * it goes with Cut, Auto and the T-bar. At least one stays lit. */ @@ -118,7 +93,8 @@ export function TransitionSection({ onChange, onCut, onAuto, - onPosition, + onLever, + tbar, onFadeToBlack, onGrab, }: { @@ -130,34 +106,14 @@ export function TransitionSection({ onChange: (patch: Record) => void; onCut: () => void; onAuto: () => void; - onPosition: (position: number) => void; + /** The T-bar lever where the operator holds it (0 top .. 1 bottom). */ + onLever: (lever: number, from: TbarState["end"]) => Promise; + /** The ME's T-bar as the mixer has it (GET /console → tbar). */ + tbar?: TbarState; onFadeToBlack: () => void; onGrab: () => void; }) { const transition: PanelTransition = panel.transition; - // The lever: where the operator holds it, or where an Auto is (it runs at the ME's rate). - const [position, setPosition] = useState(0); - const animation = useRef(0); - const move = (next: number) => { - window.cancelAnimationFrame(animation.current); - if (next === position) return; - onPosition(next); - setPosition(next >= 1 ? 0 : next); - }; - const auto = () => { - onAuto(); - const from = position; - const seconds = ((1 - from) * transition.rate_frames) / fps; - const start = performance.now(); - const step = (now: number) => { - const t = from + (1 - from) * Math.min(1, (now - start) / 1000 / seconds); - setPosition(t >= 1 ? 0 : t); - if (t < 1) animation.current = window.requestAnimationFrame(step); - }; - window.cancelAnimationFrame(animation.current); - if (transition.type !== "stinger" && (transition.background ?? true)) animation.current = window.requestAnimationFrame(step); - }; - useEffect(() => () => window.cancelAnimationFrame(animation.current), []); const wipe = transition.wipe; const setWipe = (patch: Record) => onChange({ wipe: patch }); return ( @@ -260,13 +216,19 @@ export function TransitionSection({ - - +
); diff --git a/gui/src/styles.css b/gui/src/styles.css index 1f5b12f..95d8bae 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -224,7 +224,7 @@ input.snap-name { font: inherit; color: var(--text); background: #0b1220; border .sting-slot { display: grid; gap: 4px; color: var(--muted); } .take-row { display: grid; grid-template-columns: 1fr 1fr 1fr 44px; gap: 8px; align-self: end; } .take-row .ftb.on { outline: 2px solid #e23b3b; } -.tbar { position: relative; min-height: 96px; cursor: ns-resize; touch-action: none; } +.tbar { position: relative; min-height: 96px; cursor: ns-resize; touch-action: none; user-select: none; -webkit-user-select: none; } .tbar-slot { position: absolute; left: 50%; top: 0; bottom: 0; width: 6px; transform: translateX(-50%); background: #05070b; border: 1px solid var(--line); border-radius: 3px; } .tbar-handle { position: absolute; left: 2px; right: 2px; height: 14px; transform: translateY(-50%); diff --git a/gui/src/tbar.ts b/gui/src/tbar.ts new file mode 100644 index 0000000..6a9d05a --- /dev/null +++ b/gui/src/tbar.ts @@ -0,0 +1,104 @@ +// The T-bar as a real lever (ping-pong), the logic the console and the tbar widget share; checked by +// scripts/check-tbar.ts. The mixer keeps where the lever rests (`end`: top or bottom, flipped by each +// transition the lever, an Auto or a Cut of a held one completes) and takes the lever where it +// physically is (POST /mixer/position {lever}: 0 top .. 1 bottom), so several consoles, the widget +// and a hardware panel agree. A drag: +// - takes its resting end when it starts (the mixer's answers do not fight the drag); +// - starts a transition only past DEADBAND from that end; +// - completes it at the other end (the last few % snap there) and is then latched until the +// pointer is released: wiggling at the end of travel starts nothing (no flicker); +// - back to its resting end ends the transition without the swap; +// - with a stinger or BKGD off (an Auto from the lever, no scrubbing) it is latched after its first +// send: one drag, one Auto; +// - when the mixer's resting end changes under it (another console's Auto or Cut) it is latched +// (endChanged), and each send says the end it began at (`from`), which the mixer checks too. + +export interface TbarState { + /** Where the lever rests. */ + end: "top" | "bottom"; + /** The progress of a transition it holds (0: none). */ + position: number; + /** Where it is: 0 top .. 1 bottom. */ + lever: number; +} + +export const DEADBAND = 0.02; +/** Within this much of an end the lever is at the end. */ +export const SNAP = 0.01; + +export interface Drag { + /** The end the lever rested at when the drag began. */ + rest: TbarState["end"]; + /** This drag holds a transition (sent a position off its resting end). */ + active: boolean; + /** It completed a transition: nothing more until the pointer is released. */ + latched: boolean; + /** The last lever value sent. */ + last: number | null; + /** A stinger or BKGD off: the first send fires an Auto, then the drag is latched. */ + oneShot: boolean; + /** It reached the other end (the handle stays there). */ + completed: boolean; +} + +export function startDrag(state: TbarState, oneShot = false): Drag { + return { rest: state.end, active: state.position > 0, latched: false, last: null, oneShot, completed: false }; +} + +/** The mixer's state while a drag runs: a resting end that changed under it (another console's + * Auto or Cut) latches the drag, so it cannot take the shot back. */ +export function endChanged(drag: Drag, state: TbarState): Drag { + return !drag.latched && state.end !== drag.rest && state.position === 0 ? { ...drag, latched: true } : drag; +} + +const restLever = (end: TbarState["end"]) => (end === "top" ? 0 : 1); + +/** One pointer position of a drag (0 top .. 1 bottom): what to send (null: nothing), where to draw + * the handle and the drag after it. */ +export function dragStep(drag: Drag, pointer: number): { send: number | null; shown: number; drag: Drag } { + const rest = restLever(drag.rest); + const far = 1 - rest; + let v = Math.min(1, Math.max(0, pointer)); + if (v <= SNAP) v = 0; + if (v >= 1 - SNAP) v = 1; + v = Math.round(v * 1000) / 1000; + if (drag.latched) return { send: null, shown: far, drag }; + const t = Math.abs(v - rest); + // A value already sent is not sent again. + const sendIf = (value: number, next: Drag) => ({ send: drag.last === value ? null : value, shown: value, drag: { ...next, last: value } }); + if (v === far) return sendIf(far, { ...drag, active: false, latched: true, completed: true }); + if (t < DEADBAND) { + // Back at (or not yet off) the resting end: a held transition ends without the swap. + if (drag.active) return sendIf(rest, { ...drag, active: false }); + return { send: null, shown: v, drag }; + } + return sendIf(v, { ...drag, active: true, latched: drag.oneShot }); +} + +/** Sends one value at a time, in order: a value given while one is in flight waits, and a newer + * one replaces it (newest wins), so a completion is never overtaken by an older position. */ +export function createSender(send: (value: T) => Promise): (value: T) => void { + let inFlight = false; + let waiting: T | null = null; + const next = (value: T) => { + if (inFlight) { + waiting = value; + return; + } + inFlight = true; + void send(value) + .catch(() => undefined) + .finally(() => { + inFlight = false; + const queued = waiting; + waiting = null; + if (queued !== null) next(queued); + }); + }; + return next; +} + +/** Seconds an Auto moves the lever from `from` to `to` (both 0..1) at the ME's rate. */ +export function travelSeconds(from: number, to: number, rateFrames: number, fps: number): number { + return (Math.abs(to - from) * rateFrames) / Math.max(1, fps); +} diff --git a/gui/src/widget.css b/gui/src/widget.css index a49b19c..28c1266 100644 --- a/gui/src/widget.css +++ b/gui/src/widget.css @@ -138,3 +138,27 @@ html, body, #root { margin: 0; height: 100%; background: var(--bg); color: var(- } .multiview-cell .monitor.tally-pgm .monitor-label { background: var(--pgm); } .multiview-cell .monitor.tally-pvw .monitor-label { background: var(--pvw); color: #041018; } + +/* The tbar widget: the ME's T-bar lever (the console's, components/TBar.tsx), Cut and Auto. */ +.tbar-widget { + position: relative; min-width: 120px; min-height: 240px; height: 100%; padding: 8px; + display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; background: var(--panel); +} +.tbar-widget { user-select: none; -webkit-user-select: none; } +.tbar-widget .tbar { position: relative; min-height: 120px; cursor: ns-resize; touch-action: none; } +.tbar-widget .tbar-slot { + position: absolute; left: 50%; top: 0; bottom: 0; width: 8px; transform: translateX(-50%); + background: #05070b; border: 1px solid var(--line); border-radius: 4px; +} +.tbar-widget .tbar-handle { + position: absolute; left: 50%; width: min(76%, 160px); height: 18px; transform: translate(-50%, -50%); + background: linear-gradient(#d8dde6, #8d96a6); border: 1px solid #3a4456; border-radius: 3px; +} +.tbar-widget .tbar:focus-visible { outline: 2px solid var(--pvw); } +.tbar-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.tbar-buttons button { + font: inherit; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #fff; + min-height: 40px; border-radius: 8px; border: 1px solid var(--line); cursor: pointer; +} +.tbar-buttons .cut { background: #3a1518; border-color: #6a2a30; } +.tbar-buttons .auto { background: #15243a; border-color: #2a4a6a; } diff --git a/gui/src/widget.tsx b/gui/src/widget.tsx index 3d5c8f0..f3f4f3e 100644 --- a/gui/src/widget.tsx +++ b/gui/src/widget.tsx @@ -8,7 +8,8 @@ // /widget/snapshots[?me=][&all=false][&theme=...] recall buttons for one ME's snapshots and // (unless all=false) those of all MEs; /widget/multiview[?me=][&pvw=true][&theme=...] a wall of // the mixer's pictures over WebRTC (each ME's Program, the sources) with labels and the tally of -// ME n (default 1). It runs on flowxer-gui's own +// ME n (default 1); /widget/tbar[?me=][&buttons=false][&theme=...] ME n's T-bar lever (it +// stays at the end a transition took it to) with Cut and Auto. It runs on flowxer-gui's own // origin (its /api); only the origins in WIDGET_FRAME_ANCESTORS may frame it. It posts // {type: "widget-ready"} and {type: "widget-size", w, h} to the page that frames it. import React from "react"; @@ -19,6 +20,7 @@ import { DmeWidget } from "./components/DmeWidget"; import { KeysWidget } from "./components/KeysWidget"; import { MultiviewWidget } from "./components/MultiviewWidget"; import { SnapshotsWidget } from "./components/SnapshotsWidget"; +import { TbarWidget } from "./components/TbarWidget"; import "./widget.css"; const params = new URLSearchParams(location.search); @@ -40,6 +42,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render( ) : widget.endsWith("/snapshots") ? ( + ) : widget.endsWith("/tbar") ? ( + ) : widget.endsWith("/multiview") ? ( ) : widget.endsWith("/dme") ? ( diff --git a/src/flowxer/api/routes.py b/src/flowxer/api/routes.py index c49155b..b1e2827 100644 --- a/src/flowxer/api/routes.py +++ b/src/flowxer/api/routes.py @@ -240,6 +240,17 @@ def widgets(mixer: VisionMixer = Depends(get_mixer)) -> list[dict]: "min_size": {"w": 240, "h": 120}, "version": __version__, }, + { + "id": "tbar", + "title": "FlowXer T-bar", + "params": { + "type": "object", + "properties": {"me": {**me, "default": 1}, "buttons": {"type": "boolean", "default": True, "title": "Cut and Auto"}}, + "required": [], + }, + "min_size": {"w": 120, "h": 240}, + "version": __version__, + }, { "id": "multiview", "title": "FlowXer multiview", @@ -483,9 +494,14 @@ def mixer_auto(payload: AutoRequest | None = None, mixer: VisionMixer = Depends( ) def mixer_position(payload: PositionRequest, mixer: VisionMixer = Depends(get_mixer)) -> MixerCommandResponse: try: - body = mixer.set_position(payload.panel_id, payload.position) + if payload.position is not None: + body, ignored = mixer.set_position(payload.panel_id, payload.position), None + else: + body, ignored = mixer.set_lever(payload.panel_id, payload.lever, payload.from_end, payload.source) except MixerError as exc: raise _http(exc) + if ignored: + return MixerCommandResponse(status="ignored", reason=ignored, mixer=body) return MixerCommandResponse(status="position", mixer=body) @@ -649,6 +665,7 @@ def console(mixer: VisionMixer = Depends(get_mixer)) -> ConsoleState: panels=mixer.panels, keyers=keyers, on_air=mixer.program_visibility(keyers), + tbar=mixer.tbar_state(), stinger_slots=mixer.stinger_slots, mixer=mixer_status, resources=ResourceInfo.model_validate(collect_resources(mixer)), diff --git a/src/flowxer/api/schemas.py b/src/flowxer/api/schemas.py index e3268ad..f48097c 100644 --- a/src/flowxer/api/schemas.py +++ b/src/flowxer/api/schemas.py @@ -328,9 +328,48 @@ class AutoRequest(BaseModel): class PositionRequest(BaseModel): panel_id: str = Field(default="me-1", description="Mixer panel (ME)") - position: float = Field( - ..., ge=0.0, le=1.0, description="T-bar: off 0 starts the ME's transition, 1 completes it, 0 ends it without the swap" + position: float | None = Field( + default=None, ge=0.0, le=1.0, description="T-bar: off 0 starts the ME's transition, 1 completes it, 0 ends it without the swap" ) + lever: float | None = Field( + default=None, + ge=0.0, + le=1.0, + description=( + "The T-bar lever where it physically is (0 top .. 1 bottom), instead of `position`: the mixer " + "makes it the transition's progress from the end the lever rests at (the panel's tbar_end), " + "so a lever at the bottom makes the next transition by moving up. Within 2 % of its resting " + "end it starts nothing (no flicker at the end of travel)." + ), + ) + + from_end: str | None = Field( + default=None, + alias="from", + pattern="^(top|bottom)$", + description=( + "With `lever`: the end the drag began at. A drag from an end that is no longer the resting " + "end (another console's Auto or Cut flipped it) is ignored. Without it the lever counts again " + "only after it came back to the resting end after a flip (pickup, for hardware panels)." + ), + ) + + source: str = Field( + default="", + max_length=64, + description=( + "With `lever` and without `from`: which hardware lever this is (each picks up on its own). " + "Without it all hardware levers of an ME share one pickup: one per ME, or give each its own." + ), + ) + + model_config = {"populate_by_name": True} + + @model_validator(mode="after") + def _one_of(self) -> "PositionRequest": + if (self.position is None) == (self.lever is None): + raise ValueError("give either position or lever") + return self class PlayerCueRequest(BaseModel): @@ -474,6 +513,9 @@ class HealthResponse(BaseModel): class MixerCommandResponse(BaseModel): status: str mixer: MixerStatus + reason: str | None = Field( + default=None, description="With status ignored (a T-bar lever value): stale_from or pickup (POST /mixer/position)" + ) class MixerPanel(BaseModel): @@ -485,6 +527,15 @@ class MixerPanel(BaseModel): last_transition: str = "cut" transition: PanelTransition = Field(default_factory=PanelTransition) ftb: bool = Field(default=False, description="Fade to Black: the ME's whole output (DSKs and sound too) is black") + tbar_end: str = Field( + default="top", + pattern="^(top|bottom)$", + description=( + "Where the T-bar lever rests (a real lever, ping-pong): top or bottom. Each transition the " + "T-bar or an Auto completes (and a Cut of a held one) moves it to the other end; the next " + "transition is made by moving the lever back." + ), + ) class KeyerStage(str, Enum): @@ -1058,6 +1109,10 @@ class ConsoleState(BaseModel): inputs: list[LogicalInput] panels: list[MixerPanel] keyers: list[Keyer] + tbar: dict[str, dict[str, Any]] = Field( + default_factory=dict, + description="Per ME the T-bar: end (where the lever rests: top or bottom), position (the progress of a transition it holds, 0: none) and lever (where it is, 0 top .. 1 bottom)", + ) on_air: dict[str, list[str]] = Field( default_factory=dict, description=( diff --git a/src/flowxer/engine/mixer.py b/src/flowxer/engine/mixer.py index bc6e980..5f7059b 100644 --- a/src/flowxer/engine/mixer.py +++ b/src/flowxer/engine/mixer.py @@ -313,6 +313,9 @@ def _preset_boxes(name: DmePreset, width: int, height: int, current: dict[str, A return [({"x": 0.5, "y": 0.5, "size": 1.0, "crop": _NO_CROP}, _box_state(current))] +# The T-bar lever starts nothing within this much of the end it rests at (end-of-travel hysteresis). +TBAR_DEADBAND = 0.02 + # A mix's or a key fade's end is published this long after it (s), so it has ended for sure. TALLY_END_MARGIN_S = 0.05 @@ -390,6 +393,10 @@ def __init__(self, settings: Settings) -> None: # Panel id -> (position, outgoing, incoming) of a transition the T-bar holds, and the keyers # tied to it (on air while it is held; they change state when it completes). self._positions: dict[str, tuple[float, str | None, str]] = {} + # (ME, source) of hardware levers (without from_end) that came to the resting end since it + # last changed, the engine started or a state was loaded: the others are ignored until they + # do (pickup). Empty at start: every hardware lever picks up first. + self._lever_picked: set[tuple[str, str]] = set() self._held_ties: dict[str, list[str]] = {} # Stinger player -> the stinger it holds (its mezzanine), and the monotonic time until which # it plays one; the last stinger taken (for the status). @@ -1753,6 +1760,7 @@ def _on_engine_ready(self, client: EngineClient) -> None: self._mixes.pop(panel_id, None) self._positions.clear() self._held_ties.clear() + self._arm_lever_pickup() for item in self.list_inputs(): if item.kind == InputKind.mxl_live: self._push_route(item, client) @@ -2153,6 +2161,13 @@ def auto(self, panel_id: str = "me-1", frames: int | None = None) -> MixerStatus buses swap. A stinger plays the ME's stinger slot (or the slot by direction) over its own length.""" panel = self._ensure_running_panel(panel_id) + status = self._auto(panel, frames) + # The virtual lever goes to its other end with the Auto (it runs there at the rate); only + # once the Auto has started (a failure raised above). + self._flip_tbar(panel) + return status + + def _auto(self, panel: MixerPanel, frames: int | None) -> MixerStatus: held = self._positions.pop(panel.id, None) if held is not None: # From the held T-bar position: the engine runs the rest at the rate (with the keyers @@ -2236,7 +2251,9 @@ def set_position(self, panel_id: str = "me-1", position: float = 0.0) -> MixerSt self._engine_call("POST", f"/me/{self._panel_number(panel)}/position", {"t": position}) _position, outgoing, incoming = held if position >= 1: - del self._positions[panel.id] + # (Also straight from rest to the end: a whole transition in one move.) + self._positions.pop(panel.id, None) + self._flip_tbar(panel) swap = panel.preview_input_id == incoming self._end_held_ties(panel) self._put_on_program(panel, incoming, panel.transition.type, flip_flop=swap, frames=1, send=False) @@ -2250,6 +2267,76 @@ def set_position(self, panel_id: str = "me-1", position: float = 0.0) -> MixerSt self._publish_tally() return self.status() + def set_lever( + self, panel_id: str = "me-1", lever: float = 0.0, from_end: str | None = None, source: str = "" + ) -> tuple[MixerStatus, str | None]: + """The T-bar lever where it physically is (0 top .. 1 bottom): the transition's progress + counted from the end it rests at (panel.tbar_end), so the lever works both ways + (ping-pong). Within TBAR_DEADBAND of its resting end it starts nothing; while it holds a + transition, within TBAR_DEADBAND of either end it snaps there (back without the swap, or + complete). Returns the status and, when the value was ignored, why ("stale_from", + "pickup"). + + Another console's Auto or Cut, or the lever's own completion, moves the resting end: + - `from_end` (the end the client's drag began at; the GUI sends it): a value whose + from_end is not the resting end is ignored (it would take the shot back, or move a + transition someone else holds from the other end); + - without it (a hardware lever, told apart by `source`): the lever counts only once it has + come within TBAR_DEADBAND of the resting end (pickup), at start, after the engine + (re)starts, after a state is loaded and after each change of the end; until then it is + ignored, also while another console holds a transition. + With a stinger or BKGD off (an Auto from the lever, no scrubbing) the flip makes the lever + wait for the next drag (from_end) or for its pickup, so one move fires one Auto.""" + panel = self._ensure_running_panel(panel_id) + held = panel.id in self._positions + rest = 0.0 if panel.tbar_end == "top" else 1.0 + if from_end is not None: + if from_end != panel.tbar_end: + log.debug("T-bar of %s: lever from %s ignored (rests at %s)", panel.id, from_end, panel.tbar_end) + return self.status(), "stale_from" + elif (panel.id, source) not in self._lever_picked: + if abs(lever - rest) > TBAR_DEADBAND: + log.debug("T-bar of %s: lever %.3f of %r ignored until it is at the %s", panel.id, lever, source, panel.tbar_end) + return self.status(), "pickup" + self._lever_picked.add((panel.id, source)) + position = lever if panel.tbar_end == "top" else 1.0 - lever + if held: + if position < TBAR_DEADBAND: + position = 0.0 + elif position > 1.0 - TBAR_DEADBAND: + position = 1.0 + elif position < TBAR_DEADBAND: + return self.status(), None + end = panel.tbar_end + status = self.set_position(panel.id, position) + if from_end is None and panel.tbar_end != end and abs(lever - (0.0 if panel.tbar_end == "top" else 1.0)) <= TBAR_DEADBAND: + # The hardware lever completed it itself: it is at the new resting end already. + self._lever_picked.add((panel.id, source)) + return status, None + + def _arm_lever_pickup(self, panel_id: str | None = None) -> None: + """Hardware levers (without from_end) must come to the resting end before they count: + for one ME (its end changed) or every ME (start, engine restart, a loaded state).""" + self._lever_picked = {item for item in self._lever_picked if panel_id is not None and item[0] != panel_id} + + def tbar_state(self) -> dict[str, dict[str, Any]]: + """Per ME the T-bar: the end the lever rests at, the progress of a transition it holds + (0: none) and where the lever is (0 top .. 1 bottom).""" + out: dict[str, dict[str, Any]] = {} + for panel in list(self.panels): + held = self._positions.get(panel.id) + position = held[0] if held is not None else 0.0 + lever = position if panel.tbar_end == "top" else 1.0 - position + out[panel.id] = {"end": panel.tbar_end, "position": position, "lever": lever} + return out + + def _flip_tbar(self, panel: MixerPanel) -> None: + """A transition of the T-bar (or an Auto) completed: the lever rests at the other end. A + lever that does not say where its drag began (a hardware panel) must first come to that end + (pickup) before it counts again.""" + panel.tbar_end = "bottom" if panel.tbar_end == "top" else "top" + self._arm_lever_pickup(panel.id) + def set_wipe(self, panel_id: str = "me-1", armed: bool | None = None) -> MixerStatus: """Arm (or toggle) Wipe: the next Cut plays the stinger slot by direction (FlowXer 14's meaning, kept for its clients).""" @@ -2294,7 +2381,9 @@ def _put_on_program( wait = self._grains_until(at) / self.settings.fps held = self._positions.pop(panel.id, None) if held is not None: - # A held T-bar transition ends first, as if it had run to its end (as in the engine). + # A held T-bar transition ends first, as if it had run to its end (as in the engine); + # the lever counts it as done. + self._flip_tbar(panel) _position, _outgoing, incoming = held swap = panel.preview_input_id == incoming self._end_held_ties(panel) @@ -3437,6 +3526,8 @@ def _apply_state(self, data: dict, *, checked: bool) -> None: self.settings.frame_rate_num = fmt.frame_rate_num self.settings.frame_rate_den = fmt.frame_rate_den self.workspace = workspace + # A loaded state may have the T-bar at another end: hardware levers pick up again. + self._arm_lever_pickup() self.inputs = {item.id: item for item in inputs} self.panels = panels self.keyers = keyers diff --git a/tests/test_api.py b/tests/test_api.py index 9355cb7..00413f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -108,8 +108,11 @@ def test_openapi_documents_the_mixer(client: TestClient) -> None: def test_widgets_list_the_bus_widget(client: TestClient, mixer: VisionMixer) -> None: widgets = client.get("/api/v1/widgets").json() - assert [w["id"] for w in widgets] == ["bus", "keys", "dme", "audio", "snapshots", "multiview"] - multiview = widgets[5] + assert [w["id"] for w in widgets] == ["bus", "keys", "dme", "audio", "snapshots", "tbar", "multiview"] + tbar = widgets[5] + assert tbar["params"]["required"] == [] and tbar["min_size"] == {"w": 120, "h": 240} + assert tbar["params"]["properties"]["buttons"]["default"] is True and tbar["params"]["properties"]["me"]["default"] == 1 + multiview = widgets[6] assert multiview["params"]["required"] == [] and multiview["min_size"] == {"w": 480, "h": 270} assert multiview["params"]["properties"]["pvw"]["default"] is False and multiview["params"]["properties"]["me"]["default"] == 1 snapshots = widgets[4] diff --git a/tests/test_tbar.py b/tests/test_tbar.py new file mode 100644 index 0000000..293ac7d --- /dev/null +++ b/tests/test_tbar.py @@ -0,0 +1,218 @@ +"""The T-bar as a real lever (ping-pong): `lever` is where it physically is, the mixer keeps the end it +rests at (panel.tbar_end) and flips it when the T-bar, an Auto or a Cut of a held transition +completes; `position` keeps its old meaning (the transition's progress).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +import pytest + +from flowxer.api.schemas import MixerStartRequest, PanelTransitionUpdate +from flowxer.engine.mixer import MixerError +from flowxer.engine.mixer import VisionMixer + + +def _start(mixer: VisionMixer) -> None: + mixer.start(MixerStartRequest(program_input_id="cam-1", preview_input_id="cam-2")) + + +def _bus(mixer: VisionMixer) -> tuple[str | None, str | None]: + panel = mixer.get_panel("me-1") + return panel.program_input_id, panel.preview_input_id + + +def test_a_drag_down_completes_and_the_next_one_goes_up(mixer: VisionMixer) -> None: + _start(mixer) + assert mixer.tbar_state()["me-1"] == {"end": "top", "position": 0.0, "lever": 0.0} + mixer.set_lever("me-1", 0.0) # a hardware lever picks up at the resting end first + mixer.set_lever("me-1", 0.4) + assert mixer.tbar_state()["me-1"] == {"end": "top", "position": 0.4, "lever": 0.4} + mixer.set_lever("me-1", 1.0) + # Complete: the buses swapped, the lever rests at the bottom (no jump back to the top). + assert _bus(mixer) == ("cam-2", "cam-1") + assert mixer.tbar_state()["me-1"] == {"end": "bottom", "position": 0.0, "lever": 1.0} + # Wiggling at the bottom (end-of-travel) starts nothing. + mixer.set_lever("me-1", 0.99) + assert mixer.tbar_state()["me-1"]["position"] == 0.0 and _bus(mixer) == ("cam-2", "cam-1") + # The reverse drag: up from the bottom makes the next transition. + mixer.set_lever("me-1", 0.7) + assert mixer.tbar_state()["me-1"] == {"end": "bottom", "position": 0.30000000000000004, "lever": 0.7} + mixer.set_lever("me-1", 0.0) + assert _bus(mixer) == ("cam-1", "cam-2") + assert mixer.tbar_state()["me-1"] == {"end": "top", "position": 0.0, "lever": 0.0} + + +def test_back_to_the_start_ends_it_without_the_swap(mixer: VisionMixer) -> None: + _start(mixer) + mixer.set_lever("me-1", 0.5) + mixer.set_lever("me-1", 0.0) + assert _bus(mixer) == ("cam-1", "cam-2") and mixer.tbar_state()["me-1"]["end"] == "top" + + +def test_auto_and_cut_leave_the_lever_at_the_right_end(mixer: VisionMixer) -> None: + _start(mixer) + mixer.auto("me-1") + assert mixer.get_panel("me-1").tbar_end == "bottom" + # From a held position, Auto runs the rest: the lever ends at the far end. + mixer.set_lever("me-1", 0.6, "bottom") # from the bottom: 40 % of the next transition + assert mixer.tbar_state()["me-1"]["position"] == 0.4 + mixer.auto("me-1") + assert mixer.tbar_state()["me-1"] == {"end": "top", "position": 0.0, "lever": 0.0} + # A Cut at rest leaves the lever where it is; a Cut of a held transition completes it. + mixer.cut("me-1") + assert mixer.get_panel("me-1").tbar_end == "top" + mixer.set_lever("me-1", 0.3, "top") + mixer.cut("me-1") + assert mixer.tbar_state()["me-1"] == {"end": "bottom", "position": 0.0, "lever": 1.0} + + +def test_the_api_takes_lever_or_position_and_the_console_shows_the_tbar(client: TestClient, mixer: VisionMixer) -> None: + _start(mixer) + assert client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "lever": 1.0, "from": "top"}).status_code == 200 + assert client.get("/api/v1/console").json()["tbar"]["me-1"] == {"end": "bottom", "position": 0.0, "lever": 1.0} + # `position` keeps its meaning (the progress, whatever end the lever rests at). + assert client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "position": 0.25}).status_code == 200 + assert client.get("/api/v1/console").json()["tbar"]["me-1"] == {"end": "bottom", "position": 0.25, "lever": 0.75} + assert client.post("/api/v1/mixer/position", json={"panel_id": "me-1"}).status_code == 422 + assert client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "position": 0.5, "lever": 0.5}).status_code == 422 + # The resting end is part of the saved state. + client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "position": 1.0}) + assert client.get("/api/v1/config/export").json()["panels"][0]["tbar_end"] == "top" + + +def test_a_drag_from_an_end_that_flipped_under_it_is_ignored(mixer: VisionMixer, client: TestClient) -> None: + # Console A holds the lever at 0.4 (from the top); console B's Auto completes it. + _start(mixer) + mixer.set_lever("me-1", 0.4, "top") + mixer.auto("me-1") + assert _bus(mixer) == ("cam-2", "cam-1") and mixer.get_panel("me-1").tbar_end == "bottom" + # A drags on: counted from the new end it would start a transition back; it is ignored. + for lever in (0.5, 0.7, 1.0): + mixer.set_lever("me-1", lever, "top") + assert _bus(mixer) == ("cam-2", "cam-1") and mixer.tbar_state()["me-1"]["position"] == 0.0 + # The next drag from the new end works. + mixer.set_lever("me-1", 0.5, "bottom") + assert mixer.tbar_state()["me-1"]["position"] == 0.5 + # Through the API the same, with "from". + mixer.auto("me-1") + assert client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "lever": 0.6, "from": "bottom"}).status_code == 200 + assert mixer.tbar_state()["me-1"]["position"] == 0.0 + + +def test_a_hardware_lever_picks_up_after_another_console_flipped_the_end(mixer: VisionMixer) -> None: + _start(mixer) + # The hardware lever (no from) rests at the top; a console's Auto flips the end to the bottom. + mixer.set_lever("me-1", 0.0) + mixer.auto("me-1") + # The hardware lever moves: ignored until it has come down to the new resting end ... + for lever in (0.1, 0.5, 0.9): + mixer.set_lever("me-1", lever) + assert mixer.tbar_state()["me-1"]["position"] == 0.0 and _bus(mixer) == ("cam-2", "cam-1") + mixer.set_lever("me-1", 0.99) # picked up at the bottom + # ... then it makes the next transition going up. + mixer.set_lever("me-1", 0.6) + assert mixer.tbar_state()["me-1"]["position"] == 0.4 + mixer.set_lever("me-1", 0.0) + assert _bus(mixer) == ("cam-1", "cam-2") and mixer.get_panel("me-1").tbar_end == "top" + # Its own completion leaves it at the new end: the next move up counts at once (no pickup). + mixer.set_lever("me-1", 0.3) + assert mixer.tbar_state()["me-1"]["position"] == 0.3 + + +def test_keys_only_fires_one_auto_per_drag(mixer: VisionMixer) -> None: + _start(mixer) + mixer.update_transition("me-1", PanelTransitionUpdate(ties=["me-1-key-1"], background=False)) + key = lambda: mixer.get_keyer("me-1-key-1").enabled + # A GUI drag from the top: the first move fires the keyer's Auto, the rest of the drag nothing. + for lever in (0.05, 0.2, 0.6, 1.0): + mixer.set_lever("me-1", lever, "top") + assert key() is True and mixer.get_panel("me-1").tbar_end == "bottom" + # The next drag (from the bottom) takes it off again: once. + for lever in (0.9, 0.5, 0.0): + mixer.set_lever("me-1", lever, "bottom") + assert key() is False and mixer.get_panel("me-1").tbar_end == "top" + # A hardware lever (picked up at the top): one Auto, then nothing until it has come to the + # other end (pickup). + for lever in (0.0, 0.05, 0.3, 0.7): + mixer.set_lever("me-1", lever) + assert key() is True + for lever in (0.99, 0.6): + mixer.set_lever("me-1", lever) + assert key() is False + + +def test_a_failed_auto_does_not_move_the_lever(mixer: VisionMixer) -> None: + _start(mixer) + mixer.get_panel("me-1").preview_input_id = None + with pytest.raises(MixerError, match="Preview"): + mixer.auto("me-1") + assert mixer.get_panel("me-1").tbar_end == "top" + + +def test_a_held_lever_snaps_at_both_ends(mixer: VisionMixer) -> None: + _start(mixer) + mixer.set_lever("me-1", 0.5, "top") + mixer.set_lever("me-1", 0.01, "top") # within 2 % of the start: back, without the swap + assert mixer.tbar_state()["me-1"]["position"] == 0.0 and _bus(mixer) == ("cam-1", "cam-2") + mixer.set_lever("me-1", 0.5, "top") + mixer.set_lever("me-1", 0.995, "top") # within 2 % of the end: complete + assert _bus(mixer) == ("cam-2", "cam-1") and mixer.get_panel("me-1").tbar_end == "bottom" + + +def test_position_one_from_rest_completes_the_transition(client: TestClient, mixer: VisionMixer) -> None: + _start(mixer) + response = client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "position": 1.0}) + assert response.status_code == 200 and response.json()["status"] == "position" + assert _bus(mixer) == ("cam-2", "cam-1") and mixer.get_panel("me-1").tbar_end == "bottom" + + +def test_a_hardware_lever_that_has_not_picked_up_cannot_complete_a_held_transition(mixer: VisionMixer) -> None: + _start(mixer) + mixer.auto("me-1") # the lever rests at the bottom + mixer.set_lever("me-1", 0.6, "bottom") # a console holds the next transition + # A hardware lever (no from) somewhere near the top jitters: ignored, also while held. + status, ignored = mixer.set_lever("me-1", 0.01) + assert ignored == "pickup" and mixer.tbar_state()["me-1"]["position"] == 0.4 and _bus(mixer) == ("cam-2", "cam-1") + + +def test_hardware_levers_pick_up_after_start_restart_and_a_loaded_state(mixer: VisionMixer, settings) -> None: + _start(mixer) + mixer.auto("me-1") # rests at the bottom; saved + mixer.persist() + again = VisionMixer(settings) + again.start(MixerStartRequest()) + assert again.get_panel("me-1").tbar_end == "bottom" + # A hardware panel connecting with its lever at the top: nothing taken until it is at the bottom. + assert again.set_lever("me-1", 0.0)[1] == "pickup" + assert again.tbar_state()["me-1"]["position"] == 0.0 + assert again.set_lever("me-1", 1.0)[1] is None # picked up + # The engine restarts (or a state is loaded): picked up again first. + again._arm_lever_pickup() + assert again.set_lever("me-1", 0.5)[1] == "pickup" + + +def test_a_stale_from_cannot_move_a_transition_held_from_the_other_end(mixer: VisionMixer) -> None: + _start(mixer) + mixer.auto("me-1") + mixer.set_lever("me-1", 0.6, "bottom") + status, ignored = mixer.set_lever("me-1", 0.99, "top") + assert ignored == "stale_from" and mixer.tbar_state()["me-1"]["position"] == 0.4 + + +def test_two_hardware_levers_are_told_apart_by_source(mixer: VisionMixer) -> None: + _start(mixer) + assert mixer.set_lever("me-1", 0.0, source="panel-a")[1] is None # A picks up at the top + assert mixer.set_lever("me-1", 0.3, source="panel-b")[1] == "pickup" # B has not + assert mixer.set_lever("me-1", 0.3, source="panel-a")[1] is None + assert mixer.tbar_state()["me-1"]["position"] == 0.3 + + +def test_ignored_lever_values_say_why(client: TestClient, mixer: VisionMixer) -> None: + _start(mixer) + response = client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "lever": 0.5}) + assert response.status_code == 200 and response.json()["status"] == "ignored" and response.json()["reason"] == "pickup" + response = client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "lever": 0.5, "from": "bottom"}) + assert response.json()["status"] == "ignored" and response.json()["reason"] == "stale_from" + response = client.post("/api/v1/mixer/position", json={"panel_id": "me-1", "lever": 0.5, "from": "top"}) + assert response.json()["status"] == "position" and response.json()["reason"] is None