Screenshot page fixes

This commit is contained in:
Kalle
2026-09-20 13:34:09 +03:00
parent 2add751c7d
commit f574f2b948
6 changed files with 81 additions and 58 deletions

View File

@@ -28,7 +28,9 @@ running capture (`LiveView`), a past session (`PastSessionView`) and a
scanned VoD (`VodView`): header, clip strip, then match cards
(`components/MatchCard.tsx`) newest first. Views are picked by the `view`
search param (`scanner-search-params.ts`: `live`, `session&id=`,
`vod&name=`, `clips`, and the debug-gated `debug` / dev-only `fixtures`).
`vod&name=`, `clips`, `debug` and the dev-only `fixtures`). Nothing links to
the `debug` screenshot view: dropping an image on the landing's File card
opens it, for anyone, through the same handoff Inspect uses.
- **Controllers are module singletons**, not view state: the capture
(`components/live-session.ts`) and a running VoD scan
@@ -61,10 +63,11 @@ search param (`scanner-search-params.ts`: `live`, `session&id=`,
`Matches` (`core/csv/matches.ts`, one row per game, the rows the cards
render) and `Raw detections` (`core/csv/events.ts`, one row per event).
Column names stay English keys.
- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`): the
image/screenshot view (`ScreenshotPage.tsx`), the dev-only fixtures view,
- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`):
`Save frame as fixture`, `?telemetry=true`, and the `Raw detections`
disclosure inside a match card (the per-event cards with Inspect).
disclosure inside a match card (the per-event cards with Inspect). The
screenshot view (`ScreenshotPage.tsx`) is not gated; the fixtures view is
dev-only.
## Clips

View File

