Visual improvements

This commit is contained in:
Kalle
2026-09-26 14:09:15 +03:00
parent 7a955d68fb
commit d8b7a9ee8b
9 changed files with 564 additions and 109 deletions

View File

@@ -0,0 +1,63 @@
.feed {
display: flex;
flex-direction: column;
gap: var(--s-1);
margin: 0;
padding: 0;
list-style: none;
}
.item {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
font-size: var(--font-xs);
animation: slide-in 0.35s ease-out;
&:first-child {
background: var(--color-bg-high);
}
&:nth-child(n + 4) {
opacity: 0.6;
}
}
.icon {
display: flex;
color: var(--color-fg-accent);
}
.time {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: var(--s-1) var(--s-2);
font-size: var(--font-xs);
color: var(--color-text-high);
}
@keyframes slide-in {
from {
opacity: 0;
translate: 0 -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.item {
animation: none;
}
}

View File

@@ -0,0 +1,103 @@
/** The latest reads of a running capture as one-line rows, newest first. */
import * as R from "remeda";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import { KILL_EVENT_TYPE } from "../core/detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
import { formatPosition } from "../core/format";
import { modeLabel, stageLabel } from "../core/labels";
import type { ScannerMatch } from "../core/scanner-match";
import styles from "./EventFeed.module.css";
import { EventTypeIcon } from "./EventTypeIcon";
import type { ScanEvent } from "./session-data";
const FEED_LENGTH = 6;
interface FeedItem {
t: number;
type: string;
label: string;
}
export function EventFeed({
events,
matches,
originT,
}: {
events: ScanEvent[];
matches: ScannerMatch[];
originT: number;
}) {
const items = R.pipe(
[
...events.flatMap((event) => {
const label = eventLabel(event);
return label === null ? [] : [{ t: event.t, type: event.type, label }];
}),
// the builder's splats, not the raw feed reads: each stack change re-reads the same rows
...matches.flatMap((match) =>
(match.kills ?? []).map(
(kill): FeedItem => ({
t: kill.t,
type: KILL_EVENT_TYPE,
label: kill.name ? `Splatted ${kill.name}` : "Splat",
}),
),
),
],
R.sortBy([R.prop("t"), "desc"]),
R.take(FEED_LENGTH),
);
return (
<ol className={styles.feed}>
{items.length === 0 ? (
<li className={styles.empty}>Waiting for the first read…</li>
) : (
items.map((item) => (
<li key={`${item.type}-${item.t}`} className={styles.item}>
<span className={styles.icon}>
<EventTypeIcon type={item.type} size={14} />
</span>
<span className={styles.time}>
{formatPosition(item.t - originT)}
</span>
<span className={styles.label}>{item.label}</span>
</li>
))
)}
</ol>
);
}
function eventLabel(event: ScanEvent): string | null {
switch (event.type) {
case MAP_START_EVENT_TYPE: {
const data = event.data as MapStartData;
const what = [modeLabel(data.mode), stageLabel(data.stage)]
.filter(Boolean)
.join(" on ");
return what ? `Game started: ${what}` : "Game started";
}
case DEATH_EVENT_TYPE: {
const name = (event.data as DeathData).name;
return name ? `Splatted by ${name}` : "Splatted";
}
case SCOREBOARD_EVENT_TYPE:
return "Results screen read";
case SCOREBOARD_OWN_EVENT_TYPE:
return "Personal results read";
case SCOREBOARD_BATTLE_LOG_EVENT_TYPE:
return "Battle history read";
default:
return null;
}
}

View File

@@ -1,11 +1,13 @@
.liveHeader {
display: grid;
grid-template-columns: minmax(200px, 320px) minmax(0, 1fr);
grid-template-columns: minmax(200px, 320px) minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: "preview feed status";
gap: var(--s-4);
align-items: start;
}
.preview {
grid-area: preview;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
@@ -13,7 +15,12 @@
border: var(--border-width) solid var(--color-bg-high);
}
.feed {
grid-area: feed;
}
.status {
grid-area: status;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
@@ -45,8 +52,21 @@
color: var(--color-error);
}
@container scanner (width < 900px) {
.liveHeader {
grid-template-columns: minmax(200px, 320px) minmax(0, 1fr);
grid-template-areas:
"preview status"
"feed feed";
}
}
@container scanner (width < 600px) {
.liveHeader {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"preview"
"status"
"feed";
}
}

View File

@@ -12,6 +12,7 @@ import { modeLabel, stageLabel } from "../core/labels";
import { scannerSearchParams } from "../scanner-search-params";
import { loadEventFrame } from "../store/events";
import { useClips } from "./clips-feed";
import { EventFeed } from "./EventFeed";
import { ExportMenu } from "./ExportMenu";
import { currentSession, useFeed } from "./events-feed";
import styles from "./LiveView.module.css";
@@ -136,6 +137,13 @@ export function LiveView() {
playsInline
autoPlay
/>
<div className={styles.feed}>
<EventFeed
events={events}
matches={info.built.map((built) => built.match)}
originT={session?.originT ?? 0}
/>
</div>
<div className={styles.status}>
<div className={styles.statusLine}>
{reading && newest ? (

View File

@@ -0,0 +1,162 @@
.scanWorkers {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.headerRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2) var(--s-3);
}
.progressText {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
}
.headerEnd {
margin-inline-start: auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.progress {
width: 100%;
height: 8px;
accent-color: var(--color-fg-accent);
}
.laneGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--s-3);
}
.lane {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.laneScreen {
position: relative;
border-radius: var(--radius-box);
outline: 2px solid var(--color-bg-high);
overflow: hidden;
}
.canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.laneDone .canvas {
opacity: 0.35;
filter: grayscale(1);
}
.laneChip,
.modeChip {
position: absolute;
top: var(--s-1-5);
padding: var(--s-0-5) var(--s-2);
border-radius: var(--radius-full);
background: rgb(0 0 0 / 0.6);
color: #fff;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
}
.laneChip {
left: var(--s-1-5);
}
.modeChip {
right: var(--s-1-5);
display: inline-flex;
align-items: center;
gap: var(--s-1);
color: rgb(255 255 255 / 0.7);
&::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
&:has(svg)::before {
display: none;
}
}
.modeActive {
color: var(--color-chart-kill);
&::before {
animation: pulse 1s ease-in-out infinite;
}
}
.laneFooter {
position: absolute;
inset: auto 0 0;
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-4) var(--s-2) var(--s-1-5);
background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
}
.laneClock {
color: #fff;
font-size: var(--font-md);
font-weight: var(--weight-bold);
font-variant-numeric: tabular-nums;
}
.laneBar {
height: 4px;
border-radius: var(--radius-full);
background: rgb(255 255 255 / 0.2);
overflow: hidden;
}
.laneBarFill {
height: 100%;
background: var(--color-fg-accent);
transition: width 0.25s linear;
}
.laneMeta {
display: flex;
justify-content: space-between;
font-size: var(--font-2xs);
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
}
.laneGames {
color: var(--color-text);
font-weight: var(--weight-bold);
}
@keyframes pulse {
50% {
opacity: 0.3;
}
}
@media (prefers-reduced-motion: reduce) {
.modeActive::before,
.laneBarFill {
animation: none;
transition: none;
}
}

View File

@@ -0,0 +1,137 @@
/**
* A running VoD scan: the overall progress, then one tile per worker showing
* the frames of its slice of the file, whether it is reading gameplay or
* skimming past dead air, and how many games it has found.
*/
import clsx from "clsx";
import { Check } from "lucide-react";
import { formatPosition } from "../core/format";
import { buildScannerMatches } from "../core/match-builder";
import styles from "./ScanWorkers.module.css";
import { StatusPill } from "./SessionHeader";
import type { ScanEvent } from "./session-data";
import {
setVodLaneCanvas,
useVodScanProgress,
type VodScanLane,
} from "./vod-scan";
export function ScanWorkers({
events,
headerEnd,
children,
}: {
events: ScanEvent[];
headerEnd: React.ReactNode;
children?: React.ReactNode;
}) {
const gameStarts = buildScannerMatches(events).flatMap(({ match }) =>
match.startsAt === null ? [] : [match.startsAt],
);
return (
<div className={styles.scanWorkers}>
<div className={styles.headerRow}>
<StatusPill>Scanning</StatusPill>
<ProgressText />
<div className={styles.headerEnd}>{headerEnd}</div>
</div>
<Lanes gameStarts={gameStarts} />
{children}
</div>
);
}
function ProgressText() {
const { progress } = useVodScanProgress();
return (
<span className={styles.progressText}>
{progress
? `${Math.round((progress.t / Math.max(1, progress.duration)) * 100)}% · ${formatPosition(progress.t)} / ${formatPosition(progress.duration)}${progress.rate > 0 ? ` · ${progress.rate.toFixed(1)}× realtime` : ""}`
: "opening the file…"}
</span>
);
}
function Lanes({ gameStarts }: { gameStarts: number[] }) {
const { progress } = useVodScanProgress();
return (
<>
<progress
className={styles.progress}
value={progress?.t ?? 0}
max={progress?.duration ?? 1}
/>
<div className={styles.laneGrid}>
{progress?.lanes.map((lane, i) => (
<LaneTile
key={i}
index={i}
lane={lane}
games={
gameStarts.filter((t) => t >= lane.tStart && t < lane.tEnd).length
}
/>
))}
</div>
</>
);
}
function LaneTile({
index,
lane,
games,
}: {
index: number;
lane: VodScanLane;
games: number;
}) {
const laneProgress =
(lane.t - lane.tStart) / Math.max(1, lane.tEnd - lane.tStart);
return (
<div className={clsx(styles.lane, { [styles.laneDone]: lane.done })}>
<div className={styles.laneScreen}>
<canvas
ref={(canvas) => setVodLaneCanvas(index, canvas)}
className={styles.canvas}
/>
<span className={styles.laneChip}>Worker {index + 1}</span>
<span
className={clsx(styles.modeChip, {
[styles.modeActive]: !lane.done && lane.mode === "active",
})}
>
{lane.done ? (
<>
<Check size={12} /> Done
</>
) : lane.mode === "active" ? (
"Reading"
) : (
"Skimming"
)}
</span>
<div className={styles.laneFooter}>
<span className={styles.laneClock}>{formatPosition(lane.t)}</span>
<div className={styles.laneBar}>
<div
className={styles.laneBarFill}
style={{ width: `${laneProgress * 100}%` }}
/>
</div>
</div>
</div>
<div className={styles.laneMeta}>
<span>
{formatPosition(lane.tStart)} – {formatPosition(lane.tEnd)}
</span>
<span className={styles.laneGames}>
{games} {games === 1 ? "game" : "games"}
</span>
</div>
</div>
);
}

View File

@@ -9,56 +9,6 @@
color: var(--color-text-high);
}
.scanning {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
/* the bar's column is fixed by the grid, so the numbers changing width beside it never resize it */
.progressRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--s-1) var(--s-3);
}
.progress {
width: 100%;
height: 8px;
accent-color: var(--color-fg-accent);
}
.progressText {
grid-column: 2;
font-size: var(--font-2xs);
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
}
.previewRow {
display: grid;
grid-template-columns: minmax(200px, 320px) minmax(0, 1fr);
gap: var(--s-4);
align-items: start;
}
.preview {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
.previewNotes {
display: flex;
flex-direction: column;
gap: var(--s-1);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.afterScan {
display: flex;
flex-wrap: wrap;
@@ -132,9 +82,3 @@
}
}
}
@container scanner (width < 600px) {
.previewRow {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -17,7 +17,7 @@ import {
useSearchParamsTyped,
} from "~/modules/search-params/hooks";
import type { ScanTelemetry } from "../core/detectors/telemetry";
import { formatPosition, formatTime } from "../core/format";
import { formatTime } from "../core/format";
import { scannerSearchParams } from "../scanner-search-params";
import { deleteVodClips } from "../store/clips";
import {
@@ -30,7 +30,8 @@ import {
import { refreshClips, useClips } from "./clips-feed";
import { ExportMenu } from "./ExportMenu";
import { NotFound } from "./NotFound";
import { SessionHeader, StatusPill } from "./SessionHeader";
import { ScanWorkers } from "./ScanWorkers";
import { SessionHeader } from "./SessionHeader";
import { type SessionInfo, SessionView } from "./SessionView";
import { matchContaining } from "./sendou-ingest";
import { sendouUpload } from "./sendou-upload";
@@ -40,7 +41,6 @@ import { sendVod } from "./upload";
import { useDebug } from "./use-debug";
import styles from "./VodView.module.css";
import {
setVodPreviewCanvas,
startVodScan,
uploadVodScan,
useVodScan,
@@ -99,18 +99,14 @@ function ScanVodView({ name }: { name: string }) {
</label>
</div>
) : scanning ? (
<div className={styles.scanning}>
<ScanProgressRow />
<div className={styles.previewRow}>
<canvas ref={setVodPreviewCanvas} className={styles.preview} />
<div className={styles.previewNotes}>
<div>Upload {user && settings.upload ? "on" : "off"}</div>
{scan.error ? (
<div className={styles.error}>{scan.error}</div>
) : null}
</div>
</div>
</div>
<ScanWorkers
events={scan.events}
headerEnd={`Upload ${user && settings.upload ? "on" : "off"}`}
>
{scan.error ? (
<div className={styles.error}>{scan.error}</div>
) : null}
</ScanWorkers>
) : (
<div className={styles.afterScan}>
{scan.clipsWork?.state === "cutting"
@@ -130,26 +126,6 @@ function ScanVodView({ name }: { name: string }) {
);
}
function ScanProgressRow() {
const { progress } = useVodScanProgress();
return (
<div className={styles.progressRow}>
<StatusPill>Scanning</StatusPill>
<progress
className={styles.progress}
value={progress?.t ?? 0}
max={progress?.duration ?? 1}
/>
<span className={styles.progressText}>
{progress
? `${Math.round((progress.t / Math.max(1, progress.duration)) * 100)}% · ${formatPosition(progress.t)} / ${formatPosition(progress.duration)}${progress.rate > 0 ? ` · ${progress.rate.toFixed(1)}× realtime` : ""}`
: "opening the file…"}
</span>
</div>
);
}
function ScanTelemetryPanel() {
const { telemetry } = useVodScanProgress();
return telemetry ? <TelemetryPanel telemetry={telemetry} /> : null;

View File

@@ -72,11 +72,24 @@ export type ClipsWork =
| { state: "cutting"; done: number; total: number }
| { state: "done"; saved: number; error: string | null };
export type VodScanMode = "active" | "skim";
/** One worker's slice of the file. */
export interface VodScanLane {
tStart: number;
tEnd: number;
/** seconds of video the lane has reached */
t: number;
mode: VodScanMode;
done: boolean;
}
export interface VodScanProgress {
t: number;
duration: number;
/** scan speed as a multiple of realtime */
rate: number;
lanes: VodScanLane[];
}
export interface VodScanSnapshot {
@@ -114,7 +127,7 @@ let snapshot = IDLE;
const listeners = new Set<() => void>();
let progressSnapshot = IDLE_PROGRESS;
const progressListeners = new Set<() => void>();
let previewCanvas: HTMLCanvasElement | null = null;
const laneCanvases = new Map<number, HTMLCanvasElement>();
/** lossless PNGs of the frames the detectors analyzed this scan, until saved */
let frames = new WeakMap<ScanEvent, Blob>();
let abortRef = { aborted: false };
@@ -162,9 +175,13 @@ function setProgress(patch: Partial<VodScanProgressSnapshot>): void {
for (const listener of progressListeners) listener();
}
/** The view showing the scan hands over its canvas for the frame preview. */
export function setVodPreviewCanvas(canvas: HTMLCanvasElement | null): void {
previewCanvas = canvas;
/** The view showing the scan hands over a canvas per lane (worker slice) for its frame preview. */
export function setVodLaneCanvas(
lane: number,
canvas: HTMLCanvasElement | null,
): void {
if (canvas) laneCanvases.set(lane, canvas);
else laneCanvases.delete(lane);
}
/** The frame an event of this scan was read from: in memory while scanning, the store once saved. */
@@ -227,8 +244,8 @@ export async function startVodScan(
const updateProgress = (patch: Partial<VodScanProgressSnapshot>) => {
if (own === generation) setProgress(patch);
};
const preview = (frame: ImageBitmap | VideoFrame) => {
if (own === generation) drawPreview(frame);
const preview = (frame: ImageBitmap | VideoFrame, lane: number) => {
if (own === generation) drawPreview(laneCanvases.get(lane), frame);
};
set({
...IDLE,
@@ -325,6 +342,7 @@ export async function startVodScan(
tStart: i * span,
tEnd: i === clients.length - 1 ? duration : (i + 1) * span,
t: i * span,
mode: "active" as VodScanMode,
done: false,
telemetry: null as ScanTelemetry | null,
}));
@@ -336,9 +354,9 @@ export async function startVodScan(
return parts.length > 0 ? mergeScanTelemetry(parts) : null;
};
let lastUiUpdate = Number.NEGATIVE_INFINITY;
const pushUiUpdate = () => {
const pushUiUpdate = ({ force }: { force: boolean }) => {
const now = performance.now();
if (now - lastUiUpdate < UI_UPDATE_INTERVAL_MS) return;
if (!force && now - lastUiUpdate < UI_UPDATE_INTERVAL_MS) return;
lastUiUpdate = now;
const covered = R.sumBy(
chunks,
@@ -350,6 +368,13 @@ export async function startVodScan(
t: covered,
duration,
rate: elapsed > 0 ? covered / elapsed : 0,
lanes: chunks.map((c) => ({
tStart: c.tStart,
tEnd: c.tEnd,
t: Math.min(c.t, c.tEnd),
mode: c.mode,
done: c.done,
})),
},
telemetry: mergedTelemetry(),
});
@@ -361,15 +386,13 @@ export async function startVodScan(
{ file, chunkIndex, tStart: chunk.tStart, tEnd: chunk.tEnd },
(chunkProgress) => {
chunk.t = chunkProgress.t;
chunk.mode = chunkProgress.mode;
chunk.telemetry = chunkProgress.telemetry;
if (chunkProgress.preview) {
// show one chunk at a time: the earliest still running
if (chunks.find((c) => !c.done) === chunk) {
preview(chunkProgress.preview);
}
preview(chunkProgress.preview, chunkIndex);
chunkProgress.preview.close();
}
pushUiUpdate();
pushUiUpdate({ force: false });
},
)
.then(
@@ -377,6 +400,7 @@ export async function startVodScan(
chunk.done = true;
chunk.t = chunk.tEnd;
chunk.telemetry = chunkTelemetry;
pushUiUpdate({ force: true });
},
(error) => {
chunk.done = true;
@@ -415,13 +439,22 @@ export async function startVodScan(
lastUiUpdate = now;
// the preview draw must precede analyze — transferring the
// frame to the worker detaches it
preview(frame);
preview(frame, 0);
const elapsed = (now - started) / 1000;
updateProgress({
progress: {
t,
duration: vod.duration,
rate: elapsed > 0 ? t / elapsed : 0,
lanes: [
{
tStart: 0,
tEnd: vod.duration,
t,
mode: seek.doneInfo?.calm ? "skim" : "active",
done: false,
},
],
},
telemetry: seek.doneInfo?.telemetry ?? null,
});
@@ -450,7 +483,14 @@ export async function startVodScan(
await Promise.all(thumbnailWork);
events = withoutInvalidObjectives(events);
update({ events });
updateProgress({ progress: { t: duration, duration, rate: 0 } });
updateProgress({
progress: {
t: duration,
duration,
rate: 0,
lanes: progressSnapshot.progress?.lanes ?? [],
},
});
await saveVod(
{
name: file.name,
@@ -569,8 +609,10 @@ function sameEvent(a: ScanEvent, b: DetectedEvent): boolean {
return a === b || (a.type === b.type && a.t === b.t && a.data === b.data);
}
function drawPreview(frame: ImageBitmap | VideoFrame): void {
const canvas = previewCanvas;
function drawPreview(
canvas: HTMLCanvasElement | null | undefined,
frame: ImageBitmap | VideoFrame,
): void {
if (!canvas) return;
const width = "displayWidth" in frame ? frame.displayWidth : frame.width;
const height = "displayHeight" in frame ? frame.displayHeight : frame.height;