diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 1f573a5be..46ba22d65 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -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`. diff --git a/app/features/scanner/capture/ring-buffer.ts b/app/features/scanner/capture/ring-buffer.ts index 4e9f52a66..da1e3c44b 100644 --- a/app/features/scanner/capture/ring-buffer.ts +++ b/app/features/scanner/capture/ring-buffer.ts @@ -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 { const videoTrack = stream.getVideoTracks()[0]; @@ -101,14 +114,12 @@ export class ClipRingBuffer { const started = new Promise((resolve, reject) => { worker.onmessage = (e: MessageEvent) => { 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 { - 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); } diff --git a/app/features/scanner/capture/ring-buffer.worker.ts b/app/features/scanner/capture/ring-buffer.worker.ts index 6228ab4cb..78c5cd203 100644 --- a/app/features/scanner/capture/ring-buffer.worker.ts +++ b/app/features/scanner/capture/ring-buffer.worker.ts @@ -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): Promise { - 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 { + 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 { - 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 { + 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, diff --git a/app/features/scanner/components/SettingsPopover.tsx b/app/features/scanner/components/SettingsPopover.tsx index a2a130381..6d0a3732d 100644 --- a/app/features/scanner/components/SettingsPopover.tsx +++ b/app/features/scanner/components/SettingsPopover.tsx @@ -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({

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.
Sessions: last 30 days or {MAX_SESSIONS} sessions.

diff --git a/app/features/scanner/components/events-feed.ts b/app/features/scanner/components/events-feed.ts index dc4135304..3cc961fb3 100644 --- a/app/features/scanner/components/events-feed.ts +++ b/app/features/scanner/components/events-feed.ts @@ -54,6 +54,8 @@ export interface LiveSession { events: StoredEvent[]; /** chronological */ built: BuiltMatch[]; + /** `built` plus the unbacked kill-feed stretches (match-builder.ts), chronological — clip material only */ + clipMatches: BuiltMatch[]; 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 { 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 { 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, diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 11a543cd3..707b9f990 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -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; diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index 3db4d1fa6..6f6bf3f01 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -509,7 +509,7 @@ async function cutClips( events: ScanEvent[], update: (patch: Partial) => void, ): Promise { - 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 { diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 2954c973c..8953685b3 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -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 { 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 = 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( events: readonly E[], cache?: MatchBuildCache, + { unbacked = false }: { unbacked?: boolean } = {}, ): BuiltMatch[] { const sorted = events.toSorted((a, b) => a.t - b.t); const built: BuiltMatch[] = []; + const unbackedBuilt: BuiltMatch[] = []; const nextStage = buildNextStageMap(sorted); let open: OpenMatch | null = null; @@ -208,24 +221,38 @@ export function buildScannerMatches( 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( } } 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 { lastMinimapT: number | null; } +function isBacked(open: OpenMatch): boolean { + return open.scoreboard !== null || open.minimaps.length > 0; +} + function startMatch(): OpenMatch { return { mapStart: null, @@ -703,7 +738,9 @@ function toBuiltMatch( 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(); - 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 { + 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(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(); + 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 = diff --git a/app/features/scanner/store/clips.browser.test.ts b/app/features/scanner/store/clips.browser.test.ts index 878a92b62..628c2adb0 100644 --- a/app/features/scanner/store/clips.browser.test.ts +++ b/app/features/scanner/store/clips.browser.test.ts @@ -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]); + }); +}); diff --git a/app/features/scanner/store/clips.ts b/app/features/scanner/store/clips.ts index e39b3f2d6..7c9f5ab1d 100644 --- a/app/features/scanner/store/clips.ts +++ b/app/features/scanner/store/clips.ts @@ -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; +export type ClipToSave = Omit; +/** + * 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 { + { + maxBytes = clip.source.kind === "live" + ? LIVE_CLIPS_MAX_BYTES + : VOD_CLIPS_MAX_BYTES, + } = {}, +): Promise { + 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; - 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; + 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; + 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 { } /** Sort order of every clip list: best first, newest breaks ties. */ -function byScore(a: ScannerClip, b: ScannerClip): number { +function byScore( + a: Pick, + b: Pick, +): 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>( + 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 { + 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 { if (!navigator.storage?.estimate) return null; diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index 1be46ef5b..edbe75c36 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -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, []); +});