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:
+232
@@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageSurfaceController } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/image-surface-controller';
|
||||
import { createLitElement, flushPromises } from '../../../../test-utils';
|
||||
|
||||
const createFrame = (): Blob => new Blob(['frame'], { type: 'image/jpeg' });
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ImageSurfaceController', () => {
|
||||
let nextId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
nextId = 0;
|
||||
URL.createObjectURL = vi.fn(() => `blob:mock-${nextId++}`);
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
const createdURLs = (): string[] =>
|
||||
vi.mocked(URL.createObjectURL).mock.results.map((result) => result.value);
|
||||
|
||||
// A connected visible <img> plus a detached loader whose decode() is stubbed
|
||||
// (jsdom has none). The loader is injected so a test can hold a decode in
|
||||
// flight to exercise coalescing.
|
||||
const createSurfaceController = (options?: {
|
||||
connected?: boolean;
|
||||
decode?: () => Promise<void>;
|
||||
}) => {
|
||||
const image = document.createElement('img');
|
||||
if (options?.connected !== false) {
|
||||
document.body.appendChild(image);
|
||||
}
|
||||
const decoder = document.createElement('img');
|
||||
decoder.decode = vi.fn(options?.decode ?? (() => Promise.resolve()));
|
||||
const controller = new ImageSurfaceController(createLitElement(), () => image, {
|
||||
createImage: () => decoder,
|
||||
});
|
||||
return { image, decoder, controller };
|
||||
};
|
||||
|
||||
describe('controller', () => {
|
||||
it('should expose an image media player controller', () => {
|
||||
const controller = new ImageSurfaceController(createLitElement(), () => null);
|
||||
|
||||
expect(controller.getMediaPlayer().getPIPElement()).toBeNull();
|
||||
});
|
||||
|
||||
it('should have liveness when given liveness options', () => {
|
||||
const controller = new ImageSurfaceController(createLitElement(), () => null, {
|
||||
livenessOptions: { isFrameExpected: () => true, stallWindowSeconds: 10 },
|
||||
});
|
||||
|
||||
expect(controller.getMediaPlayer().subscribeLiveness).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have no liveness without liveness options', () => {
|
||||
const controller = new ImageSurfaceController(createLitElement(), () => null);
|
||||
|
||||
expect(controller.getMediaPlayer().subscribeLiveness).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose the image element', () => {
|
||||
const { image, controller } = createSurfaceController();
|
||||
|
||||
expect(controller.getElement()).toBe(image);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFrame', () => {
|
||||
it('should decode a frame off-DOM and show it via an object URL', async () => {
|
||||
const { image, decoder, controller } = createSurfaceController();
|
||||
|
||||
await controller.showFrame(createFrame());
|
||||
|
||||
expect(decoder.decode).toBeCalledTimes(1);
|
||||
expect(URL.createObjectURL).toBeCalledTimes(1);
|
||||
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
|
||||
expect(URL.revokeObjectURL).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should revoke the previous frame when showing the next', async () => {
|
||||
const { image, controller } = createSurfaceController();
|
||||
|
||||
await controller.showFrame(createFrame());
|
||||
await controller.showFrame(createFrame());
|
||||
|
||||
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
|
||||
expect(URL.revokeObjectURL).toBeCalledTimes(1);
|
||||
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
|
||||
});
|
||||
|
||||
it('should keep showing the previous frame until the next has decoded', async () => {
|
||||
let releaseSecond: () => void = () => {};
|
||||
let calls = 0;
|
||||
const { image, controller } = createSurfaceController({
|
||||
decode: () => {
|
||||
calls++;
|
||||
return calls >= 2
|
||||
? new Promise<void>((resolve) => (releaseSecond = resolve))
|
||||
: Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
await controller.showFrame(createFrame());
|
||||
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
|
||||
|
||||
controller.showFrame(createFrame());
|
||||
await flushPromises();
|
||||
|
||||
// The next frame is still decoding off-DOM, so the visible <img> is
|
||||
// untouched and the previous URL stays valid (revoking it would blank it).
|
||||
expect(image.getAttribute('src')).toBe(createdURLs()[0]);
|
||||
expect(URL.revokeObjectURL).not.toBeCalled();
|
||||
|
||||
releaseSecond();
|
||||
await flushPromises();
|
||||
|
||||
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
|
||||
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
|
||||
});
|
||||
|
||||
it('should present only the newest frame while a decode is in flight', async () => {
|
||||
let releaseDecode: () => void = () => {};
|
||||
const decoding = new Promise<void>((resolve) => (releaseDecode = resolve));
|
||||
const { image, controller } = createSurfaceController({ decode: () => decoding });
|
||||
|
||||
controller.showFrame(createFrame());
|
||||
controller.showFrame(createFrame());
|
||||
controller.showFrame(createFrame());
|
||||
await flushPromises();
|
||||
|
||||
// Only the first frame's decode has started; the rest wait behind it.
|
||||
expect(URL.createObjectURL).toBeCalledTimes(1);
|
||||
|
||||
releaseDecode();
|
||||
await flushPromises();
|
||||
|
||||
// The middle frame was superseded, so only the newest is presented next.
|
||||
expect(URL.createObjectURL).toBeCalledTimes(2);
|
||||
expect(image.getAttribute('src')).toBe(createdURLs()[1]);
|
||||
});
|
||||
|
||||
it('should drop an undecodable frame and keep the current one', async () => {
|
||||
const { image, controller } = createSurfaceController({
|
||||
decode: () => Promise.reject(new Error('bad frame')),
|
||||
});
|
||||
|
||||
await controller.showFrame(createFrame());
|
||||
|
||||
expect(image.hasAttribute('src')).toBe(false);
|
||||
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
|
||||
});
|
||||
|
||||
it('should not paint a frame that decoded after the surface detached', async () => {
|
||||
let releaseDecode: () => void = () => {};
|
||||
const { image, controller } = createSurfaceController({
|
||||
decode: () => new Promise<void>((resolve) => (releaseDecode = resolve)),
|
||||
});
|
||||
|
||||
controller.showFrame(createFrame());
|
||||
await flushPromises();
|
||||
|
||||
image.remove();
|
||||
releaseDecode();
|
||||
await flushPromises();
|
||||
|
||||
expect(image.hasAttribute('src')).toBe(false);
|
||||
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
|
||||
});
|
||||
|
||||
it('should do nothing without an image element', async () => {
|
||||
const controller = new ImageSurfaceController(createLitElement(), () => null);
|
||||
|
||||
await controller.showFrame(createFrame());
|
||||
|
||||
expect(URL.createObjectURL).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not paint onto a detached element', async () => {
|
||||
const { controller } = createSurfaceController({ connected: false });
|
||||
|
||||
await controller.showFrame(createFrame());
|
||||
|
||||
expect(URL.createObjectURL).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should revoke the current object URL and clear the image', async () => {
|
||||
const { image, controller } = createSurfaceController();
|
||||
await controller.showFrame(createFrame());
|
||||
|
||||
controller.reset();
|
||||
|
||||
expect(URL.revokeObjectURL).toBeCalledWith(createdURLs()[0]);
|
||||
expect(image.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear the image with no frame shown', () => {
|
||||
const { image, controller } = createSurfaceController();
|
||||
|
||||
controller.reset();
|
||||
|
||||
expect(URL.revokeObjectURL).not.toBeCalled();
|
||||
expect(image.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('should drop a frame still decoding so it never paints', async () => {
|
||||
let releaseDecode: () => void = () => {};
|
||||
const { image, controller } = createSurfaceController({
|
||||
decode: () => new Promise<void>((resolve) => (releaseDecode = resolve)),
|
||||
});
|
||||
|
||||
controller.showFrame(createFrame());
|
||||
await flushPromises();
|
||||
|
||||
controller.reset();
|
||||
releaseDecode();
|
||||
await flushPromises();
|
||||
|
||||
// The in-flight frame was invalidated by reset, so it never painted.
|
||||
expect(image.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing without an image element', () => {
|
||||
const controller = new ImageSurfaceController(createLitElement(), () => null);
|
||||
|
||||
expect(() => controller.reset()).not.toThrow();
|
||||
expect(URL.revokeObjectURL).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user