Skip to content

Commit 26a9777

Browse files
authored
cameraCapture: Simplify codepaths, try to wait for a real frame (#4365)
- Don't use fallback paths in camera capture; instead, have the test pick the exact path it's using, and parameterize over it. Test two paths: VideoFrame from MediaStreamTrackProcessor, and the old-fashioned HTMLVideoElement. - Work around an issue where Chrome (at least on Mac) shows blank frames for a while after initializing the camera. - I happened to notice that requesting different width/height from the camera is broken in Chrome in several ways, so added cases for that. - Added a TODO for copyExternalImageToTexture from camera These tests only worked in Chrome, and even then they were buggy (usually running the test on a blank first frame). Now, the HTMLVideoElement tests work in Safari. Test failures are as follows, on an M1 Mac: - Chrome: https://crbug.com/411656657 - HTMLVideoElement passes some cases, but fails when a requested size is passed to getUserMedia(). - VideoFrame fails due to incorrect color management (regardless of dstColorSpace). - Safari: - HTMLVideoElement fails due to incorrect color management (regardless of dstColorSpace). - VideoFrame is skipped. - Firefox: doesn't yet implement importExternalTexture. Issue: fixes #4363, cc #4364
1 parent c09e68c commit 26a9777

2 files changed

Lines changed: 172 additions & 50 deletions

File tree

src/webgpu/web_platform/external_texture/video.spec.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ Tests for external textures from HTMLVideoElement (and other video-type sources?
66
77
TODO: consider whether external_texture and copyToTexture video tests should be in the same file
88
TODO(#3193): Test video in BT.2020 color space
9+
TODO(#4364): Test camera capture with copyExternalImageToTexture (not necessarily in this file)
910
`;
1011

1112
import { makeTestGroup } from '../../../common/framework/test_group.js';
13+
import { unreachable } from '../../../common/util/util.js';
1214
import { GPUTest } from '../../gpu_test.js';
1315
import * as ttu from '../../texture_test_utils.js';
1416
import { TextureUploadingUtils } from '../../util/copy_to_texture.js';
@@ -17,12 +19,13 @@ import {
1719
startPlayingAndWaitForVideo,
1820
getVideoFrameFromVideoElement,
1921
getVideoElement,
20-
captureCameraFrame,
2122
convertToUnorm8,
2223
kPredefinedColorSpace,
2324
kVideoNames,
2425
kVideoInfo,
2526
kVideoExpectedColors,
27+
getVideoElementFromCamera,
28+
getVideoFrameFromCamera,
2629
} from '../../web_platform/util.js';
2730

2831
const kHeight = 16;
@@ -633,34 +636,64 @@ compared with 2d canvas rendering result.
633636
`
634637
)
635638
.params(u =>
639+
// NOTE: Be careful not to add too many parameters here, this test is SLOW to initialize!
636640
u //
637641
.combineWithParams(checkNonStandardIsZeroCopyIfAvailable())
642+
.combine('path', ['HTMLVideoElement', 'MediaStreamTrackProcessor'] as const)
638643
.combine('dstColorSpace', kPredefinedColorSpace)
644+
.combine('constraints', [
645+
true,
646+
{ width: 64, height: 48 },
647+
{ width: 100, height: 300 },
648+
] as const)
639649
)
640650
.fn(async t => {
641-
const { dstColorSpace } = t.params;
642-
643-
const frame = await captureCameraFrame(t);
644-
645-
if (frame.displayHeight === 0 || frame.displayWidth === 0) {
646-
t.skip('Captured video frame has 0 height or width.');
651+
const { path, dstColorSpace, constraints } = t.params;
652+
653+
// Enable this while debugging to show the "actual" and "expected" canvases on screen.
654+
const kDebugShowCanvasesOnScreen = false;
655+
656+
let source: HTMLVideoElement | VideoFrame;
657+
let frameWidth: number, frameHeight: number;
658+
switch (path) {
659+
case 'HTMLVideoElement': {
660+
const video = await getVideoElementFromCamera(t, constraints, true);
661+
frameWidth = video.videoWidth;
662+
frameHeight = video.videoHeight;
663+
source = video;
664+
break;
665+
}
666+
case 'MediaStreamTrackProcessor': {
667+
const frame = await getVideoFrameFromCamera(t, constraints);
668+
frameWidth = frame.displayWidth;
669+
frameHeight = frame.displayHeight;
670+
source = frame;
671+
break;
672+
}
673+
default:
674+
unreachable();
647675
}
648676

649-
const frameWidth = frame.displayWidth;
650-
const frameHeight = frame.displayHeight;
651-
652677
// Use WebGPU + GPUExternalTexture to render the captured frame.
653-
const colorAttachment = t.createTextureTracked({
678+
const webgpuCanvas = createCanvas(t, 'onscreen', frameWidth, frameHeight);
679+
if (kDebugShowCanvasesOnScreen) {
680+
document.body.append(document.createElement('br'));
681+
document.body.append(webgpuCanvas);
682+
}
683+
684+
const webgpuContext = webgpuCanvas.getContext('webgpu')!;
685+
webgpuContext.configure({
686+
device: t.device,
654687
format: kFormat,
655-
size: { width: frameWidth, height: frameHeight },
656-
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
688+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
657689
});
690+
const colorAttachment = webgpuContext.getCurrentTexture();
658691

659692
const pipeline = createExternalTextureSamplingTestPipeline(t);
660693
const bindGroup = createExternalTextureSamplingTestBindGroup(
661694
t,
662695
t.params.checkNonStandardIsZeroCopy,
663-
frame,
696+
source,
664697
pipeline,
665698
dstColorSpace
666699
);
@@ -684,6 +717,9 @@ compared with 2d canvas rendering result.
684717

685718
// Use 2d context canvas as expected result.
686719
const canvas = createCanvas(t, 'onscreen', frameWidth, frameHeight);
720+
if (kDebugShowCanvasesOnScreen) {
721+
document.body.append(canvas);
722+
}
687723

688724
const canvasContext = canvas.getContext('2d', { colorSpace: dstColorSpace });
689725

@@ -692,7 +728,7 @@ compared with 2d canvas rendering result.
692728
}
693729

694730
const ctx = canvasContext as CanvasRenderingContext2D;
695-
ctx.drawImage(frame, 0, 0, frameWidth, frameHeight);
731+
ctx.drawImage(source, 0, 0, frameWidth, frameHeight);
696732

697733
const imageData = ctx.getImageData(0, 0, frameWidth, frameHeight, {
698734
colorSpace: dstColorSpace,

src/webgpu/web_platform/util.ts

Lines changed: 121 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Fixture, SkipTestCase } from '../../common/framework/fixture.js';
22
import { getResourcePath } from '../../common/framework/resources.js';
33
import { keysOf } from '../../common/util/data_tables.js';
44
import { timeout } from '../../common/util/timeout.js';
5-
import { ErrorWithExtra, raceWithRejectOnTimeout } from '../../common/util/util.js';
5+
import { ErrorWithExtra, assert, raceWithRejectOnTimeout } from '../../common/util/util.js';
66
import { GPUTest } from '../gpu_test.js';
77
import { RGBA, srgbToDisplayP3 } from '../util/color_space_conversion.js';
88

@@ -559,59 +559,145 @@ function callbackHelper(
559559
}
560560

561561
/**
562-
* Create VideoFrame from camera captured frame. Check whether browser environment has
563-
* camera supported.
564-
* Returns a webcodec VideoFrame.
565-
*
566-
* @param test: GPUTest that requires getting VideoFrame
567-
*
562+
* Get a MediaStream from the default webcam via `getUserMedia()`.
568563
*/
569-
export async function captureCameraFrame(test: GPUTest): Promise<VideoFrame> {
564+
async function getStreamFromCamera(
565+
test: Fixture,
566+
videoTrackConstraints: MediaTrackConstraints | true
567+
): Promise<MediaStream> {
570568
test.skipIf(typeof navigator === 'undefined', 'navigator does not exist in this environment');
571569
test.skipIf(
572570
typeof navigator.mediaDevices === 'undefined' ||
573571
typeof navigator.mediaDevices.getUserMedia === 'undefined',
574572
"Browser doesn't support capture frame from camera."
575573
);
576574

577-
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
578-
const track = stream.getVideoTracks()[0] as MediaStreamVideoTrack;
579-
580-
test.skipIf(!track, "Doesn't have valid camera captured stream for testing.");
575+
const stream = await navigator.mediaDevices.getUserMedia({
576+
audio: false,
577+
video: videoTrackConstraints,
578+
});
579+
test.trackForCleanup({
580+
close() {
581+
for (const track of stream.getTracks()) {
582+
track.stop();
583+
}
584+
},
585+
});
586+
return stream;
587+
}
581588

582-
// Use MediaStreamTrackProcessor and ReadableStream to generate video frame directly.
583-
if (typeof MediaStreamTrackProcessor !== 'undefined') {
584-
const trackProcessor = new MediaStreamTrackProcessor({ track });
585-
const reader = trackProcessor.readable.getReader();
586-
const result = await reader.read();
587-
if (result.done) {
588-
test.skip('MediaStreamTrackProcessor: Cannot get valid frame from readable stream.');
589+
/**
590+
* Chrome on macOS (at least) takes a while before it switches from blank frames
591+
* to real frames. Wait up to 50 frames for something to show up on the camera.
592+
*/
593+
async function waitForNonBlankFrame({
594+
getSource,
595+
waitForNextFrame,
596+
}: {
597+
getSource: () => HTMLVideoElement | VideoFrame;
598+
waitForNextFrame: () => Promise<void>;
599+
}) {
600+
const cvs = document.createElement('canvas');
601+
[cvs.width, cvs.height] = [4, 4];
602+
const ctx = cvs.getContext('2d', { willReadFrequently: true })!;
603+
let foundNonBlankFrame = false;
604+
for (let i = 0; i < 50; ++i) {
605+
ctx.drawImage(getSource(), 0, 0, cvs.width, cvs.height);
606+
const pixels = new Uint32Array(ctx.getImageData(0, 0, cvs.width, cvs.height).data.buffer);
607+
// Look only at RGB, ignore alpha.
608+
if (pixels.some(p => (p & 0x00ffffff) !== 0)) {
609+
foundNonBlankFrame = true;
610+
break;
589611
}
590-
591-
return result.value;
592-
}
593-
594-
// Fallback to ImageCapture if MediaStreamTrackProcessor not supported. Using grabFrame() to
595-
// generate imageBitmap and creating video frame from it.
596-
if (typeof ImageCapture !== 'undefined') {
597-
const imageCapture = new ImageCapture(track);
598-
const imageBitmap = await imageCapture.grabFrame();
599-
return new VideoFrame(imageBitmap);
612+
await waitForNextFrame();
600613
}
614+
assert(foundNonBlankFrame, 'Failed to get a non-blank video frame');
615+
}
601616

602-
// Fallback to using HTMLVideoElement to do capture.
617+
/**
618+
* Uses MediaStreamTrackProcessor to capture a VideoFrame from the camera.
619+
* Skips the test if not supported.
620+
* @param videoTrackConstraints - MediaTrackConstraints (e.g. width/height) to pass to
621+
* `getUserMedia()`, or `true` if none.
622+
*/
623+
export async function getVideoFrameFromCamera(
624+
test: Fixture,
625+
videoTrackConstraints: MediaTrackConstraints | true
626+
): Promise<VideoFrame> {
603627
test.skipIf(
604-
typeof HTMLVideoElement === 'undefined',
605-
'Try to use HTMLVideoElement do capture but HTMLVideoElement not available.'
628+
typeof MediaStreamTrackProcessor === 'undefined',
629+
'MediaStreamTrackProcessor not supported'
606630
);
607631

632+
const stream = await getStreamFromCamera(test, videoTrackConstraints);
633+
const tracks = stream.getVideoTracks();
634+
assert(tracks.length > 0, 'no tracks found');
635+
const track = tracks[0] as MediaStreamVideoTrack;
636+
637+
const trackProcessor = new MediaStreamTrackProcessor({ track });
638+
const reader = trackProcessor.readable.getReader();
639+
640+
const waitForNextFrame = async () => {
641+
const result = await reader.read();
642+
assert(!result.done, "MediaStreamTrackProcessor: Couldn't get valid frame from stream.");
643+
return result.value;
644+
};
645+
let frame: VideoFrame = await waitForNextFrame();
646+
await waitForNonBlankFrame({
647+
getSource: () => frame,
648+
async waitForNextFrame() {
649+
frame.close();
650+
frame = await waitForNextFrame();
651+
},
652+
});
653+
654+
test.trackForCleanup(frame);
655+
return frame;
656+
}
657+
658+
/**
659+
* Create an HTMLVideoElement from the camera stream. Skips the test if not supported.
660+
* @param videoTrackConstraints - MediaTrackConstraints (e.g. width/height) to pass to
661+
* `getUserMedia()`, or `true` if none.
662+
* @param paused - whether the video should be paused before returning.
663+
*/
664+
export async function getVideoElementFromCamera(
665+
test: Fixture,
666+
videoTrackConstraints: MediaTrackConstraints | true,
667+
paused: boolean
668+
): Promise<HTMLVideoElement> {
669+
const stream = await getStreamFromCamera(test, videoTrackConstraints);
670+
671+
// Main thread
608672
const video = document.createElement('video');
673+
video.loop = false;
674+
video.muted = true;
675+
video.setAttribute('playsinline', '');
609676
video.srcObject = stream;
677+
await new Promise(resolve => {
678+
video.onloadedmetadata = resolve;
679+
});
680+
await startPlayingAndWaitForVideo(video, () => {});
610681

611-
const frame = await getVideoFrameFromVideoElement(test, video);
612-
test.trackForCleanup(frame);
682+
await waitForNonBlankFrame({
683+
getSource: () => video,
684+
waitForNextFrame: () =>
685+
new Promise(resolve =>
686+
video.requestVideoFrameCallback(() => {
687+
resolve();
688+
})
689+
),
690+
});
613691

614-
return frame;
692+
if (paused) {
693+
// Pause the video so we get consistent readbacks.
694+
await new Promise(resolve => {
695+
video.onpause = resolve;
696+
video.pause();
697+
});
698+
}
699+
700+
return video;
615701
}
616702

617703
const kFourColorsInfo = {

0 commit comments

Comments
 (0)