@@ -21,6 +21,22 @@ function createDefaultMockOptions(): MockOptions {
2121 } ;
2222}
2323
24+ /**
25+ * Controls the timer used for the canvas drawing loop that feeds captureStream.
26+ *
27+ * - `Auto` — uses `setInterval` when `requestAnimationFrame` may be throttled
28+ * (detected by checking `document.hidden`), otherwise uses `requestAnimationFrame`.
29+ * - `Raf` — always uses `requestAnimationFrame`.
30+ * - `SetInterval` — always uses `setInterval`. More reliable in headless/virtual
31+ * display environments (e.g. xvfb) where webkit throttles rAF for inactive pages,
32+ * causing `captureStream` to stop emitting frames.
33+ */
34+ export enum TimerMode {
35+ Auto = "auto" ,
36+ Raf = "raf" ,
37+ SetInterval = "setInterval" ,
38+ }
39+
2440export interface Settings {
2541 /**
2642 * The media url to use for the mock. Video or image.
@@ -49,6 +65,14 @@ export interface Settings {
4965 * @default 60000 (60 seconds)
5066 */
5167 mediaTimeout : number ;
68+
69+ /**
70+ * Timer strategy for the canvas drawing loop.
71+ * @type {TimerMode }
72+ * @default TimerMode.SetInterval
73+ * @see TimerMode
74+ */
75+ timerMode : TimerMode ;
5276}
5377
5478function isVideoURL ( url : string ) {
@@ -216,6 +240,7 @@ export class MediaMockClass {
216240 constraints : devices [ "iPhone 12" ] . supportedConstraints ,
217241 canvasScaleFactor : 1 ,
218242 mediaTimeout : 60 * 1000 , // 60 seconds
243+ timerMode : TimerMode . SetInterval ,
219244 } ;
220245
221246 private readonly mediaMockImageId = "media-mock-image" ;
@@ -279,18 +304,35 @@ export class MediaMockClass {
279304 this . currentVideo . pause ( ) ;
280305 this . currentVideo . src = "" ;
281306 }
307+ // Remove previous image from DOM if present
308+ if ( this . currentImage ?. parentNode ) {
309+ this . currentImage . remove ( ) ;
310+ }
282311 this . currentImage = media ;
283312 this . currentVideo = undefined ;
313+ // Append the image to the document offscreen. Keeping the source image in
314+ // the DOM prevents some webkit versions from evicting its decoded pixel data
315+ // from GPU memory, which would cause drawImage to produce blank frames.
316+ if ( typeof document !== "undefined" && document . body ) {
317+ media . style . cssText =
318+ "position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none" ;
319+ media . setAttribute ( "aria-hidden" , "true" ) ;
320+ document . body . append ( media ) ;
321+ }
284322 } else if ( media instanceof HTMLVideoElement ) {
285323 if ( this . currentImage ) {
286324 this . currentImage . src = "" ;
325+ if ( this . currentImage . parentNode ) {
326+ this . currentImage . remove ( ) ;
327+ }
287328 }
288329 this . currentVideo = media ;
289330 this . currentImage = undefined ;
290331 }
291332
292- // Restart drawing with new media if stream is active
293- // Check both intervalId (setInterval) and rafId (RequestAnimationFrame)
333+ // Restart drawing with new media if stream is active.
334+ // startDrawingLoop redraws immediately, so the canvas (and captureStream track)
335+ // picks up the new image content without any gap.
294336 if ( this . intervalId !== null || this . rafId !== null ) {
295337 await this . startDrawingLoop ( ) ;
296338 }
@@ -347,16 +389,14 @@ export class MediaMockClass {
347389 this . lastDrawTime = now ;
348390 }
349391
350- if ( isRAFSupported ( ) ) {
392+ if ( this . resolveTimerMode ( ) === TimerMode . Raf && isRAFSupported ( ) ) {
351393 this . rafId = requestAnimationFrame ( drawFrame ) ;
352394 }
353395 } ;
354396
355- if ( isRAFSupported ( ) ) {
356- // Use RequestAnimationFrame for better performance
397+ if ( this . resolveTimerMode ( ) === TimerMode . Raf && isRAFSupported ( ) ) {
357398 this . rafId = requestAnimationFrame ( drawFrame ) ;
358399 } else {
359- // Fallback to setInterval for browsers without RAF
360400 this . intervalId = setInterval ( ( ) => {
361401 if ( ! this . ctx || ! this . currentVideo ) {
362402 return ;
@@ -428,18 +468,26 @@ export class MediaMockClass {
428468 scaledHeight ,
429469 ) ;
430470
431- // For images, schedule next draw with RequestAnimationFrame
432- if ( isRAFSupported ( ) ) {
471+ // Schedule next draw via the resolved timer mode
472+ if ( this . resolveTimerMode ( ) === TimerMode . Raf && isRAFSupported ( ) ) {
433473 this . rafId = requestAnimationFrame ( drawImage ) ;
434474 }
435475 } ;
436476
437- if ( isRAFSupported ( ) ) {
438- // Use RequestAnimationFrame for images
439- this . rafId = requestAnimationFrame ( drawImage ) ;
477+ // Draw the first frame synchronously so captureStream sees content immediately.
478+ drawImage ( ) ;
479+
480+ // Force pixel data commitment by reading back one pixel. On some webkit versions,
481+ // drawing a cached Image to a freshly-created canvas doesn't immediately commit
482+ // the pixel data — a getImageData call flushes pending GPU commands before
483+ // captureStream is started.
484+ this . ctx ?. getImageData ( 0 , 0 , 1 , 1 ) ;
485+
486+ const frameInterval = 1000 / this . fps ;
487+ if ( this . resolveTimerMode ( ) === TimerMode . Raf && isRAFSupported ( ) ) {
488+ // rAF chain already primed by the synchronous drawImage() call above
440489 } else {
441- // Fallback to setInterval for browsers without RAF
442- const frameInterval = 1000 / this . fps ;
490+ // setInterval fires reliably even when rAF is throttled (e.g. webkit under xvfb)
443491 this . intervalId = setInterval ( drawImage , frameInterval ) ;
444492 }
445493 }
@@ -498,12 +546,10 @@ export class MediaMockClass {
498546 public enableDebugMode ( ) : typeof MediaMock {
499547 this . debug = true ;
500548
501- if (
502- this . canvas != null &&
503- document . querySelector ( this . mediaMockCanvasId ) == null
504- ) {
505- this . canvas . style . border = "10px solid red" ;
506- document . body . append ( this . canvas ) ;
549+ // The canvas is already attached to the DOM (hidden offscreen) by getMockStream.
550+ // In debug mode we just flip it visible and add a red border.
551+ if ( this . canvas != null ) {
552+ this . applyVisibleCanvasStyles ( this . canvas ) ;
507553 }
508554
509555 if (
@@ -518,32 +564,65 @@ export class MediaMockClass {
518564 }
519565
520566 /**
521- * Removes the debug canvas and image from the body.
567+ * Hides the source canvas again (keeps it in the DOM offscreen so captureStream
568+ * keeps working) and removes any debug-only `<img>` element from the body.
522569 *
523570 * @public
524571 * @returns {typeof MediaMock }
525572 */
526573 public disableDebugMode ( ) : typeof MediaMock {
527574 this . debug = false ;
528- const canvas = document . getElementById ( this . mediaMockCanvasId ) ;
529- const image = document . getElementById ( this . mediaMockImageId ) ;
530575
531- canvas ?. remove ( ) ;
532- image ?. remove ( ) ;
576+ if ( this . canvas != null ) {
577+ this . applyHiddenCanvasStyles ( this . canvas ) ;
578+ }
579+
580+ const imageElement = document . getElementById ( this . mediaMockImageId ) ;
581+ imageElement ?. remove ( ) ;
533582
534- // Also remove from stored references if they exist
535583 if ( this . currentImage ?. parentNode ) {
536584 this . currentImage . style . border = "" ;
537585 this . currentImage . remove ( ) ;
538586 }
539- if ( this . canvas ?. parentNode ) {
540- this . canvas . style . border = "" ;
541- this . canvas . remove ( ) ;
542- }
543587
544588 return this ;
545589 }
546590
591+ /**
592+ * Positions the source canvas offscreen but keeps it at its natural drawing-
593+ * buffer size so captureStream sees a non-zero rendering rectangle. On some
594+ * webkit versions, shrinking the displayed canvas with CSS width/height causes
595+ * the captured track's intrinsic dimensions to collapse — so we move it offscreen
596+ * via `left: -9999px` instead of resizing it.
597+ */
598+ private applyHiddenCanvasStyles ( canvas : HTMLCanvasElement ) : void {
599+ canvas . style . position = "fixed" ;
600+ canvas . style . top = "0" ;
601+ canvas . style . left = "-9999px" ;
602+ canvas . style . width = "" ;
603+ canvas . style . height = "" ;
604+ canvas . style . opacity = "0" ;
605+ canvas . style . pointerEvents = "none" ;
606+ canvas . style . border = "" ;
607+ canvas . setAttribute ( "aria-hidden" , "true" ) ;
608+ }
609+
610+ /**
611+ * Restores the source canvas to its natural size and makes it visible (used by
612+ * debug mode).
613+ */
614+ private applyVisibleCanvasStyles ( canvas : HTMLCanvasElement ) : void {
615+ canvas . style . position = "" ;
616+ canvas . style . top = "" ;
617+ canvas . style . left = "" ;
618+ canvas . style . width = "" ;
619+ canvas . style . height = "" ;
620+ canvas . style . opacity = "" ;
621+ canvas . style . pointerEvents = "" ;
622+ canvas . style . border = "10px solid red" ;
623+ canvas . removeAttribute ( "aria-hidden" ) ;
624+ }
625+
547626 public setMockedVideoTracksHandler (
548627 mockedVideoTracksHandler : (
549628 tracks : MediaStreamTrack [ ] ,
@@ -641,6 +720,9 @@ export class MediaMockClass {
641720 this . currentVideo = undefined ;
642721 }
643722 if ( this . currentImage ) {
723+ if ( this . currentImage . parentNode ) {
724+ this . currentImage . remove ( ) ;
725+ }
644726 this . currentImage . src = "" ;
645727 this . currentImage = undefined ;
646728 }
@@ -685,13 +767,46 @@ export class MediaMockClass {
685767 return this ;
686768 }
687769
770+ /**
771+ * Set the timer strategy used for the canvas drawing loop.
772+ *
773+ * @public
774+ * @param {TimerMode } mode - TimerMode.Auto | TimerMode.Raf | TimerMode.SetInterval
775+ * @returns {typeof MediaMock }
776+ */
777+ public setTimerMode ( mode : TimerMode ) : typeof MediaMock {
778+ this . settings . timerMode = mode ;
779+ return this ;
780+ }
781+
782+ /**
783+ * Resolves the effective timer mode. "auto" uses setInterval when the document
784+ * is hidden (e.g. under xvfb where webkit throttles rAF), otherwise rAF.
785+ */
786+ private resolveTimerMode ( ) : TimerMode . Raf | TimerMode . SetInterval {
787+ if ( this . settings . timerMode === TimerMode . Raf ) return TimerMode . Raf ;
788+ if ( this . settings . timerMode === TimerMode . SetInterval ) return TimerMode . SetInterval ;
789+ // Auto: fall back to setInterval when the page may not be compositing
790+ const pageHidden =
791+ typeof document !== "undefined" &&
792+ typeof document . hidden !== "undefined" &&
793+ document . hidden ;
794+ return pageHidden || ! isRAFSupported ( ) ? TimerMode . SetInterval : TimerMode . Raf ;
795+ }
796+
688797 private async getMockStream (
689798 constraints : MediaStreamConstraints ,
690799 ) : Promise < MediaStream > {
691800 this . resolution = this . getResolution ( constraints , this . settings . device ) ;
692801
693802 this . fps = this . getFPSFromConstraints ( constraints ) ;
694803
804+ // Remove any prior canvas from the DOM before swapping in a new one (e.g. when
805+ // a consumer calls getUserMedia again to switch cameras).
806+ if ( this . canvas ?. parentNode ) {
807+ this . canvas . remove ( ) ;
808+ }
809+
695810 this . canvas = document . createElement ( "canvas" ) ;
696811 this . canvas . id = this . mediaMockCanvasId ;
697812
@@ -708,6 +823,17 @@ export class MediaMockClass {
708823 this . ctx . fillStyle = "#ffffff" ;
709824 this . ctx . fillRect ( 0 , 0 , width , height ) ;
710825
826+ // The canvas must live in the document for HTMLCanvasElement.captureStream() to
827+ // produce a track whose intrinsic dimensions stay stable. On some browser
828+ // versions (e.g. WebKit 26-class running under xvfb), a detached canvas
829+ // produces a track whose videoWidth/videoHeight transiently report valid values
830+ // and then drop to 2×2, breaking <video> consumers. Hidden offscreen by default;
831+ // enableDebugMode() makes it visible.
832+ this . applyHiddenCanvasStyles ( this . canvas ) ;
833+ if ( typeof document . body !== "undefined" && document . body !== null ) {
834+ document . body . append ( this . canvas ) ;
835+ }
836+
711837 await this . setMediaURL ( this . settings . mediaURL ) ;
712838 await this . startDrawingLoop ( ) ;
713839
0 commit comments