Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions docs/clients.html
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ <h4>The native desktop app — the release form <em>(as built; PRs #195, #204)</
The console's <strong>release form</strong> is a <strong>Tauri 2 desktop app</strong>
(<code>src-tauri/</code>) that <strong>embeds the Director in-process</strong>. On launch it
runs the <em>same</em> <code>gridfpv_app::director::run_director</code> the hosted
<code>gridfpv</code> binary runs, on a tokio task bound to a <strong>loopback ephemeral
port</strong> (<code>127.0.0.1:0</code>), then opens a native window pointed at
<code>gridfpv</code> binary runs, on a tokio task bound to a <strong>fixed loopback
port</strong> (<code>127.0.0.1:8080</code>, <code>GRIDFPV_ADDR</code> to override; it was
an ephemeral <code>127.0.0.1:0</code> until 2026-10-12, which broke every OBS browser source
holding an overlay URL on each restart — an ephemeral port is now only the fallback when the
fixed one is taken), then opens a native window pointed at
<code>http://127.0.0.1:&lt;port&gt;/</code>. The window loads the Director's own HTTP origin
directly, so the SPA's same-origin API (<code>/snapshot/…</code>, <code>/control/…</code>) and
the realtime WebSocket work with no cross-origin shims. Because it is
Expand Down
5 changes: 4 additions & 1 deletion docs/decisions.html
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,10 @@ <h2>D9 · The native desktop app embeds the Director, ships portable-only, store
<dd>The release form is a <strong>Tauri 2 desktop app</strong> (<code>src-tauri/</code>; PRs
#195, #204) that <strong>embeds the Director in-process</strong>. On launch it runs the
<em>same</em> <code>gridfpv_app::director::run_director</code> the hosted binary runs, on a
tokio task bound to a <strong>loopback ephemeral port</strong> (<code>127.0.0.1:0</code>), and
tokio task bound to a <strong>fixed loopback port</strong> (<code>127.0.0.1:8080</code>;
originally an ephemeral <code>127.0.0.1:0</code>, changed 2026-10-12 because OBS browser
sources hold the overlay URL and a port that moved on every restart broke them — ephemeral is
now the logged fallback when the fixed port is taken), and
opens a native window pointed at <code>http://127.0.0.1:&lt;port&gt;/</code>. The
<strong>frontend is baked into the binary</strong> via the <code>embed-assets</code> cargo
feature, so the executable is a single self-contained file. Distribution is
Expand Down
14 changes: 14 additions & 0 deletions docs/roadmap.html
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ <h2>2. The phases</h2>
<code>GET /about</code> + the console build-stamp; <code>CONTRACT_VERSION</code> stays
the independent wire integer).
</p>
<p>
<strong>Status (2026-10-12): v0.4.0 is released</strong> (the non-prerelease
<a href="https://github.com/GridFPV/gridfpv/releases/tag/v0.4.0">v0.4.0</a>, 2026-09-07),
and the first work after it is the first piece of the
<a href="streaming.html">streaming</a> design: the <strong>race overlay</strong>
(<code>#/overlay/race</code>), an OBS browser source that follows the active event — any
event — and puts the clock, the heat and every pilot's laps in the video, reached from the
event workspace's new <strong>Overlays</strong> tab. It was first built as a "RaceGOW"
solo-run preset with its own screens; that mode was dropped (an ordinary event with one
pilot and a best-3-consecutive time-trial round is the better shape, and needs nothing new)
and the overlay kept and generalised. The desktop app also moved from an ephemeral port to
a <strong>fixed</strong> one (<code>127.0.0.1:8080</code>), because a browser source holds
the Director's address.
</p>
</div>

<h3>v0.1 — Walking skeleton</h3>
Expand Down
22 changes: 22 additions & 0 deletions docs/streaming.html
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,28 @@ <h2>5. Overlay catalogue &amp; theming</h2>
</dl>
<p>Each gets a fuller spec as it is built; the list confirms the protocol exposes what they need.</p>

<div class="callout">
<p>
<strong>As built (2026-09-26, generalised 2026-10-12): the first overlay is the race
timer, <code>#/overlay/race</code>.</strong> It is a route of the RD console SPA rather
than a separate app — the "one codebase, profiled three ways" frontend — rendered bare, on
a transparent document (<code>html[data-overlay]</code> lifts the console canvas), in the
<code>.gridfpv-overlay</code> token context. It is exactly the §2 shape: a read-only
client on the same session as the console, <em>following the program</em> (§2.1) — the
Director's active event, <strong>whatever kind of event it is</strong> — with no binding
of its own yet. It shows the race clock (a countdown when the round has a fixed end, as
the console's clocks do), the phase, the current heat's friendly name, and a row per pilot
off the served lap-list projection (laps, last, best — or the best <em>n</em> in a row when
the round scores <code>BestConsecutive</code>, plus the served round-wide best); a
one-pilot heat gets the long form with its recent laps. It began life as the RaceGOW
timer, tied to a solo-run preset; the preset was removed (an ordinary event does the job
better) and the overlay was made to follow <em>any</em> event. The console reaches it from
the event workspace's <strong>Overlays</strong> tab (<code>#/event/overlays</code>): the
catalogue, each page's URL with a contrast picker, and a live preview frame of the same
route. Nothing in §3 (broadcast control) is built.
</p>
</div>

<h3>5.1 Theming &amp; chapter branding</h3>
<div class="principle">
<p><span class="name">Theming is presentation, never data.</span> Overlays render from
Expand Down
29 changes: 26 additions & 3 deletions frontend/apps/rd-console/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
import Marshaling from './screens/Marshaling.svelte';
import Results from './screens/Results.svelte';
import EventAudit from './screens/EventAudit.svelte';
import EventOverlays from './screens/EventOverlays.svelte';
import RaceOverlay from './screens/RaceOverlay.svelte';
import { openAudit } from './lib/auditFilter.svelte.js';
import type { Timer } from '@gridfpv/types';
import {
Expand All @@ -59,8 +61,11 @@

const session = new Session();
// App-wide race-day audio: tones + lap callouts follow the live stream on EVERY page (the
// race must be audible while the RD is on rounds/marshaling/results, not just Live control).
mountRaceDayAudio(session);
// race must be audible while the RD is on rounds/marshaling/results, not just Live control) —
// except an **overlay**. An overlay is a browser source inside OBS (or the Overlays tab's
// preview frame): a second copy of the start tones and callouts coming out of the stream, a
// beat behind the console's, is exactly what a transparent timer panel must not do.
if (parseHash(location.hash).kind !== 'overlay') mountRaceDayAudio(session);

// Role seam (#55/#80): a `?role=readonly` query selects the read-only-pilot tier, which hides
// every mutating control (the Marshaling actions, etc.). The Director is the enforced boundary;
Expand Down Expand Up @@ -218,7 +223,8 @@
| 'live'
| 'marshaling'
| 'results'
| 'audit';
| 'audit'
| 'overlays';
const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [
{
id: 'classes-roster',
Expand Down Expand Up @@ -248,6 +254,14 @@
label: 'Timers',
key: '6',
icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z'
},
// The OBS browser-source pages (streaming.html §2): their URLs, colors and a live preview. A
// broadcast surface sits with the equipment, after Timers; key '8', the next free digit.
{
id: 'overlays',
label: 'Overlays',
key: '8',
icon: 'M3 5h18v11H3zM8 20h8M12 16v4'
}
];
// The active workspace tab is derived from the route (the hash is the source of truth, #118):
Expand Down Expand Up @@ -346,6 +360,13 @@
</div>
{/if}
</div>
{:else if route.kind === 'overlay'}
<!-- An OBS browser source: rendered bare (no shell, no chrome) on a transparent page. It follows
the Director's active event — whatever event that is — through the same session as everything
else. One overlay exists so far (`race`); the route's name picks the page. -->
<div class="gridfpv-root">
<RaceOverlay {session} theme={route.theme ?? 'dark'} />
</div>
{:else if route.kind === 'page'}
<!-- App-level routes (#118): the home hub, or one of its three pages. The view is driven by the
hash, not just `session.currentEvent` — an explicit page hash (e.g. `#/pilots`) shows that
Expand Down Expand Up @@ -453,6 +474,8 @@
/>
{:else if active === 'audit'}
<EventAudit {session} />
{:else if active === 'overlays'}
<EventOverlays {session} />
{/if}
</main>
</div>
Expand Down
9 changes: 9 additions & 0 deletions frontend/apps/rd-console/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ body::before {
);
}

/* An OBS browser-source overlay (`#/overlay/<name>`) keys on a transparent page: the shell sets
* `data-overlay` on <html> while one is mounted, and the dark canvas + its wash step aside. */
html[data-overlay] body {
background: transparent;
}
html[data-overlay] body::before {
display: none;
}

:focus-visible {
outline: none;
}
Expand Down
154 changes: 154 additions & 0 deletions frontend/apps/rd-console/src/lib/overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Overlays — the OBS browser-source pages (`#/overlay/<name>`), and the pure helpers they share.
*
* An overlay is a **read-only protocol client** (streaming.html §2): a route of the RD console SPA
* rendered bare on a transparent document, following the Director's active event — *whatever* that
* event is. It is deliberately not tied to a kind of event, a preset or a format: a club night's
* heat of six, a one-pilot time trial at home, and an open-practice session all put the same page
* in the video, and the page shows what the current heat has. Nothing here writes; closing OBS, or
* the browser source crashing, cannot touch a race.
*
* The console reaches these pages from the event workspace's **Overlays** tab
* (`screens/EventOverlays.svelte`), which lists the catalogue below with each page's URL, its
* contrast picker and a live preview. The pages themselves live in `screens/*Overlay.svelte`.
*/

import type {
CompetitorRef,
EventMeta,
HeatId,
HeatSummary,
LapList,
Lap,
RoundDef,
RoundStanding,
WinCondition
} from '@gridfpv/types';
import { formatHash, type OverlayName, type OverlayTheme } from './route.js';

/** One entry of the overlay catalogue: what the Overlays tab lists and links. */
export interface OverlayInfo {
name: OverlayName;
/** The display title — shown in the Overlays tab and as the page's accessible label. */
title: string;
/** One or two sentences on what the page shows, for the tab. */
description: string;
}

/** The overlays the console serves, in the order the Overlays tab lists them. */
export const OVERLAY_CATALOG: readonly OverlayInfo[] = [
{
name: 'race',
title: 'Race timer',
description:
'The race clock and phase, the heat name, and every pilot in the current heat with their ' +
'laps — last lap, best lap, and the best run of laps in a row when the round scores one. ' +
'A single pilot gets the full lap list.'
}
];

/**
* The OBS browser-source URL for an overlay, from the console's own location. `theme` picks the
* panel's contrast for the footage underneath — light text on a dark panel (default) or dark text
* on a light one (`#/overlay/<name>/light`, see `route.ts`).
*/
export function overlayUrl(
loc: { origin: string; pathname: string },
overlay: OverlayName,
theme: OverlayTheme = 'dark'
): string {
return `${loc.origin}${loc.pathname}${formatHash({ kind: 'overlay', overlay, theme })}`;
}

/** A consecutive-lap window: where it starts (0-based lap index) and what it sums to. */
export interface ConsecutiveWindow {
/** Index of the window's first lap in the lap list it was found in. */
start: number;
/** The window's laps, in order (µs each). */
laps: number[];
/** The window's total (µs). */
totalMicros: number;
}

/**
* The fastest `n`-consecutive-lap window of a lap sequence (durations in µs), or `undefined` with
* fewer than `n` laps. Ties go to the **earliest** window, matching the engine's tie-break by the
* completion time of the window's last lap. Pure; the overlay runs it over the *served* lap list,
* so a voided or edited lap is already reflected.
*/
export function bestConsecutive(
lapMicros: readonly number[],
n: number
): ConsecutiveWindow | undefined {
if (n <= 0 || lapMicros.length < n) return undefined;
let best: ConsecutiveWindow | undefined;
for (let start = 0; start + n <= lapMicros.length; start += 1) {
const laps = lapMicros.slice(start, start + n);
const totalMicros = laps.reduce((sum, lap) => sum + lap, 0);
if (!best || totalMicros < best.totalMicros) best = { start, laps, totalMicros };
}
return best;
}

/**
* The consecutive-lap window a round scores — `n` for a `BestConsecutive { n }` win condition,
* `undefined` for every other condition. The overlay shows a "best N in a row" figure only for a
* round that ranks by one; a timed heat or a first-to-N race shows laps and best lap instead.
*/
export function consecutiveWindow(win: WinCondition | undefined): number | undefined {
if (typeof win === 'object' && win !== null && 'BestConsecutive' in win) {
return win.BestConsecutive.n;
}
return undefined;
}

/** The round a heat belongs to, by the heat's round tag — `undefined` for an untagged heat. */
export function heatRound(
event: EventMeta | undefined,
heats: readonly HeatSummary[],
heat: HeatId | undefined
): RoundDef | undefined {
if (!event || !heat) return undefined;
const summary = heats.find((h) => h.heat === heat);
if (!summary?.round) return undefined;
return event.rounds?.find((r) => r.id === summary.round);
}

/** The served laps of one competitor off a heat's lap list, in order (`[]` when it has none). */
export function competitorLaps(list: LapList | undefined, ref: CompetitorRef): Lap[] {
return list?.competitors.find((c) => c.competitor.competitor === ref)?.laps ?? [];
}

/**
* The served round-wide figure (µs) for one competitor off the round standings: the best
* consecutive window of a `BestConsecutive` round, or the best lap of a `BestLap` round.
* `undefined` before one exists, and for a `MostLaps` round (laps are not a time).
*/
export function servedRoundBestMicros(
standings: readonly RoundStanding[],
ref: CompetitorRef
): number | undefined {
const row = standings.find((s) => s.competitor === ref);
if (!row) return undefined;
const m = row.metric;
if (typeof m !== 'object' || m === null) return undefined;
if ('BestConsecutive' in m) return m.BestConsecutive.micros ?? undefined;
if ('BestLap' in m) return m.BestLap.micros ?? undefined;
return undefined;
}

/**
* The order the overlay lists a heat's pilots in: the live **running order** when the stream
* carries one, else the heat's active pilots, else its scheduled lineup, else whoever the lap list
* names. Refs appearing in a later source but not an earlier one are appended, so a pilot never
* vanishes from the frame because one projection lags another.
*/
export function overlayOrder(
sources: readonly (readonly CompetitorRef[] | undefined)[]
): CompetitorRef[] {
const out: CompetitorRef[] = [];
for (const list of sources) {
for (const ref of list ?? []) if (!out.includes(ref)) out.push(ref);
}
return out;
}
Loading
Loading