Skip to content

Commit ec2e57a

Browse files
authored
Merge pull request #5 from kaltura/fix/issue-3-stall-detection
fix: detect and signal stalled frame delivery via a watchdog
2 parents 3906281 + da8cf67 commit ec2e57a

3 files changed

Lines changed: 176 additions & 7 deletions

File tree

src/chromakey.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ export const DEFAULTS = Object.freeze({
9090
/** Pixel budget for the CPU fallback; frames are processed downscaled to fit. */
9191
maxCPUPixels: 512 * 512,
9292

93+
/**
94+
* Milliseconds of no decoded-frame progress (while playing) before firing
95+
* `'stalled'` — catches a track that goes silent while readyState/paused/
96+
* ended still look healthy (e.g. a WebRTC failure). `<= 0` disables it.
97+
*/
98+
stallTimeout: 4000,
99+
93100
/** Attributes applied to the <video> element created when `source` is a URL. */
94101
videoAttributes: Object.freeze({
95102
muted: true,
@@ -103,6 +110,9 @@ export const DEFAULTS = Object.freeze({
103110
/** Options that select shader variants / backends / plugins and can't change after construction. */
104111
const IMMUTABLE_OPTIONS = ['channel', 'forceCanvas2D', 'videoAttributes', 'autoTune'];
105112

113+
/** Watchdog polling cadence for stall detection (see `_checkStall`). Fixed; `stallTimeout` is the tunable. */
114+
const STALL_CHECK_INTERVAL_MS = 500;
115+
106116
function clamp(x, lo, hi) { return x < lo ? lo : x > hi ? hi : x; }
107117

108118
/** GLSL-compatible smoothstep, used by the CPU fallback fade ramps. */
@@ -631,6 +641,12 @@ function syncAspectRatio(target, video, state) {
631641
* - `'autotune'` — detail: the result object of every {@link autoTune} run.
632642
* - `'pluginerror'` — detail: `{ plugin, error }`, fired when a plugin hook
633643
* throws (the plugin is detached; the player keeps running).
644+
* - `'stalled'` — detail: `{ elapsedMs }`. Fired once when no new decoded
645+
* frame has arrived for `options.stallTimeout` ms while playing (see
646+
* issue #3). The canvas keeps showing the last good frame; the page
647+
* decides what to do (spinner, reconnect, etc).
648+
* - `'recovered'` — detail: `{ elapsedMs }`. Fired once frame delivery
649+
* resumes after a `'stalled'` event.
634650
*/
635651
export class ChromaKeyVideo extends EventTarget {
636652
/**
@@ -700,6 +716,11 @@ export class ChromaKeyVideo extends EventTarget {
700716

701717
this._startLoop();
702718

719+
this._lastFrameQualityCount = -1;
720+
this._stallStartedAt = 0;
721+
this._isStalled = false;
722+
this._stallWatchdog = setInterval(() => this._checkStall(), STALL_CHECK_INTERVAL_MS);
723+
703724
if (this.options.autoTune) {
704725
this.use(autoTunePlugin({ adaptive: this.options.autoTune === 'adaptive' }));
705726
}
@@ -928,6 +949,7 @@ export class ChromaKeyVideo extends EventTarget {
928949
this.video.cancelVideoFrameCallback(this._rvfcHandle);
929950
}
930951
if (this._rafHandle) cancelAnimationFrame(this._rafHandle);
952+
if (this._stallWatchdog) clearInterval(this._stallWatchdog);
931953
if (this._resizeObserver) this._resizeObserver.disconnect();
932954
for (const type of ['loadedmetadata', 'resize', 'loadeddata', 'seeked', 'error']) {
933955
this.video.removeEventListener(type, this._onVideoEvent);
@@ -1045,6 +1067,45 @@ export class ChromaKeyVideo extends EventTarget {
10451067
syncAspectRatio(this.canvas, this.video, this);
10461068
}
10471069

1070+
/**
1071+
* Watchdog tick: fires `'stalled'`/`'recovered'` off decoded-frame progress
1072+
* (see `options.stallTimeout`). Uses `getVideoPlaybackQuality()` rather
1073+
* than `currentTime` — for a live MediaStream, `currentTime` keeps
1074+
* advancing on the stream's own clock even when no new frame is decoded,
1075+
* so it would miss exactly the stall this exists to catch. See issue #3.
1076+
*/
1077+
_checkStall() {
1078+
const timeout = this.options.stallTimeout;
1079+
if (!timeout || timeout <= 0) return;
1080+
if (typeof this.video.getVideoPlaybackQuality !== 'function') return;
1081+
if (this.video.paused || this.video.ended) {
1082+
this._stallStartedAt = 0;
1083+
return;
1084+
}
1085+
1086+
const count = this.video.getVideoPlaybackQuality().totalVideoFrames;
1087+
if (count !== this._lastFrameQualityCount) {
1088+
this._lastFrameQualityCount = count;
1089+
this._stallStartedAt = 0;
1090+
if (this._isStalled) {
1091+
this._isStalled = false;
1092+
this.dispatchEvent(new CustomEvent('recovered', { detail: { elapsedMs: this._stalledElapsedMs } }));
1093+
}
1094+
return;
1095+
}
1096+
1097+
if (!this._stallStartedAt) {
1098+
this._stallStartedAt = Date.now();
1099+
return;
1100+
}
1101+
const elapsedMs = Date.now() - this._stallStartedAt;
1102+
if (elapsedMs >= timeout && !this._isStalled) {
1103+
this._isStalled = true;
1104+
this._stalledElapsedMs = elapsedMs;
1105+
this.dispatchEvent(new CustomEvent('stalled', { detail: { elapsedMs } }));
1106+
}
1107+
}
1108+
10481109
/** Redundant DOM-level bottom fade (defense in depth alongside the shader fade). */
10491110
_applyCssFade() {
10501111
const wanted = this.options.edgeDissolve && this.options.cssFade && this.options.fadeBottom > 0;
@@ -1306,6 +1367,7 @@ const ELEMENT_OPTION_ATTRIBUTES = {
13061367
'fade-top': ['fadeTop', Number],
13071368
'fade-bottom': ['fadeBottom', Number],
13081369
'max-pixel-ratio': ['maxPixelRatio', Number],
1370+
'stall-timeout': ['stallTimeout', Number],
13091371
};
13101372

13111373
/**
@@ -1314,7 +1376,7 @@ const ELEMENT_OPTION_ATTRIBUTES = {
13141376
* Supported attributes: `src` (required), `autoplay`, `loop`, `muted`,
13151377
* `channel`, `min-key`, `bias`, `softness`, `spill`, `edge-dissolve`,
13161378
* `auto-tune` (empty = once, `"adaptive"` = continuous), `fade-top`,
1317-
* `fade-bottom`, `max-pixel-ratio`.
1379+
* `fade-bottom`, `max-pixel-ratio`, `stall-timeout`.
13181380
*
13191381
* The underlying player is exposed as the element's `.player` property for
13201382
* full programmatic control (events, `update()`, the video element, etc.).

test/e2e.spec.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ function backend(page, id) {
4444
return page.evaluate((pid) => window.players[pid].player.backend, id);
4545
}
4646

47+
// Resolves with the detail of the next `type` event on player `id`, or
48+
// rejects if it doesn't fire within `timeoutMs`.
49+
function waitForEvent(page, id, type, timeoutMs = 5000) {
50+
return page.evaluate(([pid, t, ms]) => new Promise((resolve, reject) => {
51+
const { player } = window.players[pid];
52+
const timer = setTimeout(() => reject(new Error(`"${t}" never fired`)), ms);
53+
player.addEventListener(t, (e) => { clearTimeout(timer); resolve(e.detail); }, { once: true });
54+
}), [id, type, timeoutMs]);
55+
}
56+
57+
// Resolves with whether `type` fired on player `id` within `waitMs`.
58+
function didFire(page, id, type, waitMs) {
59+
return page.evaluate(([pid, t, ms]) => new Promise((resolve) => {
60+
const { player } = window.players[pid];
61+
let seen = false;
62+
player.addEventListener(t, () => { seen = true; }, { once: true });
63+
setTimeout(() => resolve(seen), ms);
64+
}), [id, type, waitMs]);
65+
}
66+
4767
test.describe('chroma-key-video', () => {
4868
test.beforeEach(async ({ page }) => {
4969
await openFixture(page);
@@ -450,4 +470,64 @@ test.describe('chroma-key-video', () => {
450470
expect(result.bg[3]).toBeLessThanOrEqual(30);
451471
expect(result.fg[3]).toBeGreaterThan(220);
452472
});
473+
474+
test('fires "stalled" when frame delivery stops, then "recovered" once it resumes (issue #3)', async ({ page }) => {
475+
const id = await create(page, { stallTimeout: 700 }, { fps: 0 });
476+
477+
const healthBefore = await page.evaluate((pid) => {
478+
const v = window.players[pid].player.video;
479+
return { paused: v.paused, ended: v.ended };
480+
}, id);
481+
expect(healthBefore.paused).toBe(false);
482+
expect(healthBefore.ended).toBe(false);
483+
484+
const stalledPromise = waitForEvent(page, id, 'stalled', 5000);
485+
await page.evaluate((pid) => window.stopSource(pid), id);
486+
const stalledDetail = await stalledPromise;
487+
expect(stalledDetail.elapsedMs).toBeGreaterThanOrEqual(700);
488+
489+
// readyState/paused/ended still look healthy — that's the whole point:
490+
// the video element gives no other signal that frames stopped arriving.
491+
const healthDuringStall = await page.evaluate((pid) => {
492+
const v = window.players[pid].player.video;
493+
return { paused: v.paused, ended: v.ended };
494+
}, id);
495+
expect(healthDuringStall.paused).toBe(false);
496+
expect(healthDuringStall.ended).toBe(false);
497+
498+
const recoveredPromise = waitForEvent(page, id, 'recovered', 5000);
499+
await page.evaluate((pid) => window.resumeSource(pid), id);
500+
await recoveredPromise;
501+
});
502+
503+
test('does not fire "stalled" during uninterrupted playback (issue #3)', async ({ page }) => {
504+
const id = await create(page, { stallTimeout: 300 }, { fps: 0 });
505+
expect(await didFire(page, id, 'stalled', 1500)).toBe(false);
506+
});
507+
508+
test('does not fire "stalled" while intentionally paused (issue #3)', async ({ page }) => {
509+
const id = await create(page, { stallTimeout: 300 }, { fps: 0 });
510+
await page.evaluate((pid) => {
511+
window.players[pid].player.pause();
512+
window.stopSource(pid);
513+
}, id);
514+
expect(await didFire(page, id, 'stalled', 1500)).toBe(false);
515+
});
516+
517+
test('stallTimeout <= 0 disables stall detection (issue #3)', async ({ page }) => {
518+
const id = await create(page, { stallTimeout: 0 }, { fps: 0 });
519+
await page.evaluate((pid) => window.stopSource(pid), id);
520+
expect(await didFire(page, id, 'stalled', 1500)).toBe(false);
521+
});
522+
523+
test('stalling one instance does not affect another (issue #3)', async ({ page }) => {
524+
const a = await create(page, { stallTimeout: 700 }, { fps: 0 });
525+
const b = await create(page, { stallTimeout: 700 }, { fps: 0 });
526+
527+
const stalledA = waitForEvent(page, a, 'stalled', 5000);
528+
await page.evaluate((pid) => window.stopSource(pid), a);
529+
await stalledA;
530+
531+
expect(await didFire(page, b, 'stalled', 500)).toBe(false);
532+
});
453533
});

test/fixture.html

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,24 +43,28 @@
4343

4444
// captureStream only emits frames on draw commits, so keep redrawing the
4545
// (identical) pattern on an interval to feed the video a steady frame rate.
46-
function makePatternStream(channel = 'green', w = W, h = H) {
46+
// fps=0 uses the no-arg, draw-triggered captureStream() instead of a fixed
47+
// capture rate, so stopping the redraw interval genuinely stops new frames
48+
// from being produced (rather than the fixed-rate capture re-encoding the
49+
// same static content) — needed to simulate a real stalled track.
50+
function makePatternStream(channel = 'green', w = W, h = H, fps = 30) {
4751
const canvas = document.createElement('canvas');
4852
canvas.width = w;
4953
canvas.height = h;
5054
const ctx = canvas.getContext('2d');
5155
drawPattern(ctx, channel);
52-
const stream = canvas.captureStream(30);
56+
const stream = fps ? canvas.captureStream(fps) : canvas.captureStream();
5357
const timer = setInterval(() => drawPattern(ctx, channel), 50);
54-
return { stream, canvas, timer };
58+
return { stream, canvas, timer, ctx };
5559
}
5660

5761
// Create a player from a synthetic pattern stream, mount it, wait for the
5862
// first rendered frame, and return its handle id. srcWidth/srcHeight set the
5963
// pattern canvas's (i.e. the video's native) resolution, independent of the
6064
// player canvas's CSS display size (cssWidth/cssHeight).
61-
window.createPlayer = (options = {}, { channel = 'green', cssWidth = W, cssHeight = H, srcWidth = W, srcHeight = H } = {}) => {
65+
window.createPlayer = (options = {}, { channel = 'green', cssWidth = W, cssHeight = H, srcWidth = W, srcHeight = H, fps = 30 } = {}) => {
6266
return new Promise((resolve, reject) => {
63-
const { stream, canvas, timer } = makePatternStream(channel, srcWidth, srcHeight);
67+
const { stream, canvas, timer, ctx } = makePatternStream(channel, srcWidth, srcHeight, fps);
6468
const player = new ChromaKeyVideo(stream, {
6569
maxPixelRatio: 1,
6670
videoAttributes: { ...DEFAULTS.videoAttributes, autoplay: true },
@@ -70,7 +74,7 @@
7074
player.canvas.style.height = cssHeight + 'px';
7175
player.mount(document.getElementById('stage'));
7276
const id = String(nextId++);
73-
window.players[id] = { player, timer, sourceCanvas: canvas, channel };
77+
window.players[id] = { player, timer, sourceCanvas: canvas, sourceCtx: ctx, channel };
7478
const timeout = setTimeout(() => reject(new Error('player never started')), 10000);
7579
player.addEventListener('started', () => {
7680
// Let a couple more frames land so sampling sees steady-state output.
@@ -81,6 +85,29 @@
8185
});
8286
};
8387

88+
// Stops feeding new frames into a player's source stream, simulating a
89+
// track that goes silent while still reporting healthy paused/ended/readyState.
90+
window.stopSource = (id) => {
91+
clearInterval(window.players[id].timer);
92+
window.players[id].timer = 0;
93+
};
94+
95+
// Resumes feeding frames after stopSource().
96+
window.resumeSource = (id) => {
97+
const entry = window.players[id];
98+
if (entry.timer) return;
99+
entry.timer = setInterval(() => drawPattern(entry.sourceCtx, entry.channel), 50);
100+
};
101+
102+
// Reports the video's decoded-frame counter, for diagnosing whether the
103+
// stall-detection signal (getVideoPlaybackQuality().totalVideoFrames) is
104+
// actually advancing/frozen in this browser/environment.
105+
window.playbackQuality = (id) => {
106+
const v = window.players[id].player.video;
107+
if (typeof v.getVideoPlaybackQuality !== 'function') return null;
108+
return v.getVideoPlaybackQuality().totalVideoFrames;
109+
};
110+
84111
// Resizes a player's source pattern canvas, simulating a live track's native
85112
// resolution changing mid-stream (e.g. a WebRTC renegotiation). The video
86113
// element fires its native 'resize' event once the new dimensions decode.

0 commit comments

Comments
 (0)