mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 11:28:59 +03:00
* add sub stream recording with adaptive quality playback Optionally record a second, lower bitrate stream alongside the main recording stream via a `record_sub` input role and `record.sub` config block, with its own retention windows. Recordings rows now carry the stream type plus the media details needed to serve both streams from one manifest: video codec, audio presence, audio codec and rate, and a record-time keyframe index. Playback resolves coverage across both streams and merges them into a single VOD sequence, falling back to a discontinuity manifest with per-clip init segments when the media signatures differ. The player exposes a quality selector, and an auto governor picks the stream from stall time, bandwidth, codec support, and the save-data hint. * fix tests and i18n
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
/**
|
|
* Policy engine for auto playback quality downswitching.
|
|
*
|
|
* Stall time is measured rather than counted: hls.js reports
|
|
* BUFFER_STALLED_ERROR only once per episode (the flag resets only when
|
|
* playback resumes), so counting events makes the worst networks, where
|
|
* one freeze never resolves, the least likely to ever downswitch.
|
|
*/
|
|
|
|
export type DownswitchReason =
|
|
| "stall"
|
|
| "bandwidth"
|
|
| "fatal-error"
|
|
| "startup"
|
|
| "codec";
|
|
|
|
// stalls just after a seek are expected on any network (the target
|
|
// position is rarely buffered), so they get a longer budget and are
|
|
// kept out of the cumulative window
|
|
const SEEK_GRACE_MS = 2000;
|
|
// a single unresolved stall episode this long triggers a downswitch
|
|
const SINGLE_STALL_DOWNSWITCH_MS = 4000;
|
|
// seek-adjacent episodes only trigger once clearly beyond load latency
|
|
const GRACED_STALL_DOWNSWITCH_MS = 10000;
|
|
// total (non-graced) stall time within the rolling window that triggers
|
|
const CUMULATIVE_STALL_DOWNSWITCH_MS = 7000;
|
|
// rolling window for cumulative stall accounting; long enough to catch
|
|
// chronic short stalls, short enough that ancient history ages out
|
|
const STALL_WINDOW_MS = 60000;
|
|
// a throughput sample below bitrate * margin counts as evidence the
|
|
// connection cannot sustain the stream
|
|
const PREDICTIVE_BANDWIDTH_MARGIN = 1.1;
|
|
// consecutive low samples required for a predictive (pre-stall) downswitch
|
|
const PREDICTIVE_SAMPLE_COUNT = 3;
|
|
// measured throughput must clear the original stream's bitrate by this
|
|
// margin before a downswitched player retries full quality
|
|
const RETRY_BANDWIDTH_MARGIN = 1.5;
|
|
// the stall clock is blind before playback starts (the player is still
|
|
// paused), so the initial load needs its own budget
|
|
const STARTUP_DOWNSWITCH_MS = 10000;
|
|
// no realistic original recording stream plays comfortably below this,
|
|
// so a camera whose bitrate is not yet known starts low
|
|
const KNOWN_SLOW_START_FLOOR_BPS = 3_000_000;
|
|
// the first sample is biased toward the seeded default estimate
|
|
const PROBE_MIN_SUB_SAMPLES = 2;
|
|
|
|
type StallEpisode = {
|
|
start: number;
|
|
end: number;
|
|
};
|
|
|
|
export class AutoQualityGovernor {
|
|
// returns false when quality is pinned, sub is unavailable, or the
|
|
// player is already low
|
|
private requestDownswitch: (reason: DownswitchReason) => boolean;
|
|
private requestUpswitch: (() => void) | undefined;
|
|
|
|
private episodes: StallEpisode[] = [];
|
|
private openEpisode: { start: number; graced: boolean } | null = null;
|
|
private stallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
private startupTimer: ReturnType<typeof setTimeout> | undefined;
|
|
private lastSeekTs = 0;
|
|
private consecutiveLowSamples = 0;
|
|
private upswitchProbeArmed = false;
|
|
private probeSampleCount = 0;
|
|
private mainUnplayable = false;
|
|
private holdLow = false;
|
|
|
|
// network facts survive stall-history resets: a manual pin or camera
|
|
// switch does not change what the connection can carry
|
|
private bandwidthEstimateBps: number | undefined;
|
|
private mainBitrateBps: number | undefined;
|
|
|
|
constructor(
|
|
requestDownswitch: (reason: DownswitchReason) => boolean,
|
|
requestUpswitch?: () => void,
|
|
) {
|
|
this.requestDownswitch = requestDownswitch;
|
|
this.requestUpswitch = requestUpswitch;
|
|
}
|
|
|
|
get bandwidthEstimate(): number | undefined {
|
|
return this.bandwidthEstimateBps;
|
|
}
|
|
|
|
/** Seed the connection estimate persisted from earlier sessions. */
|
|
seed(bandwidthEstimateBps: number | undefined) {
|
|
if (this.bandwidthEstimateBps === undefined) {
|
|
this.bandwidthEstimateBps = bandwidthEstimateBps;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suppresses every path that would route playback back onto the
|
|
* original stream.
|
|
*/
|
|
markMainUnplayable() {
|
|
this.mainUnplayable = true;
|
|
}
|
|
|
|
get isMainUnplayable(): boolean {
|
|
return this.mainUnplayable;
|
|
}
|
|
|
|
/**
|
|
* Hold playback on the low stream regardless of measured headroom
|
|
* (user preference such as data saver, not a bandwidth fact).
|
|
*/
|
|
setHoldLow(hold: boolean) {
|
|
this.holdLow = hold;
|
|
}
|
|
|
|
/**
|
|
* Record the original stream's advertised bitrate learned outside of
|
|
* playback (e.g. parsed from its master playlist). Live measurements
|
|
* take precedence.
|
|
*/
|
|
learnMainBitrate(bitrateBps: number) {
|
|
if (this.mainBitrateBps === undefined && bitrateBps > 0) {
|
|
this.mainBitrateBps = bitrateBps;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts the time-to-first-frame budget: no stall episode can exist
|
|
* before playback starts, so a first segment too large for the
|
|
* connection would otherwise spin forever.
|
|
*/
|
|
sourceLoadStarted() {
|
|
clearTimeout(this.startupTimer);
|
|
this.startupTimer = setTimeout(
|
|
() => this.triggerDownswitch("startup"),
|
|
STARTUP_DOWNSWITCH_MS,
|
|
);
|
|
}
|
|
|
|
sourceLoadEnded() {
|
|
clearTimeout(this.startupTimer);
|
|
this.startupTimer = undefined;
|
|
}
|
|
|
|
/**
|
|
* Arm the one-shot upswitch probe after a conservative low start.
|
|
* Stays armed until it fires or a manual pin resets it, so a
|
|
* connection that improves later still recovers mid-chunk.
|
|
*/
|
|
armUpswitchProbe() {
|
|
this.upswitchProbeArmed = true;
|
|
this.probeSampleCount = 0;
|
|
}
|
|
|
|
noteSeek() {
|
|
this.lastSeekTs = Date.now();
|
|
}
|
|
|
|
/**
|
|
* A stall episode began (hls.js BUFFER_STALLED_ERROR or a video
|
|
* element waiting event). Idempotent while an episode is open, so the
|
|
* two signal sources need no cross-coordination.
|
|
*/
|
|
stallStarted() {
|
|
if (this.openEpisode) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const graced = now - this.lastSeekTs < SEEK_GRACE_MS;
|
|
this.openEpisode = { start: now, graced };
|
|
|
|
// fire mid-stall: either this episode alone exceeds its budget, or
|
|
// it pushes the window's cumulative stall time over the threshold
|
|
const singleBudget = graced
|
|
? GRACED_STALL_DOWNSWITCH_MS
|
|
: SINGLE_STALL_DOWNSWITCH_MS;
|
|
const cumulativeBudget = graced
|
|
? Number.POSITIVE_INFINITY
|
|
: Math.max(0, CUMULATIVE_STALL_DOWNSWITCH_MS - this.windowStallMs(now));
|
|
this.stallTimer = setTimeout(
|
|
() => this.triggerDownswitch("stall"),
|
|
Math.min(singleBudget, cumulativeBudget),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Playback resumed (STALL_RESOLVED, playing, timeupdate) or paused.
|
|
* Closes any open episode; graced episodes never enter the window.
|
|
*/
|
|
stallEnded() {
|
|
if (!this.openEpisode) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this.stallTimer);
|
|
this.stallTimer = undefined;
|
|
|
|
const now = Date.now();
|
|
if (!this.openEpisode.graced && now > this.openEpisode.start) {
|
|
this.episodes.push({ start: this.openEpisode.start, end: now });
|
|
}
|
|
this.openEpisode = null;
|
|
this.pruneEpisodes(now);
|
|
}
|
|
|
|
/**
|
|
* A segment finished loading. Records throughput, refreshes the
|
|
* original stream's bitrate while playing it, and downswitches
|
|
* predictively when sustained throughput cannot carry the stream.
|
|
*/
|
|
bandwidthSample(
|
|
estimateBps: number,
|
|
levelBitrateBps: number | undefined,
|
|
playingMain: boolean,
|
|
) {
|
|
if (!Number.isFinite(estimateBps) || estimateBps <= 0) {
|
|
return;
|
|
}
|
|
|
|
this.bandwidthEstimateBps = estimateBps;
|
|
|
|
if (!playingMain) {
|
|
this.consecutiveLowSamples = 0;
|
|
this.probeSampleCount += 1;
|
|
if (
|
|
this.upswitchProbeArmed &&
|
|
!this.mainUnplayable &&
|
|
!this.holdLow &&
|
|
this.probeSampleCount >= PROBE_MIN_SUB_SAMPLES &&
|
|
this.mainBitrateBps !== undefined &&
|
|
estimateBps > this.mainBitrateBps * RETRY_BANDWIDTH_MARGIN
|
|
) {
|
|
this.upswitchProbeArmed = false;
|
|
this.requestUpswitch?.();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (levelBitrateBps === undefined || levelBitrateBps <= 0) {
|
|
return;
|
|
}
|
|
|
|
this.mainBitrateBps = levelBitrateBps;
|
|
|
|
if (estimateBps < levelBitrateBps * PREDICTIVE_BANDWIDTH_MARGIN) {
|
|
this.consecutiveLowSamples += 1;
|
|
if (this.consecutiveLowSamples >= PREDICTIVE_SAMPLE_COUNT) {
|
|
this.consecutiveLowSamples = 0;
|
|
this.triggerDownswitch("bandwidth");
|
|
}
|
|
} else {
|
|
this.consecutiveLowSamples = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* hls.js gave up loading (retries exhausted). Returns whether a
|
|
* downswitch happened so the player knows to attempt recovery instead.
|
|
*/
|
|
fatalNetworkError(): boolean {
|
|
return this.triggerDownswitch("fatal-error");
|
|
}
|
|
|
|
/**
|
|
* Unlike bandwidth signals a codec failure is proof, so the original
|
|
* stream is marked unplayable before the downswitch. Returns whether
|
|
* a downswitch happened.
|
|
*/
|
|
fatalCodecError(): boolean {
|
|
this.mainUnplayable = true;
|
|
return this.triggerDownswitch("codec");
|
|
}
|
|
|
|
/**
|
|
* Whether a downswitched player should retry full quality at the next
|
|
* chunk boundary. Native HLS playback reports no segment stats, so
|
|
* without bandwidth evidence this falls back to a clean stall window.
|
|
*/
|
|
shouldRetryMain(): boolean {
|
|
if (this.mainUnplayable || this.holdLow) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
this.bandwidthEstimateBps !== undefined &&
|
|
this.mainBitrateBps !== undefined
|
|
) {
|
|
return (
|
|
this.bandwidthEstimateBps > this.mainBitrateBps * RETRY_BANDWIDTH_MARGIN
|
|
);
|
|
}
|
|
|
|
return this.windowStallMs(Date.now()) === 0;
|
|
}
|
|
|
|
/**
|
|
* Whether playback should begin on the low quality stream based on
|
|
* persisted network knowledge. A fully-cold device returns false; the
|
|
* owner handles that case with a conservative start plus the probe.
|
|
*/
|
|
shouldStartLow(): boolean {
|
|
if (this.bandwidthEstimateBps === undefined) {
|
|
return false;
|
|
}
|
|
|
|
if (this.mainBitrateBps !== undefined) {
|
|
return (
|
|
this.bandwidthEstimateBps <
|
|
this.mainBitrateBps * PREDICTIVE_BANDWIDTH_MARGIN
|
|
);
|
|
}
|
|
|
|
// unknown camera bitrate: above the floor, start on the original
|
|
// and let the startup budget correct a wrong guess
|
|
return this.bandwidthEstimateBps < KNOWN_SLOW_START_FLOOR_BPS;
|
|
}
|
|
|
|
/** A manual pin invalidates stall history but not network facts. */
|
|
resetStallHistory() {
|
|
clearTimeout(this.stallTimer);
|
|
this.stallTimer = undefined;
|
|
clearTimeout(this.startupTimer);
|
|
this.startupTimer = undefined;
|
|
this.openEpisode = null;
|
|
this.episodes = [];
|
|
this.consecutiveLowSamples = 0;
|
|
this.upswitchProbeArmed = false;
|
|
this.probeSampleCount = 0;
|
|
}
|
|
|
|
/**
|
|
* A camera switch additionally invalidates the per-camera facts: the
|
|
* stream bitrate and codec playability. The holdLow preference is
|
|
* device-level and survives.
|
|
*/
|
|
resetForCamera() {
|
|
this.resetStallHistory();
|
|
this.mainBitrateBps = undefined;
|
|
this.mainUnplayable = false;
|
|
}
|
|
|
|
destroy() {
|
|
this.resetStallHistory();
|
|
}
|
|
|
|
private triggerDownswitch(reason: DownswitchReason): boolean {
|
|
const handled = this.requestDownswitch(reason);
|
|
if (handled) {
|
|
// the low stream starts with a clean record
|
|
this.resetStallHistory();
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
private windowStallMs(now: number): number {
|
|
this.pruneEpisodes(now);
|
|
const windowStart = now - STALL_WINDOW_MS;
|
|
let total = 0;
|
|
for (const episode of this.episodes) {
|
|
total += episode.end - Math.max(episode.start, windowStart);
|
|
}
|
|
if (this.openEpisode && !this.openEpisode.graced) {
|
|
total += now - Math.max(this.openEpisode.start, windowStart);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
private pruneEpisodes(now: number) {
|
|
const windowStart = now - STALL_WINDOW_MS;
|
|
this.episodes = this.episodes.filter(
|
|
(episode) => episode.end > windowStart,
|
|
);
|
|
}
|
|
}
|