Clips fixes

This commit is contained in:
Kalle
2026-10-01 08:18:29 +03:00
parent 8bfe6f54d7
commit 650feede25
11 changed files with 543 additions and 119 deletions

View File

@@ -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`.

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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]);
});
});

View File

@@ -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;

View File

@@ -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, []);
});