@@ -1,7 +1,9 @@
/**
* The landing: two entry cards (Live / File), the clip history strip and
* the sessions list. Anyone can capture, scan files and get clips locally;
* only uploading needs a login. Dropping a file anywhere here starts a scan.
* only uploading needs a login. Dropping a file anywhere here starts a scan;
* an image opens the screenshot view instead, through the same handoff
* Inspect uses.
*/
import clsx from "clsx";
import { Play, Upload } from "lucide-react";
@@ -17,6 +19,7 @@ import {
import { SCANNER_PAGE } from "~/utils/urls";
import { scannerSearchParams } from "../scanner-search-params";
import { MAX_HISTORY_CLIPS, type ScannerClip } from "../store/clips";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { ClipDialog } from "./ClipDialog";
import { ClipStrip } from "./ClipStrip";
import { useClips } from "./clips-feed";
@@ -71,10 +74,20 @@ export function LandingView() {
};
const scanFile = (file: File) => {
if (file.type.startsWith("image/")) {
void inspectScreenshot(file);
return;
}
void startVodScan(file, { saveClips: saveClipsFromFile, telemetry });
setParams({ view: "vod", name: file.name });
};
const inspectScreenshot = async (file: File) => {
const key = newInspectKey();
await putInspectFrame(key, file);
setParams({ view: "debug", inspect: key });
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target for the whole landing; the file input is the accessible path
<div
@@ -131,13 +144,16 @@ export function LandingView() {
<Upload size={16} aria-hidden />
File
</h2>
<p className={styles.cardText}>Scan a recorded VoD.</p>
<p className={styles.cardText}>
Scan a recorded VoD, or a screenshot to see what the scanner reads
from it.
</p>
<label className={styles.dropzone}>
Drop a video here or{" "}
Drop a video or screenshot here or{" "}
<span className={styles.choose}>choose file</span>
<input
type="file"
accept="video/*"
accept="video/*,image/*"
className={styles.fileInput}
onChange={(e) => {
const file = e.target.files?.[0];

View File

@@ -20,7 +20,6 @@ import { PastSessionView } from "./PastSessionView";
import styles from "./ScannerApp.module.css";
import { ScreenshotPage } from "./ScreenshotPage";
import { setUploadUser } from "./upload";
import { useDebug } from "./use-debug";
import { VodView } from "./VodView";
import { cancelVodScan } from "./vod-scan";
@@ -35,7 +34,6 @@ export function ScannerApp() {
const [view] = useSearchParam(scannerSearchParams, "view");
const user = useUser();
const live = useLiveSession();
const debug = useDebug();
// the controllers run outside React and need the login to decide on uploads
useEffect(() => {
@@ -65,7 +63,7 @@ export function ScannerApp() {
<VodView />
) : view === "clips" ? (
<ClipsView />
) : view === "debug" && debug ? (
) : view === "debug" ? (
<ScreenshotPage />
) : view === "fixtures" && process.env.NODE_ENV === "development" ? (
<FixturesPage />

View File

@@ -129,6 +129,13 @@
}
/* one pill per player slot */
.downloads {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
margin-block: var(--s-3);
}
.statusSlots {
display: inline-flex;
gap: var(--s-2);

View File

@@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { SendouButton } from "~/components/elements/Button";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
@@ -107,26 +108,26 @@ function LabeledCrop(props: {
);
}
/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */
function StatusSlots(props: { data: PlayerStatusData }) {
/** One pill per player slot, both sides; a lit pill is a slot the flag was read on. */
function StatusSlots(props: {
flags: PlayerStatusData["special"] | PlayerStatusData["dead"];
tone: "special" | "dead";
}) {
return (
<span className={styles.statusSlots}>
{([0, 1] as const).map((side) => (
<span key={side} className={styles.statusSide}>
{props.data.dead[side].map((dead, slot) => {
const special = !dead && props.data.special[side][slot];
return (
<span
key={slot}
className={clsx(styles.statusSlot, {
[styles.dead]: dead,
[styles.special]: special,
})}
>
{dead ? "✗" : special ? "★" : slot + 1}
</span>
);
})}
{props.flags.map((side, sideIndex) => (
<span key={sideIndex} className={styles.statusSide}>
{side.map((on, slot) => (
<span
key={slot}
className={clsx(styles.statusSlot, {
[styles.dead]: on && props.tone === "dead",
[styles.special]: on && props.tone === "special",
})}
>
{slot + 1}
</span>
))}
</span>
))}
</span>
@@ -554,18 +555,20 @@ export function ScreenshotPage() {
</div>
{frame && !busy ? (
<p>
<button
type="button"
<div className={styles.downloads}>
<SendouButton
variant="outlined"
size="small"
onClick={() =>
downloadExpectedJson(event?.data ?? null, event?.type)
}
>
Download expected.json
</button>{" "}
<button
type="button"
disabled={!event}
</SendouButton>
<SendouButton
variant="outlined"
size="small"
isDisabled={!event}
onClick={() =>
downloadCsv(
"screenshot-events.csv",
@@ -574,8 +577,8 @@ export function ScreenshotPage() {
}
>
Download CSV
</button>
</p>
</SendouButton>
</div>
) : null}
{Object.keys(results).length > 0 ? (
@@ -834,8 +837,14 @@ export function ScreenshotPage() {
{status ? (
<>
<Stat label="layout">{status.data.layout}</Stat>
<Stat label="players">
<StatusSlots data={status.data} />
<Stat label="special">
<StatusSlots
flags={status.data.special}
tone="special"
/>
</Stat>
<Stat label="splatted">
<StatusSlots flags={status.data.dead} tone="dead" />
</Stat>
</>
) : null}

View File

@@ -1,8 +1,8 @@
/**
* The settings popover, opened from ⚙ on the landing and the live header:
* the upload and clip toggles, the retention notes, and the debug links for
* those who see them. The
* source lives on the landing's Live card, the one place it must be right.
* the upload and clip toggles, the retention notes, and in development the
* fixtures link. The source lives on the landing's Live card, the one place
* it must be right.
*/
import { Settings } from "lucide-react";
import { Link } from "react-router";
@@ -25,14 +25,12 @@ import {
useScannerSettings,
} from "./settings";
import { isLoggedIn } from "./upload";
import { useDebug } from "./use-debug";
/** a step of one frame-ish: fine enough to tune by ear, coarse enough to reach a second in a few clicks */
const AUDIO_OFFSET_STEP_MS = 25;
export function SettingsPopover() {
const settings = useScannerSettings();
const debug = useDebug();
const loggedIn = isLoggedIn();
return (
@@ -122,26 +120,18 @@ export function SettingsPopover() {
<br />
Sessions: last 30 days or {MAX_SESSIONS} sessions.
</p>
{debug ? (
{process.env.NODE_ENV === "development" ? (
<section className={styles.section}>
<span className={styles.label}>Debug</span>
<div className={styles.row}>
<Link
to={scannerSearchParams.href(SCANNER_PAGE, { view: "debug" })}
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "fixtures",
})}
defaultShouldRevalidate={false}
>
Image / screenshot view
Fixtures
</Link>
{process.env.NODE_ENV === "development" ? (
<Link
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "fixtures",
})}
defaultShouldRevalidate={false}
>
Fixtures
</Link>
) : null}
</div>
</section>
) : null}