Skip to content
Merged
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
5 changes: 3 additions & 2 deletions fleet/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ The page for `docs/fleet/ARCHITECTURE.md`: one image tiled across every GPU, fcv
left and EC2 on the right, each side as wide as the GPUs it has live, live from each tile's own renderer. The render is the
page: the image fills the screen (contain-fit, centred), with a row of controls over its top and a strip over its
bottom (the one button, "Restart all N GPUs", the last race in one line, the next scheduled restart, and what draws the
image: resolution, GPUs, frame rate). The controls fade when the pointer rests. While a race runs, its two bars,
fcvm against EC2, sit over the bottom of the image. Each tile asks its renderer for at most `MAX_TILE_PX` (0.5 M)
image: resolution, GPUs, frame rate). The controls fade when the pointer rests. A race is drawn on the picture: every
dark tile counts, each half has one large clock (fcvm freezes when its last tile is back, EC2 keeps counting), each tile
shows its own time as it relights, and a card gives the result for 8 s before the strip's line takes it over. Each tile asks its renderer for at most `MAX_TILE_PX` (0.5 M)
pixels, so one viewer on a large screen cannot push a GPU under one sample per pixel. Everything else
(the camera's next stop, words, lights, how the streams arrive, stream stats, the counters and recent actions) is in the drawer, where the sender of a word also picks how it is drawn:
Lockup (type above the clock), Skywriting (smoke in the sky) or Landmark (big letters
Expand Down
116 changes: 90 additions & 26 deletions fleet/web/app/_wall/Overlays.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

// Everything drawn over the frames: the line between the halves, each half's
// tag, dark tiles' glyphs and phases,
// tag, dark tiles' clocks, glyphs and phases, the race's clocks and result,
// the wake's progress,
// the stats switch's per-tile lines, the seams and the hovered tile's outline
// and label, banners and notices.

import type { CSSProperties } from "react";
import type { Glyph, TileView, WallView } from "../../core/index.ts";
import type { Glyph, HalfClock, SleepView, TileView, WallView } from "../../core/index.ts";

function accent(color: string): CSSProperties {
return { ["--accent" as string]: color };
Expand Down Expand Up @@ -66,14 +67,19 @@ export function GlyphMark({ g, size }: { g: Glyph; size: number }) {
function DarkTile({ t }: { t: TileView }) {
const b = t.box!;
const big = b.h >= 64 && b.w >= 110;
const size = Math.max(14, Math.min(34, b.h / 3.2));
const size = Math.max(14, Math.min(34, b.h / (t.clock ? 5 : 3.2)));
return (
<div
className={`dark-tile${t.hatch ? " hatched" : ""} ${t.glyph.kind}`}
data-testid={`dark-${t.id}`}
data-kind={t.glyph.kind}
style={{ ...accent(t.accent), left: b.x, top: b.y, width: b.w, height: b.h }}
>
{t.clock && big ? (
<span className="clock" data-testid={`clock-${t.id}`} style={{ fontSize: `clamp(18px, ${Math.round(b.h / 5)}px, 56px)` }}>
{t.clock}
</span>
) : null}
<GlyphMark g={t.glyph} size={size} />
{big ? (
<span className="phase plate" data-testid={`phase-${t.id}`}>
Expand All @@ -85,7 +91,64 @@ function DarkTile({ t }: { t: TileView }) {
);
}

export function Overlays({ view }: { view: WallView; phone: boolean }) {
/** One half's big clock: on the image (absolute), or in the phone's strip under it. */
export function HalfClockView({ c, style }: { c: HalfClock; style?: CSSProperties }) {
return (
<div className={`half-clock ${c.half} ${c.state}${style ? " on-image" : ""}`} data-testid={`race-clock-${c.half}`} data-state={c.state} style={style}>
<span className="value" data-testid={`race-value-${c.half}`}>
{c.value}
</span>
<span className="sub" data-testid={`race-sub-${c.half}`}>
{c.sub}
</span>
{c.hatch ? (
<span className="hatch-line" data-testid={`race-hatch-${c.half}`}>
{c.hatch}
</span>
) : null}
{c.steps.length > 0 ? (
<span className="steps" data-testid={`race-steps-${c.half}`}>
{c.steps.map((st, i) => (
<span key={st.label} className={st.state}>
{i > 0 ? <span className="arrow" aria-hidden="true">→</span> : null}
{st.label}
</span>
))}
</span>
) : null}
</div>
);
}

/** Asleep or waking, in one lower third: on the image, or under it on an upright phone. */
export function WakeCard({ sleep, style }: { sleep: SleepView; style?: CSSProperties }) {
return (
<div className={`lower-third ${sleep.state}${style ? " on-image" : ""}`} data-testid="sleep-card" style={style} role="status">
<span className="title">
{sleep.title}
{sleep.clock ? (
<span className="clock" data-testid="wake-clock">
{sleep.clock}
</span>
) : null}
</span>
{sleep.state === "asleep" ? <span className="line">{sleep.line}</span> : null}
{sleep.steps.length > 0 ? (
<ol className="dots" data-testid="wake-steps">
{sleep.steps.map((st) => (
<li key={st.phase} className={st.state} data-testid={`wake-${st.phase}`} data-state={st.state}>
<span className="mark" aria-hidden="true" />
<span className="label">{st.label}</span>
{st.value ? <span className="v">{st.value}</span> : null}
</li>
))}
</ol>
) : null}
</div>
);
}

export function Overlays({ view, phone }: { view: WallView; phone: boolean }) {
const L = view.layout;
const showMosaic = L !== null && view.focus === null && view.own === null;
const img = L?.image;
Expand All @@ -107,6 +170,28 @@ export function Overlays({ view }: { view: WallView; phone: boolean }) {
</span>
) : null}
{view.tiles.map((t) => (t.dark && t.box ? <DarkTile key={t.id} t={t} /> : null))}
{view.tiles.map((t) =>
t.chip && t.box ? (
<div
key={`relit-${t.id}`}
className={`relit ${t.half}${t.chip.fresh ? " fresh" : ""}`}
style={{ left: t.box.x, top: t.box.y, width: t.box.w, height: t.box.h }}
data-testid={`relit-${t.id}`}
>
{t.box.w >= 56 && t.box.h >= 28 ? (
<span className="chip" data-testid={`chip-${t.id}`}>
{t.chip.text}
</span>
) : null}
</div>
) : null,
)}
{phone ? null : view.race?.clocks.map((c) => <HalfClockView key={c.half} c={c} style={{ left: c.x, top: c.y, ["--clock-size" as string]: `${Math.round(c.size)}px` }} />)}
{view.race?.end && !phone ? (
<div className="end-card" data-testid="end-card" style={{ left: img.x + img.w / 2, top: img.y + img.h * 0.68 }}>
{view.race.end}
</div>
) : null}
{statsOn
? view.tiles.map((t) =>
t.box ? (
Expand Down Expand Up @@ -171,28 +256,7 @@ export function Overlays({ view }: { view: WallView; phone: boolean }) {
) : null}
</>
) : null}
{view.sleep && img ? (
<div className={`sleep-card ${view.sleep.state}`} data-testid="sleep-card" style={{ right: `calc(100vw - ${img.x + img.w - 12}px)`, top: Math.max(img.y + 12, 120) }} role="status">
<span className="title">{view.sleep.title}</span>
{view.sleep.clock ? (
<span className="clock" data-testid="wake-clock">
{view.sleep.clock}
</span>
) : null}
<span className="line">{view.sleep.line}</span>
{view.sleep.steps.length > 0 ? (
<ol className="steps" data-testid="wake-steps">
{view.sleep.steps.map((st) => (
<li key={st.phase} className={st.state} data-testid={`wake-${st.phase}`} data-state={st.state}>
<span className="mark" aria-hidden="true" />
<span className="label">{st.label}</span>
<span className="v">{st.value}</span>
</li>
))}
</ol>
) : null}
</div>
) : null}
{view.sleep && img && !phone ? <WakeCard sleep={view.sleep} style={{ left: img.x + img.w / 2, top: img.y + img.h * 0.74 }} /> : null}
{view.banners.length > 0 ? (
<div className="banners" role="status">
{view.banners.map((b) => (
Expand Down
6 changes: 3 additions & 3 deletions fleet/web/app/_wall/Reel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { ReelView } from "../../core/index.ts";
/** The crossfade from the recording to the live image (wall.css uses the same). */
const FADE_MS = 700;

export function Reel({ reel, box, onDone }: { reel: ReelView; box: CSSProperties | undefined; onDone: () => void }) {
export function Reel({ reel, box, onDone, badgeTop }: { reel: ReelView; box: CSSProperties | undefined; onDone: () => void; badgeTop: number }) {
const video = useRef<HTMLVideoElement | null>(null);
const [playing, setPlaying] = useState(false);
const [failed, setFailed] = useState(false);
Expand Down Expand Up @@ -72,8 +72,8 @@ export function Reel({ reel, box, onDone }: { reel: ReelView; box: CSSProperties
</span>
) : null}
{playing && !failed && (reel.badge || reel.caption) ? (
<span className="reel-badge plate" data-testid="reel-badge">
{reel.badge}
<span className={`reel-badge${reel.leaving ? " live" : ""}`} data-testid="reel-badge" style={{ top: badgeTop }}>
<span className="mark">{reel.badge}</span>
{reel.caption ? <span className="caption-line">{reel.caption}</span> : null}
</span>
) : null}
Expand Down
59 changes: 0 additions & 59 deletions fleet/web/app/_wall/Score.tsx

This file was deleted.

40 changes: 26 additions & 14 deletions fleet/web/app/_wall/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
lightPosition,
sceneT,
serverNow,
HUD_BAND,
LIGHT_COLORS,
wallView,
type Box,
Expand All @@ -31,10 +32,9 @@ import {
import { canDecodeH264, WallClient } from "./wall-client.ts";
import { FocusPanel } from "./FocusPanel.tsx";
import { OwnGpu } from "./OwnGpu.tsx";
import { Overlays } from "./Overlays.tsx";
import { HalfClockView, Overlays, WakeCard } from "./Overlays.tsx";
import { PlayDrawer, VerifyBox } from "./PlayDrawer.tsx";
import { Reel } from "./Reel.tsx";
import { Scoreboard } from "./Score.tsx";
import type { DrawSettings } from "./worker-msgs.ts";

const localNow = () => performance.timeOrigin + performance.now();
Expand Down Expand Up @@ -593,6 +593,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
reel={view.reel}
onDone={reelDone}
box={{ left: view.plan.image.x - areas.mosaic.x, top: view.plan.image.y - areas.mosaic.y, width: view.plan.image.w, height: view.plan.image.h }}
badgeTop={phone ? 10 : Math.max(10, HUD_BAND - (view.plan.image.y - areas.mosaic.y))}
/>
) : null}
</div>
Expand Down Expand Up @@ -671,22 +672,22 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
</span>
) : null;

// While a race runs, and for a few seconds after, its two bars sit over the bottom of the image; at rest, one line says how the last one went.
const race = score && view.racePanel ? (
<section className="race-panel" aria-label="The race" data-hud="">
{next}
<Scoreboard score={score} compact={phone} />
</section>
) : null;

// While a race runs the strip says who started it (the clocks are on the image); after
// its result card, one line says how the last race went.
const racing = view.race !== null;
const info = (
<div className="info">
{!view.racePanel && view.lastRace ? (
{view.race?.label ? (
<span className="next racing" data-testid="countdown">
{view.race.label}
</span>
) : null}
{!racing && view.lastRace ? (
<span className="last-race" data-testid="last-race">
{view.lastRace}
</span>
) : null}
{!view.racePanel ? next : null}
{!racing ? next : null}
{view.spec ? (
<span className="spec" data-testid="spec">
{view.spec}
Expand All @@ -707,7 +708,19 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
</button>
</header>
{stage}
{race}
{view.race && view.race.clocks.length > 0 ? (
<div className="race-row">
{view.race.clocks.map((c) => (
<HalfClockView key={c.half} c={c} />
))}
</div>
) : null}
{view.sleep ? <WakeCard sleep={view.sleep} /> : null}
{view.race?.end ? (
<p className="end-line" data-testid="end-card">
{view.race.end}
</p>
) : null}
{info}
<p className="rotate-hint" data-testid="rotate-hint">
{view.rotateHint}
Expand Down Expand Up @@ -769,7 +782,6 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
</div>
</header>
<footer className="hud-bottom" data-hud="">
{race}
<div className="strip">
<div className="fades">{restart}</div>
{info}
Expand Down
Loading
Loading