mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Clips fixes
This commit is contained in:
@@ -96,7 +96,11 @@ is the pure, swappable scorer: only the POV player's own kills feed it
|
||||
no POV death between them and no pause over `STREAK_MAX_GAP_S`, cut where the
|
||||
clip would outgrow `MAX_CLIP_SECONDS`; `MIN_KILLS` (4) makes it a window,
|
||||
scored `kills² + kills / span`. Both controllers run the same
|
||||
`scoreWindows(match, deaths)` → cut → `store/clips.ts` path:
|
||||
`scoreWindows(match, deaths)` → cut → `store/clips.ts` path, over the
|
||||
session's matches plus the stretches no scoreboard or minimap backs
|
||||
(`buildScannerMatches(…, { unbacked: true })`, `LiveSession.clipMatches`):
|
||||
a streak must not wait for a results screen that may never be read, and live
|
||||
the ring would have dropped it by then:
|
||||
|
||||
- One capture per browser profile: `startCapture` holds a Web Lock
|
||||
(`CAPTURE_LOCK`) for its lifetime, so a second tab gets an error instead
|
||||
@@ -106,8 +110,9 @@ scored `kills² + kills / span`. Both controllers run the same
|
||||
stream's tracks become `MediaStreamTrackProcessor` streams, transferred to
|
||||
a worker so a busy page never costs the footage a frame (on the main
|
||||
thread ~12% of a 60 fps track was lost to the one-frame processor
|
||||
buffer). There the video runs through a `VideoEncoder` (hardware H.264,
|
||||
~16 Mbps, keyframe every 2 s) into a ring of GOPs holding the last
|
||||
buffer). There the video runs through a `VideoEncoder` (H.264, keyframe
|
||||
every 2 s, bitrate scaled by pixel rate: ~10 Mbps at 1080p60) into a ring
|
||||
of GOPs holding the last
|
||||
`RING_BUFFER_SECONDS`; the audio through an `AudioEncoder` (AAC, else
|
||||
Opus) into the same ring. `openCapture` asks for 60 fps explicitly, as
|
||||
Chromium's default of 30 would halve a capture card. Packets carry the
|
||||
@@ -143,11 +148,22 @@ scored `kills² + kills / span`. Both controllers run the same
|
||||
the window start are copied into a fresh MP4 (video + audio, no
|
||||
re-encode, so a minute of 1080p takes well under a second). mediabunny's
|
||||
`Conversion` with `trim` always transcodes; keep using the packet copy.
|
||||
- **Encoder**: hardware first, then software — Chromium's
|
||||
`prefer-hardware` never falls back by itself (Linux, VMs, older GPUs). A
|
||||
hardware encoder can also fail only once frames flow (NVENC sessions taken
|
||||
by OBS): the worker swaps in software and restarts the ring. If nothing is
|
||||
left the status line shows clips as failed; the ring still cuts what it
|
||||
holds.
|
||||
- **Buckets** (`store/clips.ts`): `session` holds the running session's
|
||||
clips (nothing evicted while you play); Stop rolls them into `history`,
|
||||
clips (kept until Stop, budget permitting); Stop rolls them into `history`,
|
||||
where `MAX_HISTORY_CLIPS` (20) applies by score — download to keep. A
|
||||
file's clips (`vod`) live for one visit and are purged on the next page
|
||||
load; the file is on disk. Clip records carry the real `start`/`end`
|
||||
load; the file is on disk. Bytes are capped too: the live clips (session
|
||||
and history together) stay under `LIVE_CLIPS_MAX_BYTES` (4 GB) and each
|
||||
file's clips under `VOD_CLIPS_MAX_BYTES` (2 GB, they keep the source
|
||||
bitrate), lowest score dropped first, and a clip that
|
||||
would eat into the last 250 MB of quota (the events share it) is not
|
||||
saved — on a file the scan carries on with the next window. Clip records carry the real `start`/`end`
|
||||
seconds (a packet-copied clip starts at a keyframe), and a card's deaths
|
||||
and kills get a ▶ when a clip covers their `t`.
|
||||
|
||||
|
||||
@@ -45,10 +45,12 @@ export function supportsRingBuffer(): boolean {
|
||||
|
||||
export class ClipRingBuffer {
|
||||
readonly #seconds: number;
|
||||
readonly #onFailure: (message: string) => void;
|
||||
#worker: Worker | null = null;
|
||||
#started = false;
|
||||
#failure: string | null = null;
|
||||
#audioSignalAt: number | null = null;
|
||||
#audioFailure: string | null = null;
|
||||
#failure: string | null = null;
|
||||
#nextCutId = 0;
|
||||
readonly #cuts = new Map<
|
||||
number,
|
||||
@@ -58,8 +60,14 @@ export class ClipRingBuffer {
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(seconds: number) {
|
||||
/**
|
||||
* `onFailure`: the encoder gave up after starting (with no software to
|
||||
* fall back on) or the worker died. Cuts go on serving the footage
|
||||
* already in the ring.
|
||||
*/
|
||||
constructor(seconds: number, onFailure: (message: string) => void) {
|
||||
this.#seconds = seconds;
|
||||
this.#onFailure = onFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +84,11 @@ export class ClipRingBuffer {
|
||||
return this.#audioFailure;
|
||||
}
|
||||
|
||||
/** Why the encoder gave up after starting, once it has; no new footage from then on. */
|
||||
get failure(): string | null {
|
||||
return this.#failure;
|
||||
}
|
||||
|
||||
/** Starts encoding both tracks; resolves once the worker's video encoder is configured. */
|
||||
async start(stream: MediaStream): Promise<void> {
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
@@ -101,14 +114,12 @@ export class ClipRingBuffer {
|
||||
const started = new Promise<void>((resolve, reject) => {
|
||||
worker.onmessage = (e: MessageEvent<RingWorkerResponse>) => {
|
||||
const msg = e.data;
|
||||
if (msg.kind === "started") resolve();
|
||||
else if (msg.kind === "error") {
|
||||
reject(new Error(msg.message));
|
||||
this.#failure = msg.message;
|
||||
for (const cut of this.#cuts.values()) {
|
||||
cut.reject(new Error(msg.message));
|
||||
}
|
||||
this.#cuts.clear();
|
||||
if (msg.kind === "started") {
|
||||
this.#started = true;
|
||||
resolve();
|
||||
} else if (msg.kind === "error") {
|
||||
if (this.#started) this.#fail(msg.message);
|
||||
else reject(new Error(msg.message));
|
||||
} else if (msg.kind === "cut") {
|
||||
this.#cuts.get(msg.id)?.resolve(msg.clip);
|
||||
this.#cuts.delete(msg.id);
|
||||
@@ -122,7 +133,13 @@ export class ClipRingBuffer {
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
reject(new Error(event.message || "clip worker failed"));
|
||||
const message = event.message || "clip worker failed";
|
||||
if (!this.#started) {
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
this.stop();
|
||||
this.#fail(message);
|
||||
};
|
||||
});
|
||||
this.#send(
|
||||
@@ -150,7 +167,6 @@ export class ClipRingBuffer {
|
||||
end: number,
|
||||
audioOffset = 0,
|
||||
): Promise<RingBufferClip | null> {
|
||||
if (this.#failure) return Promise.reject(new Error(this.#failure));
|
||||
if (!this.#worker) return Promise.resolve(null);
|
||||
const id = this.#nextCutId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -168,6 +184,11 @@ export class ClipRingBuffer {
|
||||
this.#audioSignalAt = null;
|
||||
}
|
||||
|
||||
#fail(message: string): void {
|
||||
this.#failure = message;
|
||||
this.#onFailure(message);
|
||||
}
|
||||
|
||||
#send(msg: RingWorkerRequest, transfer: Transferable[] = []): void {
|
||||
this.#worker?.postMessage(msg, transfer);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* The clip ring buffer's engine, off the main thread so a busy page never
|
||||
* costs it a frame: the capture's video track (a transferred
|
||||
* MediaStreamTrackProcessor stream) runs through a VideoEncoder (hardware
|
||||
* H.264 where available, a keyframe every `KEYFRAME_INTERVAL_S`) into a
|
||||
* H.264 where available, else software — also when the hardware encoder
|
||||
* gives up mid-capture — a keyframe every `KEYFRAME_INTERVAL_S`) into a
|
||||
* ring of GOPs holding the last `seconds`; the audio track through an
|
||||
* AudioEncoder into the same ring. Every packet is stamped with the wall
|
||||
* clock its frame was captured at — noted as the frame enters the encoder,
|
||||
@@ -29,8 +30,23 @@ import type {
|
||||
RingWorkerResponse,
|
||||
} from "./ring-buffer-protocol";
|
||||
|
||||
/** ~indistinguishable from the source at 720p60 per the auto-clipper measurements */
|
||||
const VIDEO_BITRATE = 16_000_000;
|
||||
/**
|
||||
* Bitrate scales with the pixel rate: 1080p60 lands at ~10 Mbps, plenty for
|
||||
* highlights, and a clip costs ~1.25 MB a second. The floor keeps 720p sharp
|
||||
* (16 Mbps was ~indistinguishable from the source at 720p60 per the
|
||||
* auto-clipper measurements).
|
||||
*/
|
||||
const VIDEO_BITS_PER_PIXEL = 0.08;
|
||||
const MIN_VIDEO_BITRATE = 6_000_000;
|
||||
const MAX_VIDEO_BITRATE = 12_000_000;
|
||||
/**
|
||||
* Hardware first. Chromium's `prefer-hardware` is hardware-only, so software
|
||||
* (OpenH264) must be asked for: Linux, VMs, older GPUs.
|
||||
*/
|
||||
const VIDEO_ACCELERATIONS: HardwareAcceleration[] = [
|
||||
"prefer-hardware",
|
||||
"no-preference",
|
||||
];
|
||||
const KEYFRAME_INTERVAL_S = 2;
|
||||
const AUDIO_BITRATE = 160_000;
|
||||
/** high profile at level 5.1 covers 1080p60; the fallbacks trade profile for reach */
|
||||
@@ -119,22 +135,12 @@ class Ring {
|
||||
height,
|
||||
framerate,
|
||||
}: Extract<RingWorkerRequest, { kind: "start" }>): Promise<void> {
|
||||
const codec = await firstSupportedVideoCodec(width, height, framerate);
|
||||
if (!codec) throw new Error("no H.264 encoder available for clips");
|
||||
|
||||
this.#videoEncoder = new VideoEncoder({
|
||||
output: (chunk, meta) => this.#onVideoChunk(chunk, meta),
|
||||
error: (error) => this.#fail(error),
|
||||
});
|
||||
this.#videoEncoder.configure({
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
bitrate: VIDEO_BITRATE,
|
||||
framerate,
|
||||
latencyMode: "realtime",
|
||||
hardwareAcceleration: "prefer-hardware",
|
||||
});
|
||||
const config = await firstSupportedVideoConfig(
|
||||
{ width, height, framerate },
|
||||
VIDEO_ACCELERATIONS,
|
||||
);
|
||||
if (!config) throw new Error("no H.264 encoder available for clips");
|
||||
this.#configureVideo(config);
|
||||
void this.#pumpVideo(video);
|
||||
if (audio && typeof AudioEncoder !== "undefined") {
|
||||
void this.#pumpAudio(audio);
|
||||
@@ -166,6 +172,8 @@ class Ring {
|
||||
if (!first || !this.#videoConfig) return null;
|
||||
const clipStart = first.wall;
|
||||
const clipEnd = video.at(-1)!.wall;
|
||||
// the encoder stopped before the window: the ring only holds older footage
|
||||
if (clipEnd < start) return null;
|
||||
const thumbnail =
|
||||
gops.findLast((gop) => gop.thumbnail && gop.packets[0]!.wall <= end)
|
||||
?.thumbnail ?? gops[0]?.thumbnail;
|
||||
@@ -362,9 +370,39 @@ class Ring {
|
||||
}
|
||||
}
|
||||
|
||||
#fail(error: unknown): void {
|
||||
#configureVideo(config: VideoEncoderConfig): void {
|
||||
const encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => this.#onVideoChunk(chunk, meta),
|
||||
error: (error) => void this.#onVideoError(encoder, config, error),
|
||||
});
|
||||
encoder.configure(config);
|
||||
this.#videoEncoder = encoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hardware encoder can fail only once frames flow (its sessions run out
|
||||
* while OBS encodes on the same GPU), so it is swapped for software and
|
||||
* the ring restarts: packets of two encoders never share a clip.
|
||||
*/
|
||||
async #onVideoError(
|
||||
encoder: VideoEncoder,
|
||||
config: VideoEncoderConfig,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
if (this.#videoEncoder !== encoder) return;
|
||||
this.#videoEncoder = null;
|
||||
post({ kind: "error", message: describe(error) });
|
||||
const software =
|
||||
config.hardwareAcceleration === "prefer-hardware"
|
||||
? await firstSupportedVideoConfig(config, ["no-preference"])
|
||||
: null;
|
||||
if (!software) {
|
||||
post({ kind: "error", message: describe(error) });
|
||||
return;
|
||||
}
|
||||
this.#gops.length = 0;
|
||||
this.#videoConfig = undefined;
|
||||
this.#lastKeyframeAt = Number.NEGATIVE_INFINITY;
|
||||
this.#configureVideo(software);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,29 +505,51 @@ function base64Of(bytes: Uint8Array): string {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function firstSupportedVideoCodec(
|
||||
width: number,
|
||||
height: number,
|
||||
framerate: number,
|
||||
): Promise<string | null> {
|
||||
for (const codec of VIDEO_CODECS) {
|
||||
try {
|
||||
const { supported } = await VideoEncoder.isConfigSupported({
|
||||
/** The first encoder config this browser supports, accelerations in order, each through every codec. */
|
||||
async function firstSupportedVideoConfig(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
framerate,
|
||||
}: { width: number; height: number; framerate?: number },
|
||||
accelerations: readonly HardwareAcceleration[],
|
||||
): Promise<VideoEncoderConfig | null> {
|
||||
for (const hardwareAcceleration of accelerations) {
|
||||
for (const codec of VIDEO_CODECS) {
|
||||
const config: VideoEncoderConfig = {
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
bitrate: VIDEO_BITRATE,
|
||||
bitrate: videoBitrate(width, height, framerate ?? 60),
|
||||
framerate,
|
||||
latencyMode: "realtime",
|
||||
});
|
||||
if (supported) return codec;
|
||||
} catch {
|
||||
// an unknown codec string throws rather than reporting unsupported
|
||||
hardwareAcceleration,
|
||||
};
|
||||
try {
|
||||
const { supported } = await VideoEncoder.isConfigSupported(config);
|
||||
if (supported) return config;
|
||||
} catch {
|
||||
// an unknown codec string throws rather than reporting unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function videoBitrate(
|
||||
width: number,
|
||||
height: number,
|
||||
framerate: number,
|
||||
): number {
|
||||
return Math.min(
|
||||
MAX_VIDEO_BITRATE,
|
||||
Math.max(
|
||||
MIN_VIDEO_BITRATE,
|
||||
Math.round(width * height * framerate * VIDEO_BITS_PER_PIXEL),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function firstSupportedAudioCodec(
|
||||
numberOfChannels: number,
|
||||
sampleRate: number,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { SCANNER_PAGE } from "~/utils/urls";
|
||||
import { MAX_SESSIONS } from "../core/sessions";
|
||||
import { scannerSearchParams } from "../scanner-search-params";
|
||||
import { MAX_HISTORY_CLIPS } from "../store/clips";
|
||||
import { LIVE_CLIPS_MAX_BYTES, MAX_HISTORY_CLIPS } from "../store/clips";
|
||||
import styles from "./SettingsPopover.module.css";
|
||||
import {
|
||||
AUDIO_OFFSET_LIMIT_MS,
|
||||
@@ -138,7 +138,9 @@ export function SettingsPopover({
|
||||
<p className={styles.note}>
|
||||
Clip history keeps the {MAX_HISTORY_CLIPS} best; the lowest is
|
||||
replaced when full. Download what you want to keep. This session's
|
||||
clips are safe until you stop.
|
||||
clips are kept until you stop, unless clips outgrow{" "}
|
||||
{LIVE_CLIPS_MAX_BYTES / 1_000_000_000} GB: the lowest-scoring go
|
||||
first.
|
||||
<br />
|
||||
Sessions: last 30 days or {MAX_SESSIONS} sessions.
|
||||
</p>
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface LiveSession {
|
||||
events: StoredEvent[];
|
||||
/** chronological */
|
||||
built: BuiltMatch<StoredEvent>[];
|
||||
/** `built` plus the unbacked kill-feed stretches (match-builder.ts), chronological — clip material only */
|
||||
clipMatches: BuiltMatch<StoredEvent>[];
|
||||
summary: SessionSummary;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
@@ -272,6 +274,7 @@ function addCompactedSessions(matches: readonly CompactedMatch[]): void {
|
||||
compacted: true,
|
||||
events: built.flatMap((b) => b.sources).toSorted((a, b) => a.t - b.t),
|
||||
built,
|
||||
clipMatches: built,
|
||||
summary: sessionSummary(built.map((b) => b.match)),
|
||||
startedAt: session.key,
|
||||
endedAt: session.endedAt,
|
||||
@@ -296,7 +299,10 @@ async function rawSessions(events: StoredEvent[]): Promise<LiveSession[]> {
|
||||
let sorted = kept.toSorted(
|
||||
(a, b) => a.t - b.t || (a.id ?? 0) - (b.id ?? 0),
|
||||
);
|
||||
let built = buildScannerMatches(sorted, matchCache);
|
||||
let clipMatches = buildScannerMatches(sorted, matchCache, {
|
||||
unbacked: true,
|
||||
});
|
||||
let built = clipMatches.filter((b) => !b.unbacked);
|
||||
// objective reads grouped into a match whose mode rules their overlay out
|
||||
// slipped past the live block (e.g. the mode read arrived after them) —
|
||||
// delete them
|
||||
@@ -311,13 +317,17 @@ async function rawSessions(events: StoredEvent[]): Promise<LiveSession[]> {
|
||||
rawEvents = rawEvents.filter((event) => !invalidSet.has(event));
|
||||
kept = kept.filter((event) => !invalidSet.has(event));
|
||||
sorted = sorted.filter((event) => !invalidSet.has(event));
|
||||
built = buildScannerMatches(sorted, matchCache);
|
||||
clipMatches = buildScannerMatches(sorted, matchCache, {
|
||||
unbacked: true,
|
||||
});
|
||||
built = clipMatches.filter((b) => !b.unbacked);
|
||||
}
|
||||
const session: LiveSession = {
|
||||
key,
|
||||
compacted: false,
|
||||
events: sorted,
|
||||
built,
|
||||
clipMatches,
|
||||
summary: sessionSummary(built.map((b) => b.match)),
|
||||
startedAt: key,
|
||||
endedAt: sessionEvents.at(-1)!.detectedAt,
|
||||
|
||||
@@ -274,7 +274,9 @@ export async function startCapture({
|
||||
if (!supportsRingBuffer()) {
|
||||
clips = "unsupported";
|
||||
} else {
|
||||
const startingRing = new ClipRingBuffer(RING_BUFFER_SECONDS);
|
||||
const startingRing = new ClipRingBuffer(RING_BUFFER_SECONDS, () =>
|
||||
onClipsFailed(startingRing),
|
||||
);
|
||||
ring = startingRing;
|
||||
try {
|
||||
await startingRing.start(stream);
|
||||
@@ -319,7 +321,8 @@ export async function startCapture({
|
||||
stream,
|
||||
hasAudio: stream.getAudioTracks().length > 0,
|
||||
audioError: desktop ? desktop.error : opened.audioError,
|
||||
clips,
|
||||
// the encoder may have given up between starting and here
|
||||
clips: ring?.failure ? "failed" : clips,
|
||||
});
|
||||
} catch (error) {
|
||||
if (stream) stopTracks(stream);
|
||||
@@ -548,9 +551,18 @@ async function persist(
|
||||
refreshFeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* The clip encoder gave up mid-capture: the capture goes on without new
|
||||
* footage, the ring still cuts the windows it holds.
|
||||
*/
|
||||
function onClipsFailed(failed: ClipRingBuffer): void {
|
||||
if (ring !== failed || snapshot.status !== "running") return;
|
||||
set({ clips: "failed", audioSignal: null });
|
||||
}
|
||||
|
||||
/** Whether the clip encoder is getting sound; a device that opens fine but delivers silence shows up here. */
|
||||
function audioCheck(): void {
|
||||
if (snapshot.status !== "running") return;
|
||||
if (snapshot.status !== "running" || snapshot.clips !== "on") return;
|
||||
const track = snapshot.stream?.getAudioTracks()[0];
|
||||
const signalAt = ring?.audioSignalAt ?? null;
|
||||
const next: AudioSignal | null = !track
|
||||
@@ -611,7 +623,7 @@ function cutWindows(clipRing: ClipRingBuffer, nowT: number): void {
|
||||
score: clip.score,
|
||||
clipId: Promise.resolve(clip.id),
|
||||
}));
|
||||
for (const built of session.built) {
|
||||
for (const built of session.clipMatches) {
|
||||
const deaths = built.sources
|
||||
.filter((event) => event.type === DEATH_EVENT_TYPE)
|
||||
.map((event) => event.t);
|
||||
@@ -677,6 +689,15 @@ async function cutClip(
|
||||
},
|
||||
clip.blob,
|
||||
);
|
||||
if (!saved) {
|
||||
// out of room: the clips it would have replaced stay, and the next
|
||||
// tick sees them again as saved clips
|
||||
for (const cut of replaces) {
|
||||
const id = await cut.clipId;
|
||||
if (id !== null) ownClipIds.delete(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
ownClipIds.add(saved.id);
|
||||
for (const cut of replaces) {
|
||||
const id = await cut.clipId;
|
||||
|
||||
@@ -509,7 +509,7 @@ async function cutClips(
|
||||
events: ScanEvent[],
|
||||
update: (patch: Partial<VodScanSnapshot>) => void,
|
||||
): Promise<void> {
|
||||
const windows = buildScannerMatches(events)
|
||||
const windows = buildScannerMatches(events, undefined, { unbacked: true })
|
||||
.flatMap((built) => {
|
||||
const deaths = built.sources
|
||||
.filter((event) => event.type === DEATH_EVENT_TYPE)
|
||||
@@ -534,15 +534,18 @@ async function cutClips(
|
||||
}
|
||||
update({ clipsWork: { state: "cutting", done: 0, total: windows.length } });
|
||||
let saved = 0;
|
||||
try {
|
||||
for (const { window, built } of windows) {
|
||||
let skipped = 0;
|
||||
let error: string | null = null;
|
||||
// best first: one clip failing (or not fitting) still leaves room to try the rest
|
||||
for (const [index, { window, built }] of windows.entries()) {
|
||||
try {
|
||||
const clip = await extractVodClip(file, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
maxSeconds: MAX_CLIP_SECONDS,
|
||||
});
|
||||
const thumbnail = (await vodFrameThumbnail(file, window.t)) ?? undefined;
|
||||
await saveClip(
|
||||
const stored = await saveClip(
|
||||
{
|
||||
createdAt: Date.now(),
|
||||
bucket: "vod",
|
||||
@@ -560,18 +563,30 @@ async function cutClips(
|
||||
},
|
||||
clip.blob,
|
||||
);
|
||||
saved++;
|
||||
update({
|
||||
clipsWork: { state: "cutting", done: saved, total: windows.length },
|
||||
});
|
||||
await refreshClips();
|
||||
if (stored) {
|
||||
saved++;
|
||||
await refreshClips();
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (clipError) {
|
||||
error ??= describeError(clipError);
|
||||
}
|
||||
update({ clipsWork: { state: "done", saved, error: null } });
|
||||
} catch (error) {
|
||||
update({
|
||||
clipsWork: { state: "done", saved, error: describeError(error) },
|
||||
clipsWork: { state: "cutting", done: index + 1, total: windows.length },
|
||||
});
|
||||
}
|
||||
update({
|
||||
clipsWork: {
|
||||
state: "done",
|
||||
saved,
|
||||
error:
|
||||
error ??
|
||||
(skipped > 0
|
||||
? `${skipped} ${skipped === 1 ? "clip" : "clips"} left out, storage is full`
|
||||
: null),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toScanEvent(event: StoredVodEvent): ScanEvent {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* preceding MapStart claims the last 8 minutes of deaths. Without delimiters
|
||||
* (casted footage) minimaps group per map by stage change and time gap. A
|
||||
* match is emitted only when a scoreboard or minimaps back it, regardless of
|
||||
* lobby/outcome — `ingestSkipReasons` filters those. Deaths are harvested
|
||||
* lobby/outcome — `ingestSkipReasons` filters those (the clip scorer alone
|
||||
* asks for the unbacked ones too). Deaths are harvested
|
||||
* onto player rows as enemy builds (ability-harvest.ts).
|
||||
*/
|
||||
import type {
|
||||
@@ -169,6 +170,11 @@ export interface BuiltMatch<E extends DetectedEvent> {
|
||||
match: ScannerMatch;
|
||||
/** input events the match was built from, chronological — the send-status unit for callers */
|
||||
sources: E[];
|
||||
/**
|
||||
* only built with `{ unbacked: true }`: no scoreboard or minimap backs the
|
||||
* match, so it is kill-feed material for clips, not a game to show or send
|
||||
*/
|
||||
unbacked?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,13 +196,20 @@ export type MatchBuildCache<E extends DetectedEvent> = WeakMap<
|
||||
* recording time not contradicting it) joins that match's `sources` instead of
|
||||
* forming a new one, as does a results screen read again with no match
|
||||
* opened since. Every input event ends up in at most one match's `sources`.
|
||||
*
|
||||
* `unbacked` also emits, flagged, the stretches with kill reads no scoreboard
|
||||
* or minimap backed (a results screen missed, the map never opened, a match
|
||||
* still being played): the clip scorer needs their streaks, nothing else
|
||||
* should see them.
|
||||
*/
|
||||
export function buildScannerMatches<E extends DetectedEvent>(
|
||||
events: readonly E[],
|
||||
cache?: MatchBuildCache<E>,
|
||||
{ unbacked = false }: { unbacked?: boolean } = {},
|
||||
): BuiltMatch<E>[] {
|
||||
const sorted = events.toSorted((a, b) => a.t - b.t);
|
||||
const built: BuiltMatch<E>[] = [];
|
||||
const unbackedBuilt: BuiltMatch<E>[] = [];
|
||||
const nextStage = buildNextStageMap(sorted);
|
||||
|
||||
let open: OpenMatch<E> | null = null;
|
||||
@@ -208,24 +221,38 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
||||
let orphanKills: E[] = [];
|
||||
const finalize = (): void => {
|
||||
if (!open) return;
|
||||
if (open.scoreboard || open.minimaps.length > 0) {
|
||||
if (isBacked(open)) {
|
||||
built.push(cachedBuiltMatch(open, cache));
|
||||
} else if (unbacked && open.kills.length > 0) {
|
||||
unbackedBuilt.push(cachedBuiltMatch(open, cache));
|
||||
}
|
||||
open = null;
|
||||
};
|
||||
// orphan reads no scoreboard claimed are left behind
|
||||
const dropOrphans = (): void => {
|
||||
if (unbacked && orphanKills.length > 0) {
|
||||
unbackedBuilt.push(
|
||||
cachedBuiltMatch(
|
||||
{ ...startMatch(), deaths: orphanDeaths, kills: orphanKills },
|
||||
cache,
|
||||
),
|
||||
);
|
||||
}
|
||||
orphanDeaths = [];
|
||||
orphanObjectives = [];
|
||||
orphanPlayerStatuses = [];
|
||||
orphanStripWeapons = [];
|
||||
orphanKills = [];
|
||||
};
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.type === MAP_START_EVENT_TYPE) {
|
||||
// a new match intro abandons any match whose scoreboard was missed
|
||||
finalize();
|
||||
dropOrphans();
|
||||
open = startMatch();
|
||||
open.mapStart = event;
|
||||
vote(open.stageVotes, (event.data as MapStartData).stage);
|
||||
orphanDeaths = [];
|
||||
orphanObjectives = [];
|
||||
orphanPlayerStatuses = [];
|
||||
orphanStripWeapons = [];
|
||||
orphanKills = [];
|
||||
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
|
||||
const revisited =
|
||||
revisitedMatch(built, event) ??
|
||||
@@ -301,8 +328,12 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
||||
}
|
||||
}
|
||||
finalize();
|
||||
dropOrphans();
|
||||
|
||||
return built;
|
||||
if (unbackedBuilt.length === 0) return built;
|
||||
return [...built, ...unbackedBuilt].sort(
|
||||
(a, b) => a.sources[0]!.t - b.sources[0]!.t,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,6 +553,10 @@ interface OpenMatch<E extends DetectedEvent> {
|
||||
lastMinimapT: number | null;
|
||||
}
|
||||
|
||||
function isBacked<E extends DetectedEvent>(open: OpenMatch<E>): boolean {
|
||||
return open.scoreboard !== null || open.minimaps.length > 0;
|
||||
}
|
||||
|
||||
function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
|
||||
return {
|
||||
mapStart: null,
|
||||
@@ -703,7 +738,9 @@ function toBuiltMatch<E extends DetectedEvent>(
|
||||
pov,
|
||||
};
|
||||
|
||||
return { match, sources };
|
||||
return isBacked(open)
|
||||
? { match, sources }
|
||||
: { match, sources, unbacked: true };
|
||||
}
|
||||
|
||||
function floorOrNull(t: number | undefined): number | null {
|
||||
@@ -872,35 +909,28 @@ function buildProgress(
|
||||
/**
|
||||
* One kill per feed row entering the feed. Rows expire oldest-first and a
|
||||
* single read can miss an inner row (a blurred pill ends the bottom-up scan
|
||||
* early), so each read is matched newest-first as a subsequence of the rows
|
||||
* still remembered (first seen within KILL_ROW_LIFETIME_SECONDS): a row
|
||||
* matching a remembered one is carried, anything else is a new kill.
|
||||
* Remembered rows a read fails to show stay remembered until they age out,
|
||||
* so the recovered read after a truncated one re-counts nothing.
|
||||
* early), so each read is aligned as a subsequence of the rows still
|
||||
* remembered (first seen within KILL_ROW_LIFETIME_SECONDS): a row matching a
|
||||
* remembered one is carried, anything else is a new kill. An unreadable
|
||||
* (null) row matches any name, but the alignment carries as many named
|
||||
* matches as it can, so a row sliding in unread never takes a named row's
|
||||
* place; a carried unread row takes the name it is later read with, kill
|
||||
* included. Remembered rows a read fails to show stay remembered until they
|
||||
* age out, so the recovered read after a truncated one re-counts nothing.
|
||||
*/
|
||||
function deriveKills(
|
||||
reads: readonly { t: number; data: KillData }[],
|
||||
): ScannerMatchKill[] {
|
||||
const kills: ScannerMatchKill[] = [];
|
||||
// rows believed on screen, oldest first, by the read that first saw them
|
||||
let known: { name: string | null; t: number }[] = [];
|
||||
let known: { name: string | null; t: number; kill: ScannerMatchKill }[] = [];
|
||||
for (const read of reads) {
|
||||
known = known.filter((row) => read.t - row.t <= KILL_ROW_LIFETIME_SECONDS);
|
||||
const names = read.data.names.toReversed();
|
||||
|
||||
// newest-first greedy subsequence match: a row matches the newest
|
||||
// remembered row not yet claimed, skipping remembered rows this read
|
||||
// failed to show
|
||||
const matched = new Map<number, number>();
|
||||
let j = known.length - 1;
|
||||
for (let i = names.length - 1; i >= 0; i--) {
|
||||
let k = j;
|
||||
while (k >= 0 && !sameRowName(names[i]!, known[k]!.name)) k--;
|
||||
if (k >= 0) {
|
||||
matched.set(k, i);
|
||||
j = k - 1;
|
||||
}
|
||||
}
|
||||
const matched = alignKillRows(
|
||||
known.map((row) => row.name),
|
||||
names,
|
||||
);
|
||||
|
||||
// rebuild the remembered stack in order: unmatched remembered rows stay
|
||||
// (hidden or expiring), unmatched read rows are new kills
|
||||
@@ -910,8 +940,9 @@ function deriveKills(
|
||||
const placeNewUpTo = (end: number): void => {
|
||||
for (; placed < end; placed++) {
|
||||
const name = names[placed]!;
|
||||
kills.push({ t, time: read.data.time, name });
|
||||
next.push({ name, t: read.t });
|
||||
const kill = { t, time: read.data.time, name };
|
||||
kills.push(kill);
|
||||
next.push({ name, t: read.t, kill });
|
||||
}
|
||||
};
|
||||
for (const [k, row] of known.entries()) {
|
||||
@@ -921,7 +952,13 @@ function deriveKills(
|
||||
continue;
|
||||
}
|
||||
placeNewUpTo(i);
|
||||
next.push(row);
|
||||
const name = names[i]!;
|
||||
if (row.name === null && name !== null) {
|
||||
row.kill.name = name;
|
||||
next.push({ ...row, name });
|
||||
} else {
|
||||
next.push(row);
|
||||
}
|
||||
placed = i + 1;
|
||||
}
|
||||
placeNewUpTo(names.length);
|
||||
@@ -932,8 +969,56 @@ function deriveKills(
|
||||
return kills.toSorted((a, b) => a.t - b.t);
|
||||
}
|
||||
|
||||
function sameRowName(a: string | null, b: string | null): boolean {
|
||||
if (a === null || b === null) return true;
|
||||
/**
|
||||
* The order-preserving pairing of remembered rows with read rows (both oldest
|
||||
* first) that carries the most rows, named matches outweighing two unread
|
||||
* ones; ties go to the newest remembered rows, which expire last. Maps
|
||||
* remembered index → read index.
|
||||
*/
|
||||
function alignKillRows(
|
||||
known: readonly (string | null)[],
|
||||
read: readonly (string | null)[],
|
||||
): Map<number, number> {
|
||||
const weightOf = (knownIndex: number, readIndex: number): number => {
|
||||
const a = known[knownIndex]!;
|
||||
const b = read[readIndex]!;
|
||||
if (a === null || b === null) return 1;
|
||||
return sameRowName(a, b) ? 3 : 0;
|
||||
};
|
||||
// best[k][i]: the heaviest alignment of known[0..k) with read[0..i)
|
||||
const best = Array.from({ length: known.length + 1 }, () =>
|
||||
new Array<number>(read.length + 1).fill(0),
|
||||
);
|
||||
for (let k = 1; k <= known.length; k++) {
|
||||
for (let i = 1; i <= read.length; i++) {
|
||||
const weight = weightOf(k - 1, i - 1);
|
||||
best[k]![i] = Math.max(
|
||||
best[k - 1]![i]!,
|
||||
best[k]![i - 1]!,
|
||||
weight > 0 ? best[k - 1]![i - 1]! + weight : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const matched = new Map<number, number>();
|
||||
let k = known.length;
|
||||
let i = read.length;
|
||||
while (k > 0 && i > 0) {
|
||||
const weight = weightOf(k - 1, i - 1);
|
||||
if (weight > 0 && best[k]![i] === best[k - 1]![i - 1]! + weight) {
|
||||
matched.set(k - 1, i - 1);
|
||||
k--;
|
||||
i--;
|
||||
} else if (best[k]![i] === best[k - 1]![i]) {
|
||||
k--;
|
||||
} else {
|
||||
i--;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function sameRowName(a: string, b: string): boolean {
|
||||
const ka = matchKey(a);
|
||||
const kb = matchKey(b);
|
||||
const similarity =
|
||||
|
||||
@@ -111,3 +111,50 @@ describe("deleteVodClips()", () => {
|
||||
expect((await listClips()).map((c) => c.bucket)).toEqual(["history"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveClip()", () => {
|
||||
beforeEach(clearAll);
|
||||
|
||||
const MAX_BYTES = BLOB.size * 2;
|
||||
|
||||
test("drops the group's lowest-scoring clips to stay under the byte budget", async () => {
|
||||
await saveClip(clip({ score: 10 }), BLOB, { maxBytes: MAX_BYTES });
|
||||
await saveClip(clip({ bucket: "history", score: 20 }), BLOB, {
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
const saved = await saveClip(clip({ score: 30 }), BLOB, {
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(saved?.bytes).toBe(BLOB.size);
|
||||
expect((await listClips()).map((c) => c.score)).toEqual([30, 20]);
|
||||
});
|
||||
|
||||
test("saves nothing when the clip scores below a full budget", async () => {
|
||||
await saveClip(clip({ score: 20 }), BLOB, { maxBytes: MAX_BYTES });
|
||||
await saveClip(clip({ score: 30 }), BLOB, { maxBytes: MAX_BYTES });
|
||||
|
||||
expect(
|
||||
await saveClip(clip({ score: 10 }), BLOB, { maxBytes: MAX_BYTES }),
|
||||
).toBeNull();
|
||||
expect((await listClips()).map((c) => c.score)).toEqual([30, 20]);
|
||||
});
|
||||
|
||||
test("keeps a file's clips out of the live clips' budget", async () => {
|
||||
await saveClip(clip({ score: 20 }), BLOB, { maxBytes: MAX_BYTES });
|
||||
await saveClip(clip({ score: 30 }), BLOB, { maxBytes: MAX_BYTES });
|
||||
|
||||
await saveClip(
|
||||
clip({
|
||||
bucket: "vod",
|
||||
source: { kind: "vod", name: "a.mkv", visit: "v" },
|
||||
score: 100,
|
||||
}),
|
||||
BLOB,
|
||||
{ maxBytes: MAX_BYTES },
|
||||
);
|
||||
|
||||
expect((await listClips()).map((c) => c.score)).toEqual([100, 30, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* Clip persistence: records in `clips` (listing never touches video), MP4s
|
||||
* in `clip-blobs` under the record id. Three buckets: `session` holds the
|
||||
* running live session's clips (nothing evicted while you play), `history`
|
||||
* keeps the `MAX_HISTORY_CLIPS` best across sessions (lowest score replaced
|
||||
* when full — download to keep), and `vod` holds a scanned file's clips for
|
||||
* the visit that cut them only (the file is on disk; see visit.ts).
|
||||
* running live session's clips (kept until Stop), `history` keeps the
|
||||
* `MAX_HISTORY_CLIPS` best across sessions (lowest score replaced when full —
|
||||
* download to keep), and `vod` holds a scanned file's clips for the visit
|
||||
* that cut them only (the file is on disk; see visit.ts). Bytes are budgeted
|
||||
* too: the live clips (session and history together) stay under
|
||||
* `LIVE_CLIPS_MAX_BYTES` and each file's clips under `VOD_CLIPS_MAX_BYTES`,
|
||||
* the lowest-scoring going first, and no
|
||||
* clip is saved into the quota the events need.
|
||||
*/
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { CLIP_BLOBS_STORE, CLIPS_STORE, readwrite, tx } from "./db";
|
||||
|
||||
export const MAX_HISTORY_CLIPS = 20;
|
||||
/** ~90 typical session clips on top of a full history */
|
||||
export const LIVE_CLIPS_MAX_BYTES = 4_000_000_000;
|
||||
/** a file's clips keep its bitrate (~5 MB/s at 40 Mbps) but only live for one visit */
|
||||
const VOD_CLIPS_MAX_BYTES = 2_000_000_000;
|
||||
/** quota a clip save leaves free, so the games' events keep saving */
|
||||
const STORAGE_RESERVE_BYTES = 250_000_000;
|
||||
/** clips saved before their size was kept were all encoded at 16 Mbps */
|
||||
const LEGACY_BYTES_PER_SECOND = 2_000_000;
|
||||
|
||||
export type ClipBucket = "session" | "history" | "vod";
|
||||
|
||||
@@ -38,25 +50,51 @@ export interface ScannerClip {
|
||||
hasAudio: boolean;
|
||||
/** small JPEG data URL */
|
||||
thumbnail?: string;
|
||||
/** the MP4's size; absent on clips saved before it was kept */
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
export type ClipToSave = Omit<ScannerClip, "id">;
|
||||
export type ClipToSave = Omit<ScannerClip, "id" | "bytes">;
|
||||
|
||||
/**
|
||||
* Saves the clip, making room in its byte budget by dropping its group's
|
||||
* lowest-scoring clips. Resolves to null, saving nothing, when the clip
|
||||
* itself does not make the cut or would eat into the quota the events need.
|
||||
*/
|
||||
export async function saveClip(
|
||||
clip: ClipToSave,
|
||||
blob: Blob,
|
||||
): Promise<ScannerClip> {
|
||||
{
|
||||
maxBytes = clip.source.kind === "live"
|
||||
? LIVE_CLIPS_MAX_BYTES
|
||||
: VOD_CLIPS_MAX_BYTES,
|
||||
} = {},
|
||||
): Promise<ScannerClip | null> {
|
||||
if (!(await hasRoomFor(blob.size))) return null;
|
||||
let saved: ScannerClip | null = null;
|
||||
await readwrite([CLIPS_STORE, CLIP_BLOBS_STORE], (transaction) => {
|
||||
const add = transaction
|
||||
.objectStore(CLIPS_STORE)
|
||||
.add(clip) as IDBRequest<number>;
|
||||
add.onsuccess = () => {
|
||||
saved = { ...clip, id: add.result };
|
||||
transaction.objectStore(CLIP_BLOBS_STORE).put(blob, add.result);
|
||||
const clips = transaction.objectStore(CLIPS_STORE);
|
||||
const blobs = transaction.objectStore(CLIP_BLOBS_STORE);
|
||||
const getAll = clips.getAll() as IDBRequest<ScannerClip[]>;
|
||||
getAll.onsuccess = () => {
|
||||
const incoming = { ...clip, bytes: blob.size };
|
||||
const group = getAll.result.filter((other) =>
|
||||
sameBudget(other.source, clip.source),
|
||||
);
|
||||
const dropped = overBudget([...group, incoming], maxBytes);
|
||||
if (dropped.includes(incoming)) return;
|
||||
for (const other of dropped as ScannerClip[]) {
|
||||
clips.delete(other.id);
|
||||
blobs.delete(other.id);
|
||||
}
|
||||
const add = clips.add(incoming) as IDBRequest<number>;
|
||||
add.onsuccess = () => {
|
||||
saved = { ...incoming, id: add.result };
|
||||
blobs.put(blob, add.result);
|
||||
};
|
||||
};
|
||||
});
|
||||
return saved!;
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Every clip, best first (score, then newest). */
|
||||
@@ -70,7 +108,10 @@ export async function listClips(): Promise<ScannerClip[]> {
|
||||
}
|
||||
|
||||
/** Sort order of every clip list: best first, newest breaks ties. */
|
||||
function byScore(a: ScannerClip, b: ScannerClip): number {
|
||||
function byScore(
|
||||
a: Pick<ScannerClip, "score" | "createdAt">,
|
||||
b: Pick<ScannerClip, "score" | "createdAt">,
|
||||
): number {
|
||||
return b.score - a.score || b.createdAt - a.createdAt;
|
||||
}
|
||||
|
||||
@@ -140,6 +181,36 @@ export function deleteVodClips(
|
||||
});
|
||||
}
|
||||
|
||||
/** Live clips share one budget, a file's clips (per visit) another. */
|
||||
function sameBudget(a: ClipSource, b: ClipSource): boolean {
|
||||
if (a.kind === "live" || b.kind === "live") return a.kind === b.kind;
|
||||
return a.name === b.name && a.visit === b.visit;
|
||||
}
|
||||
|
||||
/** The clips that do not fit `maxBytes` when kept best first. */
|
||||
function overBudget<T extends Omit<ScannerClip, "id">>(
|
||||
clips: readonly T[],
|
||||
maxBytes: number,
|
||||
): T[] {
|
||||
let total = 0;
|
||||
return clips.toSorted(byScore).filter((clip) => {
|
||||
const bytes =
|
||||
clip.bytes ?? (clip.end - clip.start) * LEGACY_BYTES_PER_SECOND;
|
||||
if (total + bytes > maxBytes) return true;
|
||||
total += bytes;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether `bytes` more still leaves the reserve free; true where the browser cannot tell. */
|
||||
async function hasRoomFor(bytes: number): Promise<boolean> {
|
||||
const estimate = await navigator.storage?.estimate?.().catch(() => undefined);
|
||||
if (estimate?.quota === undefined || estimate.usage === undefined) {
|
||||
return true;
|
||||
}
|
||||
return estimate.quota - estimate.usage - bytes >= STORAGE_RESERVE_BYTES;
|
||||
}
|
||||
|
||||
/** Bytes the origin uses, per the browser's estimate; null where unsupported. */
|
||||
export async function storageUsage(): Promise<number | null> {
|
||||
if (!navigator.storage?.estimate) return null;
|
||||
|
||||
@@ -1750,3 +1750,79 @@ test("a same-name stack seen again inside the row lifetime is the same row", ()
|
||||
]);
|
||||
assert.equal(built[0]!.match.kills!.length, 1);
|
||||
});
|
||||
|
||||
test("a row entering unread does not take the place of the named row above it", () => {
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
kill(10, ["A"]),
|
||||
kill(12, [null, "A"]),
|
||||
kill(13, ["C", "A"]),
|
||||
scoreboard(300),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
built[0]!.match.kills!.map((k) => [k.t, k.name]),
|
||||
[
|
||||
[10, "A"],
|
||||
[12, "C"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("an unread row keeps its kill when a later read cannot name it either", () => {
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
kill(10, ["A"]),
|
||||
kill(12, [null, "A"]),
|
||||
kill(13, [null, "A"]),
|
||||
scoreboard(300),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
built[0]!.match.kills!.map((k) => [k.t, k.name]),
|
||||
[
|
||||
[10, "A"],
|
||||
[12, null],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("unbacked matches are left out by default", () => {
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
kill(60, ["A"]),
|
||||
mapStart(400),
|
||||
kill(460, ["B"]),
|
||||
]);
|
||||
assert.deepEqual(built, []);
|
||||
});
|
||||
|
||||
test("unbacked matches are emitted flagged on request, map intro opened or orphaned", () => {
|
||||
const built = buildScannerMatches(
|
||||
[
|
||||
kill(20, ["Z"]),
|
||||
mapStart(30),
|
||||
kill(60, ["A"]),
|
||||
mapStart(400),
|
||||
kill(460, ["B"]),
|
||||
scoreboard(700),
|
||||
kill(800, ["C"]),
|
||||
],
|
||||
undefined,
|
||||
{ unbacked: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
built.map((b) => [b.unbacked ?? false, b.match.kills?.map((k) => k.name)]),
|
||||
[
|
||||
[true, ["Z"]],
|
||||
[true, ["A"]],
|
||||
[false, ["B"]],
|
||||
[true, ["C"]],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("unbacked matches without kill reads are not emitted", () => {
|
||||
const built = buildScannerMatches([mapStart(0), mapStart(400)], undefined, {
|
||||
unbacked: true,
|
||||
});
|
||||
assert.deepEqual(built, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user