Skip to content

Commit cbf1865

Browse files
authored
Merge pull request #43 from eatsjobs/fix/deviceId-constraint
add timer mode setting
2 parents e25fd8c + 2761133 commit cbf1865

4 files changed

Lines changed: 279 additions & 52 deletions

File tree

.changeset/big-seas-say.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@eatsjobs/media-mock": minor
3+
---
4+
5+
Add TimerMode setting

lib/loadImage.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,40 @@ export async function loadImage(
33
timeoutMs: number = 60 * 1000,
44
): Promise<HTMLImageElement> {
55
const image = new Image();
6-
image.src = imageURL;
7-
try {
8-
// Race between decode and timeout to prevent hanging indefinitely
9-
await Promise.race([
10-
image.decode(),
11-
new Promise<never>((_, reject) =>
12-
setTimeout(
13-
() =>
14-
reject(
15-
new Error(`Image load timeout after ${timeoutMs / 1000} seconds`),
16-
),
17-
timeoutMs,
6+
7+
const timeout = new Promise<never>((_, reject) =>
8+
setTimeout(
9+
() =>
10+
reject(
11+
new Error(`Image load timeout after ${timeoutMs / 1000} seconds`),
1812
),
19-
),
20-
]);
13+
timeoutMs,
14+
),
15+
);
16+
17+
try {
18+
// 1. Set src and wait for the network fetch to complete (load event).
19+
const loadPromise = new Promise<void>((resolve, reject) => {
20+
image.onload = () => resolve();
21+
image.onerror = (error: unknown) =>
22+
reject(new Error(`Failed to load image: ${imageURL}: ${error}`));
23+
});
24+
image.src = imageURL;
25+
await Promise.race([loadPromise, timeout]);
26+
27+
// 2. Decode the image: ensures pixel data is fully decoded before use.
28+
// decode() can be called after load; the browser may have already
29+
// started decoding during the fetch.
30+
await Promise.race([image.decode(), timeout]);
31+
32+
// 3. Force pixel data into CPU-accessible memory. On some webkit versions,
33+
// decode() resolves before the pixel data is ready for canvas drawImage —
34+
// a 1×1 warmup draw commits it immediately.
35+
const warmup = document.createElement("canvas");
36+
warmup.width = 1;
37+
warmup.height = 1;
38+
warmup.getContext("2d")?.drawImage(image, 0, 0, 1, 1);
39+
2140
return image;
2241
} catch (error: unknown) {
2342
throw new Error(`Failed to load image: ${imageURL}. Details: ${error}`);

lib/main.ts

Lines changed: 155 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2440
export 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

5478
function 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

Comments
 (0)