mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
Visual improvements
This commit is contained in:
63
app/features/scanner/components/EventFeed.module.css
Normal file
63
app/features/scanner/components/EventFeed.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
103
app/features/scanner/components/EventFeed.tsx
Normal file
103
app/features/scanner/components/EventFeed.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
162
app/features/scanner/components/ScanWorkers.module.css
Normal file
162
app/features/scanner/components/ScanWorkers.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
137
app/features/scanner/components/ScanWorkers.tsx
Normal file
137
app/features/scanner/components/ScanWorkers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user