feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)
- Closes #2556 - Closes #2450 **Key intended features:** - go2rtc compatible - 100% test coverage to significantly improve ability to test, maintain and work around browser weirdnesses (e.g. Safari). - Written from the ground up in the style of the rest of the project. **To use:** - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
+54
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GOP_SAMPLE_WINDOW_SIZE,
|
||||
GOPCadenceEstimator,
|
||||
} from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/gop-cadence-estimator';
|
||||
|
||||
describe('GOPCadenceEstimator', () => {
|
||||
it('should return the default GOP before any samples', () => {
|
||||
expect(new GOPCadenceEstimator().estimateSeconds()).toBe(1);
|
||||
});
|
||||
|
||||
it('should average the interval between buffer advances', () => {
|
||||
const estimator = new GOPCadenceEstimator();
|
||||
estimator.sample(10, new Date(0));
|
||||
estimator.sample(12, new Date(2000));
|
||||
estimator.sample(14, new Date(4000));
|
||||
expect(estimator.estimateSeconds()).toBe(2);
|
||||
});
|
||||
|
||||
it('should ignore updates that do not advance the buffer', () => {
|
||||
const estimator = new GOPCadenceEstimator();
|
||||
estimator.sample(10, new Date(0));
|
||||
estimator.sample(12, new Date(2000));
|
||||
// A trim: same buffered end, later time -> not a delivery interval, and it
|
||||
// must not reset the last-advance timestamp.
|
||||
estimator.sample(12, new Date(5000));
|
||||
estimator.sample(14, new Date(6000));
|
||||
// Intervals are 2s (0 -> 2000) and 4s (2000 -> 6000) -> average 3s.
|
||||
expect(estimator.estimateSeconds()).toBe(3);
|
||||
});
|
||||
|
||||
it('should ignore advances with no elapsed time', () => {
|
||||
const estimator = new GOPCadenceEstimator();
|
||||
estimator.sample(10, new Date(1000));
|
||||
estimator.sample(12, new Date(1000));
|
||||
expect(estimator.estimateSeconds()).toBe(1);
|
||||
});
|
||||
|
||||
it('should evict the oldest sample beyond the window', () => {
|
||||
const estimator = new GOPCadenceEstimator();
|
||||
// A slow 5s interval, then a full window of 1s intervals evicts it.
|
||||
estimator.sample(0, new Date(0));
|
||||
estimator.sample(5, new Date(5000));
|
||||
let time = 5000;
|
||||
let end = 5;
|
||||
for (let i = 0; i < GOP_SAMPLE_WINDOW_SIZE; ++i) {
|
||||
time += 1000;
|
||||
end += 1;
|
||||
estimator.sample(end, new Date(time));
|
||||
}
|
||||
expect(estimator.estimateSeconds()).toBe(1);
|
||||
});
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { LiveEdgeTracker } from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker';
|
||||
import { createStatus } from './test-utils';
|
||||
|
||||
describe('LiveEdgeTracker', () => {
|
||||
it('should use the seek strategy on WebKit', () => {
|
||||
const tracker = new LiveEdgeTracker({ webkit: true });
|
||||
// Far behind: the WebKit strategy seeks to the default 3s hold-back.
|
||||
expect(tracker.next(createStatus(20, 13))).toEqual({ action: 'seek', seconds: 17 });
|
||||
});
|
||||
|
||||
it('should use the playback-rate strategy on other browsers', () => {
|
||||
const tracker = new LiveEdgeTracker({ webkit: false });
|
||||
expect(tracker.next(createStatus(20, 18))).toEqual({ action: 'rate', rate: 1 });
|
||||
});
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
LAG_SAMPLE_WINDOW_SIZE,
|
||||
NonWebKitLiveEdgeStrategy,
|
||||
} from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/non-webkit';
|
||||
import { createStatus } from './test-utils';
|
||||
|
||||
describe('NonWebKitLiveEdgeStrategy', () => {
|
||||
it('should play at realtime when close to the live edge', () => {
|
||||
const strategy = new NonWebKitLiveEdgeStrategy();
|
||||
expect(strategy.next(createStatus(20, 18))).toEqual({ action: 'rate', rate: 1 });
|
||||
});
|
||||
|
||||
it('should nudge the rate up when lag exceeds the stream norm', () => {
|
||||
const strategy = new NonWebKitLiveEdgeStrategy();
|
||||
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
|
||||
strategy.next(createStatus(20, 19));
|
||||
}
|
||||
|
||||
const action = strategy.next(createStatus(20, 15));
|
||||
|
||||
assert(action.action === 'rate');
|
||||
expect(action.rate).toBeGreaterThan(1);
|
||||
expect(action.rate).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should cap the catch-up rate', () => {
|
||||
const strategy = new NonWebKitLiveEdgeStrategy();
|
||||
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
|
||||
strategy.next(createStatus(20, 20));
|
||||
}
|
||||
|
||||
expect(strategy.next(createStatus(60, 10))).toEqual({ action: 'rate', rate: 2 });
|
||||
});
|
||||
|
||||
it('should stay near realtime within the stream normal lag', () => {
|
||||
const strategy = new NonWebKitLiveEdgeStrategy();
|
||||
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
|
||||
strategy.next(createStatus(24, 20));
|
||||
}
|
||||
|
||||
const action = strategy.next(createStatus(24, 20));
|
||||
|
||||
assert(action.action === 'rate');
|
||||
expect(action.rate).toBeCloseTo(1, 2);
|
||||
});
|
||||
|
||||
it('should catch up hard before any baseline samples exist', () => {
|
||||
const strategy = new NonWebKitLiveEdgeStrategy();
|
||||
|
||||
// The very first sample is taken while already catching up, so it is
|
||||
// excluded and there is no average to temper the threshold.
|
||||
expect(strategy.next(createStatus(20, 15, { playbackRate: 2 }))).toEqual({
|
||||
action: 'rate',
|
||||
rate: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop stale lag samples as the stream recovers', () => {
|
||||
const strategy = new NonWebKitLiveEdgeStrategy();
|
||||
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
|
||||
strategy.next(createStatus(26, 20));
|
||||
}
|
||||
// A full window of low lag evicts the earlier high-lag samples.
|
||||
for (let i = 0; i < LAG_SAMPLE_WINDOW_SIZE; ++i) {
|
||||
strategy.next(createStatus(21, 20));
|
||||
}
|
||||
|
||||
const action = strategy.next(createStatus(25, 20));
|
||||
|
||||
assert(action.action === 'rate');
|
||||
expect(action.rate).toBeGreaterThan(1.1);
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export const createStatus = (
|
||||
bufferedEndSeconds: number,
|
||||
currentTimeSeconds: number,
|
||||
options?: { playbackRate?: number; now?: Date },
|
||||
) => ({
|
||||
bufferedEndSeconds,
|
||||
currentTimeSeconds,
|
||||
playbackRate: options?.playbackRate ?? 1,
|
||||
now: options?.now ?? new Date(0),
|
||||
});
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GOP_SAMPLE_WINDOW_SIZE } from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/gop-cadence-estimator';
|
||||
import { WebKitLiveEdgeStrategy } from '../../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/live-edge-tracker/webkit';
|
||||
import { createStatus } from './test-utils';
|
||||
|
||||
describe('WebKitLiveEdgeStrategy', () => {
|
||||
// With no measured cadence the GOP defaults to 1s, so the hold-back is 3s.
|
||||
it('should do nothing within the hold-back band', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
expect(strategy.next(createStatus(20, 18))).toEqual({ action: 'none' });
|
||||
});
|
||||
|
||||
it('should seek back to the hold-back when starving within a GOP of the edge', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
expect(strategy.next(createStatus(20, 19.5))).toEqual({
|
||||
action: 'seek',
|
||||
seconds: 17,
|
||||
});
|
||||
});
|
||||
|
||||
it('should seek forward to the hold-back when far behind', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
expect(strategy.next(createStatus(20, 13))).toEqual({ action: 'seek', seconds: 17 });
|
||||
});
|
||||
|
||||
it('should not seek forward again within the cooldown', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
strategy.next(createStatus(20, 13, { now: new Date(0) }));
|
||||
expect(strategy.next(createStatus(20, 13, { now: new Date(2000) }))).toEqual({
|
||||
action: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('should seek forward again after the cooldown', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
strategy.next(createStatus(20, 13, { now: new Date(0) }));
|
||||
expect(strategy.next(createStatus(20, 13, { now: new Date(6000) }))).toEqual({
|
||||
action: 'seek',
|
||||
seconds: 17,
|
||||
});
|
||||
});
|
||||
|
||||
it('should widen the hold-back to the measured GOP cadence', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
|
||||
// Buffer advances 2s apart -> GOP ~2s -> hold-back 6s. Runs past the sample
|
||||
// window so the oldest samples are evicted. (Sampling happens before the
|
||||
// action, so intermediate seeks do not affect the estimate.)
|
||||
let end = 20;
|
||||
for (let i = 1; i <= GOP_SAMPLE_WINDOW_SIZE + 2; ++i) {
|
||||
end = 20 + i * 2;
|
||||
strategy.next(createStatus(end, end - 4, { now: new Date(i * 2000) }));
|
||||
}
|
||||
|
||||
// A far-behind sample now seeks to bufferedEnd - 6, not - 3.
|
||||
expect(
|
||||
strategy.next(createStatus(end, end - 12, { now: new Date(100000) })),
|
||||
).toEqual({
|
||||
action: 'seek',
|
||||
seconds: end - 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('should clamp the widened hold-back to the maximum', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
|
||||
// Buffer advances 5s apart -> GOP 5s -> 15s, clamped to 8s.
|
||||
let end = 0;
|
||||
for (let i = 1; i <= GOP_SAMPLE_WINDOW_SIZE + 1; ++i) {
|
||||
end = i * 5;
|
||||
strategy.next(createStatus(end, end - 2, { now: new Date(i * 5000) }));
|
||||
}
|
||||
|
||||
expect(
|
||||
strategy.next(createStatus(end, end - 19, { now: new Date(100000) })),
|
||||
).toEqual({
|
||||
action: 'seek',
|
||||
seconds: end - 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('should clamp the shrunken hold-back to the minimum', () => {
|
||||
const strategy = new WebKitLiveEdgeStrategy();
|
||||
|
||||
// Buffer advances 0.3s apart -> GOP 0.3s -> 0.9s, clamped to 1.5s.
|
||||
let end = 20;
|
||||
for (let i = 1; i <= GOP_SAMPLE_WINDOW_SIZE + 1; ++i) {
|
||||
end = 20 + i * 0.3;
|
||||
strategy.next(createStatus(end, end - 0.1, { now: new Date(i * 300) }));
|
||||
}
|
||||
|
||||
expect(strategy.next(createStatus(end, end - 3, { now: new Date(100000) }))).toEqual(
|
||||
{
|
||||
action: 'seek',
|
||||
seconds: end - 1.5,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user