From d8b7a9ee8b6ccd1ab2bedd2b7f541c14daf69f37 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:09:15 +0300 Subject: [PATCH] Visual improvements --- .../scanner/components/EventFeed.module.css | 63 +++++++ app/features/scanner/components/EventFeed.tsx | 103 +++++++++++ .../scanner/components/LiveView.module.css | 22 ++- app/features/scanner/components/LiveView.tsx | 8 + .../scanner/components/ScanWorkers.module.css | 162 ++++++++++++++++++ .../scanner/components/ScanWorkers.tsx | 137 +++++++++++++++ .../scanner/components/VodView.module.css | 56 ------ app/features/scanner/components/VodView.tsx | 46 ++--- app/features/scanner/components/vod-scan.ts | 76 ++++++-- 9 files changed, 564 insertions(+), 109 deletions(-) create mode 100644 app/features/scanner/components/EventFeed.module.css create mode 100644 app/features/scanner/components/EventFeed.tsx create mode 100644 app/features/scanner/components/ScanWorkers.module.css create mode 100644 app/features/scanner/components/ScanWorkers.tsx diff --git a/app/features/scanner/components/EventFeed.module.css b/app/features/scanner/components/EventFeed.module.css new file mode 100644 index 000000000..9f9b7f760 --- /dev/null +++ b/app/features/scanner/components/EventFeed.module.css @@ -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; + } +} diff --git a/app/features/scanner/components/EventFeed.tsx b/app/features/scanner/components/EventFeed.tsx new file mode 100644 index 000000000..a249734d3 --- /dev/null +++ b/app/features/scanner/components/EventFeed.tsx @@ -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 ( +
    + {items.length === 0 ? ( +
  1. Waiting for the first read…
  2. + ) : ( + items.map((item) => ( +
  3. + + + + + {formatPosition(item.t - originT)} + + {item.label} +
  4. + )) + )} +
+ ); +} + +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; + } +} diff --git a/app/features/scanner/components/LiveView.module.css b/app/features/scanner/components/LiveView.module.css index 269e665f0..60b37a251 100644 --- a/app/features/scanner/components/LiveView.module.css +++ b/app/features/scanner/components/LiveView.module.css @@ -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"; } } diff --git a/app/features/scanner/components/LiveView.tsx b/app/features/scanner/components/LiveView.tsx index d557f1c86..0b05ca24f 100644 --- a/app/features/scanner/components/LiveView.tsx +++ b/app/features/scanner/components/LiveView.tsx @@ -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 /> +
+ built.match)} + originT={session?.originT ?? 0} + /> +
{reading && newest ? ( diff --git a/app/features/scanner/components/ScanWorkers.module.css b/app/features/scanner/components/ScanWorkers.module.css new file mode 100644 index 000000000..1fc4d149d --- /dev/null +++ b/app/features/scanner/components/ScanWorkers.module.css @@ -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; + } +} diff --git a/app/features/scanner/components/ScanWorkers.tsx b/app/features/scanner/components/ScanWorkers.tsx new file mode 100644 index 000000000..928286eb3 --- /dev/null +++ b/app/features/scanner/components/ScanWorkers.tsx @@ -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 ( +
+
+ Scanning + +
{headerEnd}
+
+ + {children} +
+ ); +} + +function ProgressText() { + const { progress } = useVodScanProgress(); + return ( + + {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…"} + + ); +} + +function Lanes({ gameStarts }: { gameStarts: number[] }) { + const { progress } = useVodScanProgress(); + + return ( + <> + +
+ {progress?.lanes.map((lane, i) => ( + t >= lane.tStart && t < lane.tEnd).length + } + /> + ))} +
+ + ); +} + +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 ( +
+
+ setVodLaneCanvas(index, canvas)} + className={styles.canvas} + /> + Worker {index + 1} + + {lane.done ? ( + <> + Done + + ) : lane.mode === "active" ? ( + "Reading" + ) : ( + "Skimming" + )} + +
+ {formatPosition(lane.t)} +
+
+
+
+
+
+ + {formatPosition(lane.tStart)} – {formatPosition(lane.tEnd)} + + + {games} {games === 1 ? "game" : "games"} + +
+
+ ); +} diff --git a/app/features/scanner/components/VodView.module.css b/app/features/scanner/components/VodView.module.css index cf164994f..ec584aac1 100644 --- a/app/features/scanner/components/VodView.module.css +++ b/app/features/scanner/components/VodView.module.css @@ -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); - } -} diff --git a/app/features/scanner/components/VodView.tsx b/app/features/scanner/components/VodView.tsx index 6590bd793..1896ddcc0 100644 --- a/app/features/scanner/components/VodView.tsx +++ b/app/features/scanner/components/VodView.tsx @@ -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 }) {
) : scanning ? ( -
- -
- -
-
Upload {user && settings.upload ? "on" : "off"}
- {scan.error ? ( -
{scan.error}
- ) : null} -
-
-
+ + {scan.error ? ( +
{scan.error}
+ ) : null} +
) : (
{scan.clipsWork?.state === "cutting" @@ -130,26 +126,6 @@ function ScanVodView({ name }: { name: string }) { ); } -function ScanProgressRow() { - const { progress } = useVodScanProgress(); - - return ( -
- Scanning - - - {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…"} - -
- ); -} - function ScanTelemetryPanel() { const { telemetry } = useVodScanProgress(); return telemetry ? : null; diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index 5fbc17816..b32f0dc99 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -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(); /** lossless PNGs of the frames the detectors analyzed this scan, until saved */ let frames = new WeakMap(); let abortRef = { aborted: false }; @@ -162,9 +175,13 @@ function setProgress(patch: Partial): 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) => { 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;