mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 02:18:57 +03:00
Add sub stream recording with adaptive quality playback (#24009)
* 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
This commit is contained in:
committed by
Nicolas Mowen
parent
f7c5500ea8
commit
1498231eb9
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ export class DynamicVideoController {
|
||||
private timeRange: TimeRange = { after: 0, before: 0 };
|
||||
private inpointOffset: number = 0;
|
||||
private annotationOffset: number;
|
||||
private timeToStart: number | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
camera: string,
|
||||
@@ -55,11 +54,6 @@ export class DynamicVideoController {
|
||||
this.timeRange.after,
|
||||
this.recordings[0],
|
||||
);
|
||||
|
||||
if (this.timeToStart) {
|
||||
this.seekToTimestamp(this.timeToStart);
|
||||
this.timeToStart = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
play() {
|
||||
@@ -75,8 +69,11 @@ export class DynamicVideoController {
|
||||
}
|
||||
|
||||
seekToTimestamp(time: number, play: boolean = false) {
|
||||
// a seek outside the current playback window is a no-op: the view
|
||||
// moves its anchor and chunk on such seeks, and the rebuilt source
|
||||
// resumes at the anchor (startPosition plus the post-load seek).
|
||||
// Seeking here would only reposition the outgoing source's media
|
||||
if (time < this.timeRange.after || time > this.timeRange.before) {
|
||||
this.timeToStart = time;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,16 +92,24 @@ export class DynamicVideoController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seekSeconds != 0) {
|
||||
this.playerController.currentTime = seekSeconds;
|
||||
|
||||
if (this.playerController.currentTime === seekSeconds) {
|
||||
// seeking to the current position fires no seeked event, so apply
|
||||
// the play intent directly (this includes position 0, which the
|
||||
// player sits at before its first seek)
|
||||
if (play) {
|
||||
this.waitAndPlay();
|
||||
playWithTemporaryMuteFallback(this.playerController);
|
||||
} else {
|
||||
this.playerController.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.playerController.currentTime = seekSeconds;
|
||||
|
||||
if (play) {
|
||||
this.waitAndPlay();
|
||||
} else {
|
||||
// no op
|
||||
this.playerController.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,20 +144,26 @@ export class DynamicVideoController {
|
||||
|
||||
getProgress(playerTime: number): number {
|
||||
// take a player time in seconds and convert to timestamp in timeline
|
||||
let timestamp = 0;
|
||||
const recordings = this.recordings || [];
|
||||
let totalTime = 0;
|
||||
(this.recordings || []).every((segment) => {
|
||||
for (const segment of recordings) {
|
||||
if (totalTime + segment.duration > playerTime) {
|
||||
// segment is here
|
||||
timestamp = segment.start_time + (playerTime - totalTime);
|
||||
return false;
|
||||
} else {
|
||||
totalTime += segment.duration;
|
||||
return true;
|
||||
// playlist media from before the span's wall start (keyframe
|
||||
// back-snap lead-in) clamps to the span start
|
||||
const wallLength = segment.end_time - segment.start_time;
|
||||
const leadIn = Math.max(0, segment.duration - wallLength);
|
||||
return (
|
||||
segment.start_time + Math.max(0, playerTime - totalTime - leadIn)
|
||||
);
|
||||
}
|
||||
});
|
||||
totalTime += segment.duration;
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
// past the modeled total: clamp to the covered end rather than
|
||||
// reporting wall-clock zero
|
||||
return recordings.length > 0
|
||||
? recordings[recordings.length - 1].end_time
|
||||
: 0;
|
||||
}
|
||||
|
||||
scrubToTimestamp(time: number, saveIfNotReady: boolean = false) {
|
||||
@@ -162,7 +173,10 @@ export class DynamicVideoController {
|
||||
this.previewController.setNewPreviewStartTime(time);
|
||||
}
|
||||
|
||||
if (scrubResult && this.playerMode != "scrubbing") {
|
||||
// pause even when no preview can render this range: a hidden player
|
||||
// left running reports stale times once the drag releases, bouncing
|
||||
// the handlebar back and sometimes swallowing the release seek
|
||||
if (this.playerMode != "scrubbing") {
|
||||
this.playerMode = "scrubbing";
|
||||
this.playerController.pause();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
import { useApiHost } from "@/api";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Recording } from "@/types/record";
|
||||
import {
|
||||
AutoQualityReason,
|
||||
PlaybackQuality,
|
||||
Recording,
|
||||
RecordingCoverage,
|
||||
} from "@/types/record";
|
||||
import { Preview } from "@/types/preview";
|
||||
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
||||
import { DynamicVideoController } from "./DynamicVideoController";
|
||||
@@ -32,6 +37,13 @@ import {
|
||||
grabVideoSnapshot,
|
||||
} from "@/utils/snapshotUtil";
|
||||
import { isFirefox } from "react-device-detect";
|
||||
import { AutoQualityGovernor } from "./AutoQualityGovernor";
|
||||
import { isCodecFamilySupported } from "@/utils/codecSupport";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
|
||||
// forward buffer while playing the low quality stream; low bitrate makes
|
||||
// a longer buffer cheap and it rides out connection variance better
|
||||
const SUB_STREAM_BUFFER_LENGTH_S = 30;
|
||||
|
||||
/**
|
||||
* Dynamically switches between video playback and scrubbing preview player.
|
||||
@@ -55,6 +67,11 @@ type DynamicVideoPlayerProps = {
|
||||
toggleFullscreen: () => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
transformedOverlay?: ReactNode;
|
||||
quality?: PlaybackQuality;
|
||||
onAutoQualityChange?: (
|
||||
lowQuality: boolean,
|
||||
reason: AutoQualityReason | undefined,
|
||||
) => void;
|
||||
};
|
||||
export default function DynamicVideoPlayer({
|
||||
className,
|
||||
@@ -75,6 +92,8 @@ export default function DynamicVideoPlayer({
|
||||
toggleFullscreen,
|
||||
containerRef,
|
||||
transformedOverlay,
|
||||
quality,
|
||||
onAutoQualityChange,
|
||||
}: DynamicVideoPlayerProps) {
|
||||
const { t } = useTranslation(["components/player", "views/live"]);
|
||||
const apiHost = useApiHost();
|
||||
@@ -128,7 +147,7 @@ export default function DynamicVideoPlayer({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
|
||||
const loadingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
// Don't set source until recordings load - we need accurate startPosition
|
||||
// to avoid hls.js clamping to video end when startPosition exceeds duration
|
||||
@@ -138,32 +157,80 @@ export default function DynamicVideoPlayer({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) {
|
||||
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
|
||||
loadingTimeoutRef.current = setTimeout(() => setIsLoading(true), 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loadingTimeout) {
|
||||
clearTimeout(loadingTimeout);
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
// we only want trigger when scrubbing state changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [camera, isScrubbing]);
|
||||
|
||||
// wall-clock position to resume from once the current source finishes
|
||||
// loading. A seek landing mid-load must win over the position the
|
||||
// source was built around, or the post-load seek drags playback back
|
||||
const sourceAnchorRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
sourceAnchorRef.current = startTimestamp;
|
||||
}, [startTimestamp]);
|
||||
// a recordings change refined the seek model without changing the
|
||||
// playlist, so the playback effect skips its loading indicator
|
||||
const modelOnlyUpdateRef = useRef(false);
|
||||
|
||||
const onPlayerLoaded = useCallback(() => {
|
||||
if (!controller || !startTimestamp) {
|
||||
sourceLoadedRef.current = true;
|
||||
governorRef.current?.sourceLoadEnded();
|
||||
|
||||
const anchor = sourceAnchorRef.current;
|
||||
|
||||
if (!controller || !anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.seekToTimestamp(startTimestamp, true);
|
||||
}, [startTimestamp, controller]);
|
||||
// an anchor outside this chunk is stale (e.g. a natural clip
|
||||
// advance); the playlist already starts where playback should
|
||||
if (anchor < timeRange.after || anchor > timeRange.before) {
|
||||
return;
|
||||
}
|
||||
|
||||
// while the handlebar is down only position the hidden player, never
|
||||
// start it: a mid-drag chunk prefetch can audibly blip before
|
||||
// onPlaying pauses it. The release seek starts playback
|
||||
controller.seekToTimestamp(anchor, !isScrubbing);
|
||||
}, [controller, timeRange, isScrubbing]);
|
||||
|
||||
// used to re-anchor the source when an auto quality switch rebuilds
|
||||
// the playlist mid-playback
|
||||
const lastPlayedTimestampRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// the range the controller's playback model was last built for; while
|
||||
// a chunk change awaits its coverage, the outgoing source reports
|
||||
// times that would map through the stale model
|
||||
const modelTimeRangeRef = useRef<TimeRange | undefined>(undefined);
|
||||
|
||||
const onTimeUpdate = useCallback(
|
||||
(time: number) => {
|
||||
// safety net for stall or startup signals the player missed
|
||||
governorRef.current?.stallEnded();
|
||||
if (!sourceLoadedRef.current) {
|
||||
sourceLoadedRef.current = true;
|
||||
governorRef.current?.sourceLoadEnded();
|
||||
}
|
||||
|
||||
if (isScrubbing || !controller || !onTimestampUpdate || time == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// drop reports until the controller's model matches this chunk
|
||||
if (
|
||||
modelTimeRangeRef.current?.after !== timeRange.after ||
|
||||
modelTimeRangeRef.current?.before !== timeRange.before
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -172,9 +239,18 @@ export default function DynamicVideoPlayer({
|
||||
setIsBuffering(false);
|
||||
}
|
||||
|
||||
onTimestampUpdate(controller.getProgress(time));
|
||||
const progress = controller.getProgress(time);
|
||||
lastPlayedTimestampRef.current = progress;
|
||||
onTimestampUpdate(progress);
|
||||
},
|
||||
[controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing],
|
||||
[
|
||||
controller,
|
||||
onTimestampUpdate,
|
||||
isBuffering,
|
||||
isLoading,
|
||||
isScrubbing,
|
||||
timeRange,
|
||||
],
|
||||
);
|
||||
|
||||
const onUploadFrameToPlus = useCallback(
|
||||
@@ -238,45 +314,350 @@ export default function DynamicVideoPlayer({
|
||||
() => ({
|
||||
before: timeRange.before,
|
||||
after: timeRange.after,
|
||||
timelines: true,
|
||||
}),
|
||||
[timeRange],
|
||||
);
|
||||
const { data: recordings } = useSWR<Recording[]>(
|
||||
[`${camera}/recordings`, recordingParams],
|
||||
const { data: coverage } = useSWR<RecordingCoverage>(
|
||||
[`${camera}/recordings/coverage`, recordingParams],
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
// auto quality plays the default route until the governor downswitches
|
||||
// to the pinned sub route; manual pins bypass this entirely
|
||||
const [autoLowQuality, setAutoLowQuality] = useState(false);
|
||||
const [autoLowReason, setAutoLowReason] = useState<
|
||||
AutoQualityReason | undefined
|
||||
>(undefined);
|
||||
const autoLowQualityRef = useRef(false);
|
||||
|
||||
const subAvailable = useMemo(
|
||||
() =>
|
||||
coverage?.spans?.some((span) => span.streams.includes("sub")) ?? false,
|
||||
[coverage],
|
||||
);
|
||||
|
||||
const resolvedQuality = quality ?? "auto";
|
||||
|
||||
// the ref indirection keeps these reading fresh state while the
|
||||
// governor stays a single instance for the component's lifetime
|
||||
const tryDownswitchRef = useRef<(reason: string) => boolean>(() => false);
|
||||
const tryUpswitchRef = useRef<() => void>(() => {});
|
||||
const governorRef = useRef<AutoQualityGovernor | null>(null);
|
||||
if (governorRef.current === null) {
|
||||
governorRef.current = new AutoQualityGovernor(
|
||||
(reason) => tryDownswitchRef.current(reason),
|
||||
() => tryUpswitchRef.current(),
|
||||
);
|
||||
}
|
||||
const governor = governorRef.current;
|
||||
|
||||
// callers pass an inline callback, so keeping it out of the notify
|
||||
// effect's deps stops the notification's re-render from re-firing it
|
||||
const onAutoQualityChangeRef = useRef(onAutoQualityChange);
|
||||
|
||||
useEffect(() => {
|
||||
onAutoQualityChangeRef.current = onAutoQualityChange;
|
||||
}, [onAutoQualityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
autoLowQualityRef.current = autoLowQuality;
|
||||
onAutoQualityChangeRef.current?.(
|
||||
autoLowQuality,
|
||||
autoLowQuality ? autoLowReason : undefined,
|
||||
);
|
||||
}, [autoLowQuality, autoLowReason]);
|
||||
|
||||
useEffect(() => {
|
||||
tryDownswitchRef.current = (reason: string) => {
|
||||
if (
|
||||
resolvedQuality !== "auto" ||
|
||||
!subAvailable ||
|
||||
autoLowQualityRef.current
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
setAutoLowQuality(true);
|
||||
setAutoLowReason(reason === "codec" ? "codec" : "bandwidth");
|
||||
// so a recovered connection (or a wrong downswitch) returns to
|
||||
// full quality mid-chunk rather than at the next boundary
|
||||
governor.armUpswitchProbe();
|
||||
return true;
|
||||
};
|
||||
tryUpswitchRef.current = () => {
|
||||
if (resolvedQuality === "auto" && autoLowQualityRef.current) {
|
||||
setAutoLowQuality(false);
|
||||
setAutoLowReason(undefined);
|
||||
}
|
||||
};
|
||||
}, [resolvedQuality, subAvailable, governor]);
|
||||
|
||||
// persisted across sessions so a device on a known-slow connection
|
||||
// starts low instead of paying the first stall to find out
|
||||
const [persistedEstimate, setPersistedEstimate, estimateLoaded] =
|
||||
useUserPersistence<number>("playbackBandwidthEstimate");
|
||||
|
||||
const persistGovernor = useCallback(() => {
|
||||
const estimate = governor.bandwidthEstimate;
|
||||
if (estimate !== undefined) {
|
||||
setPersistedEstimate(Math.round(estimate));
|
||||
}
|
||||
}, [governor, setPersistedEstimate]);
|
||||
const persistGovernorRef = useRef(persistGovernor);
|
||||
|
||||
useEffect(() => {
|
||||
persistGovernorRef.current = persistGovernor;
|
||||
}, [persistGovernor]);
|
||||
|
||||
useEffect(() => {
|
||||
// returning to auto starts fresh on the default route, except when
|
||||
// this browser already proved it cannot decode the original stream
|
||||
governor.resetStallHistory();
|
||||
setAutoLowQuality(governor.isMainUnplayable);
|
||||
setAutoLowReason(governor.isMainUnplayable ? "codec" : undefined);
|
||||
// we only want to reset when the pinned quality changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [quality]);
|
||||
|
||||
useEffect(() => {
|
||||
// measured connection throughput carries over across cameras
|
||||
governor.resetForCamera();
|
||||
setAutoLowQuality(false);
|
||||
setAutoLowReason(undefined);
|
||||
// we only want to reset when the camera changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [camera]);
|
||||
|
||||
// seed the governor once per camera, then decide the starting quality
|
||||
const seededCameraRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (seededCameraRef.current === camera || !estimateLoaded || !coverage) {
|
||||
return;
|
||||
}
|
||||
seededCameraRef.current = camera;
|
||||
|
||||
const mainSummary = coverage.streams?.main;
|
||||
if (mainSummary?.bitrate) {
|
||||
governor.learnMainBitrate(mainSummary.bitrate);
|
||||
}
|
||||
governor.seed(persistedEstimate);
|
||||
|
||||
if (resolvedQuality !== "auto" || !subAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// data saver is a user preference, not a bandwidth fact: hold the
|
||||
// low stream and never auto-upswitch against it (a manual pin to
|
||||
// Original still wins as an explicit action)
|
||||
const saveData =
|
||||
(navigator as Navigator & { connection?: { saveData?: boolean } })
|
||||
.connection?.saveData === true;
|
||||
if (saveData) {
|
||||
governor.setHoldLow(true);
|
||||
}
|
||||
|
||||
// a browser that cannot decode the original codec can never play
|
||||
// the merged route. This probe fails open (unknown codecs count as
|
||||
// supported); the reactive fatal-codec path is the real authority
|
||||
const mainSupported = isCodecFamilySupported(mainSummary?.video_codec);
|
||||
const subSupported = isCodecFamilySupported(
|
||||
coverage.streams?.sub?.video_codec,
|
||||
);
|
||||
if (!mainSupported && subSupported) {
|
||||
governor.markMainUnplayable();
|
||||
setAutoLowQuality(true);
|
||||
setAutoLowReason("codec");
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveData) {
|
||||
setAutoLowQuality(true);
|
||||
setAutoLowReason("saveData");
|
||||
return;
|
||||
}
|
||||
|
||||
// a fully cold device also starts low: the conservative start shows
|
||||
// a first frame in seconds and the armed probe recovers full
|
||||
// quality within a few segment loads on connections that allow it
|
||||
const coldStart = governor.bandwidthEstimate === undefined;
|
||||
if (!coldStart && !governor.shouldStartLow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoLowQuality(true);
|
||||
setAutoLowReason("bandwidth");
|
||||
governor.armUpswitchProbe();
|
||||
}, [
|
||||
camera,
|
||||
coverage,
|
||||
estimateLoaded,
|
||||
persistedEstimate,
|
||||
resolvedQuality,
|
||||
subAvailable,
|
||||
governor,
|
||||
]);
|
||||
|
||||
// time-to-first-frame budget; the stall clock is blind before
|
||||
// playback starts, so an oversized first segment would spin forever
|
||||
const sourceLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
sourceLoadedRef.current = false;
|
||||
}, [source]);
|
||||
useEffect(() => {
|
||||
if (!source || isScrubbing || sourceLoadedRef.current) {
|
||||
governor.sourceLoadEnded();
|
||||
return;
|
||||
}
|
||||
governor.sourceLoadStarted();
|
||||
}, [source, isScrubbing, governor]);
|
||||
|
||||
useEffect(() => {
|
||||
// a chunk boundary is where full quality may be retried, and a
|
||||
// natural point to persist what the governor has learned
|
||||
setAutoLowQuality((prev) => prev && !governor.shouldRetryMain());
|
||||
persistGovernorRef.current();
|
||||
// we only want to re-evaluate when the playback chunk changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
persistGovernorRef.current();
|
||||
governor.destroy();
|
||||
};
|
||||
// governor is a stable per-mount instance
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const effectiveQuality: PlaybackQuality =
|
||||
resolvedQuality === "auto" && autoLowQuality ? "sub" : resolvedQuality;
|
||||
|
||||
const onStallStart = useCallback(() => governor.stallStarted(), [governor]);
|
||||
const onStallEnd = useCallback(() => governor.stallEnded(), [governor]);
|
||||
const onSeekStart = useCallback(() => governor.noteSeek(), [governor]);
|
||||
const onFatalNetworkError = useCallback(
|
||||
() => governor.fatalNetworkError(),
|
||||
[governor],
|
||||
);
|
||||
const onFatalCodecError = useCallback(
|
||||
() => governor.fatalCodecError(),
|
||||
[governor],
|
||||
);
|
||||
const onBandwidthSample = useCallback(
|
||||
(estimateBps: number, levelBitrateBps?: number) =>
|
||||
governor.bandwidthSample(
|
||||
estimateBps,
|
||||
levelBitrateBps,
|
||||
// the merged default route leads with the original stream, so
|
||||
// its samples measure original-quality sustainability
|
||||
effectiveQuality !== "sub",
|
||||
),
|
||||
[governor, effectiveQuality],
|
||||
);
|
||||
|
||||
// the realized timelines mirror the vod manifests exactly, including
|
||||
// keyframe back-snap lead-in at cross-stream hand-offs. Walking wall
|
||||
// lengths instead drifts ~0.5s per hand-off, since the playlist
|
||||
// contains lead-in media the model never knew about
|
||||
const recordings = useMemo<Recording[] | undefined>(() => {
|
||||
const timeline =
|
||||
coverage?.timelines?.[
|
||||
effectiveQuality === "main" || effectiveQuality === "sub"
|
||||
? effectiveQuality
|
||||
: "auto"
|
||||
];
|
||||
|
||||
if (!timeline) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return timeline.map((span) => ({
|
||||
start_time: span.start_time,
|
||||
end_time: span.end_time,
|
||||
duration: span.duration / 1000,
|
||||
})) as Recording[];
|
||||
}, [coverage, effectiveQuality]);
|
||||
|
||||
// lets the effect below tell quality rebuilds apart from chunk changes
|
||||
const prevEffectiveQualityRef = useRef(effectiveQuality);
|
||||
|
||||
useEffect(() => {
|
||||
const qualityChanged = prevEffectiveQualityRef.current !== effectiveQuality;
|
||||
prevEffectiveQualityRef.current = effectiveQuality;
|
||||
|
||||
if (!recordings?.length) {
|
||||
if (recordings?.length == 0) {
|
||||
// drop any stale source so the previous playlist unmounts
|
||||
// instead of playing under the no-recording state
|
||||
setSource(undefined);
|
||||
setNoRecording(true);
|
||||
// with no source nothing will play to clear a pending
|
||||
// camera-switch load, hiding the message behind a preview frame
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// an identical playlist means coverage only refined the seek model;
|
||||
// skip the rebuild so the player is not torn down
|
||||
const streamPath =
|
||||
effectiveQuality === "main" || effectiveQuality === "sub"
|
||||
? `/${effectiveQuality}`
|
||||
: "";
|
||||
const playlist = `${apiHost}vod/${camera}${streamPath}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`;
|
||||
if (!qualityChanged && source?.playlist === playlist) {
|
||||
modelOnlyUpdateRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// a quality switch rebuilds mid-playback, so anchor to the live
|
||||
// playhead rather than the chunk-stale startTimestamp prop. The
|
||||
// controller still holds the OUTGOING timeline here (newPlayback
|
||||
// runs in a later effect), and the timeupdate-throttled lastPlayed
|
||||
// ref lags the frame on screen by up to ~250ms
|
||||
const liveTime = playerRef.current?.currentTime;
|
||||
const livePlayed =
|
||||
qualityChanged && controller && liveTime !== undefined && liveTime > 0
|
||||
? controller.getProgress(liveTime)
|
||||
: undefined;
|
||||
const lastPlayed = livePlayed ?? lastPlayedTimestampRef.current;
|
||||
const anchorTimestamp =
|
||||
qualityChanged &&
|
||||
lastPlayed !== undefined &&
|
||||
lastPlayed >= timeRange.after &&
|
||||
lastPlayed <= timeRange.before
|
||||
? lastPlayed
|
||||
: startTimestamp;
|
||||
sourceAnchorRef.current = anchorTimestamp;
|
||||
|
||||
let startPosition = undefined;
|
||||
|
||||
if (startTimestamp) {
|
||||
if (anchorTimestamp) {
|
||||
const inpointOffset = calculateInpointOffset(
|
||||
recordingParams.after,
|
||||
(recordings || [])[0],
|
||||
);
|
||||
|
||||
startPosition = calculateSeekPosition(
|
||||
startTimestamp,
|
||||
anchorTimestamp,
|
||||
recordings,
|
||||
inpointOffset,
|
||||
);
|
||||
}
|
||||
|
||||
setSource({
|
||||
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
|
||||
playlist,
|
||||
startPosition,
|
||||
});
|
||||
|
||||
// we only want to rebuild the source when the playlist itself changes;
|
||||
// startTimestamp, timeRange, and the anchor refs are read as-of-rebuild
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recordings]);
|
||||
}, [recordings, effectiveQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller || !recordings?.length) {
|
||||
@@ -287,12 +668,28 @@ export default function DynamicVideoPlayer({
|
||||
playerRef.current.autoplay = !isScrubbing;
|
||||
}
|
||||
|
||||
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
|
||||
const modelOnlyUpdate = modelOnlyUpdateRef.current;
|
||||
modelOnlyUpdateRef.current = false;
|
||||
|
||||
// on a source swap the element already has a decoded frame; keep it
|
||||
// visible under the buffering indicator rather than hiding it
|
||||
// behind the preview player like the initial load does
|
||||
const hasDecodedFrame =
|
||||
(playerRef.current?.readyState ?? 0) >=
|
||||
HTMLMediaElement.HAVE_CURRENT_DATA;
|
||||
|
||||
if (!modelOnlyUpdate) {
|
||||
loadingTimeoutRef.current = setTimeout(
|
||||
() => (hasDecodedFrame ? setIsBuffering(true) : setIsLoading(true)),
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
controller.newPlayback({
|
||||
recordings: recordings ?? [],
|
||||
timeRange,
|
||||
});
|
||||
modelTimeRangeRef.current = timeRange;
|
||||
|
||||
// we only want this to change when controller or recordings update
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -356,8 +753,8 @@ export default function DynamicVideoPlayer({
|
||||
playerRef.current?.pause();
|
||||
}
|
||||
|
||||
if (loadingTimeout) {
|
||||
clearTimeout(loadingTimeout);
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
}
|
||||
|
||||
setNoRecording(false);
|
||||
@@ -372,6 +769,16 @@ export default function DynamicVideoPlayer({
|
||||
setIsBuffering(true);
|
||||
}
|
||||
}}
|
||||
onStallStart={onStallStart}
|
||||
onStallEnd={onStallEnd}
|
||||
onSeekStart={onSeekStart}
|
||||
onBandwidthSample={onBandwidthSample}
|
||||
onFatalNetworkError={onFatalNetworkError}
|
||||
onFatalCodecError={onFatalCodecError}
|
||||
initialBandwidthEstimate={governor.bandwidthEstimate}
|
||||
bufferLength={
|
||||
effectiveQuality === "sub" ? SUB_STREAM_BUFFER_LENGTH_S : undefined
|
||||
}
|
||||
isDetailMode={isDetailMode}
|
||||
camera={contextCamera || camera}
|
||||
currentTimeOverride={currentTime}
|
||||
|
||||
Reference in New Issue
Block a user