export const description = `
Tests for external textures from HTMLVideoElement (and other video-type sources?).
- videos with various encodings/formats (webm vp8, webm vp9, ogg theora, mp4), video color spaces
(bt.601, bt.709, bt.2020) and dst color spaces(display-p3, srgb)
TODO: consider whether external_texture and copyToTexture video tests should be in the same file
TODO(#3193): Test video in BT.2020 color space
TODO(#4364): Test camera capture with copyExternalImageToTexture (not necessarily in this file)
TODO(#4605): Test importExternalTexture with video frame display size different with coded size from
a video file
`;
import { makeTestGroup } from '../../../common/framework/test_group.js';
import { unreachable } from '../../../common/util/util.js';
import { GPUTest } from '../../gpu_test.js';
import * as ttu from '../../texture_test_utils.js';
import { TextureUploadingUtils } from '../../util/copy_to_texture.js';
import { createCanvas } from '../../util/create_elements.js';
import {
startPlayingAndWaitForVideo,
getVideoFrameFromVideoElement,
getVideoElement,
convertToUnorm8,
kPredefinedColorSpace,
kVideoNames,
kVideoInfo,
kVideoExpectedColors,
getVideoElementFromCamera,
getVideoFrameFromCamera,
} from '../../web_platform/util.js';
const kHeight = 16;
const kWidth = 16;
const kFormat = 'rgba8unorm';
export const g = makeTestGroup(TextureUploadingUtils);
function createExternalTextureSamplingTestPipeline(
t: GPUTest,
colorAttachmentFormat: GPUTextureFormat = kFormat
): GPURenderPipeline {
const pipeline = t.device.createRenderPipeline({
layout: 'auto',
vertex: {
module: t.device.createShaderModule({
code: `
struct VertexOutput {
@builtin(position) Position : vec4f,
@location(0) fragUV : vec2f,
}
@vertex fn main(@builtin(vertex_index) VertexIndex : u32) -> VertexOutput {
const pos = array(
vec2( 1.0, 1.0),
vec2( 1.0, -1.0),
vec2(-1.0, -1.0),
vec2( 1.0, 1.0),
vec2(-1.0, -1.0),
vec2(-1.0, 1.0),
);
const uv = array(
vec2(1.0, 0.0),
vec2(1.0, 1.0),
vec2(0.0, 1.0),
vec2(1.0, 0.0),
vec2(0.0, 1.0),
vec2(0.0, 0.0),
);
var output : VertexOutput;
output.Position = vec4(pos[VertexIndex], 0.0, 1.0);
output.fragUV = uv[VertexIndex];
return output;
}
`,
}),
entryPoint: 'main',
},
fragment: {
module: t.device.createShaderModule({
code: `
@group(0) @binding(0) var s : sampler;
@group(0) @binding(1) var t : texture_external;
@fragment fn main(@location(0) fragUV : vec2f)
-> @location(0) vec4f {
return textureSampleBaseClampToEdge(t, s, fragUV);
}
`,
}),
entryPoint: 'main',
targets: [
{
format: colorAttachmentFormat,
},
],
},
primitive: { topology: 'triangle-list' },
});
return pipeline;
}
function createExternalTextureSamplingTestBindGroup(
t: GPUTest,
checkNonStandardIsZeroCopy: true | undefined,
source: HTMLVideoElement | VideoFrame,
pipeline: GPURenderPipeline,
dstColorSpace: PredefinedColorSpace
): GPUBindGroup {
const linearSampler = t.device.createSampler();
const externalTexture = t.device.importExternalTexture({
source,
colorSpace: dstColorSpace,
});
if (checkNonStandardIsZeroCopy) {
expectZeroCopyNonStandard(t, externalTexture);
}
const bindGroup = t.device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: linearSampler,
},
{
binding: 1,
resource: externalTexture,
},
],
});
return bindGroup;
}
* Expect the non-standard `externalTexture.isZeroCopy` is true.
*/
function expectZeroCopyNonStandard(t: GPUTest, externalTexture: GPUExternalTexture): void {
t.expect((externalTexture as any).isZeroCopy, '0-copy import failed.');
}
* `externalTexture.isZeroCopy` is a non-standard Chrome API for testing only.
* It is exposed by enabling chrome://flags/#enable-webgpu-developer-features
*
* If the API is available, this function adds a parameter `checkNonStandardIsZeroCopy`.
* Cases with that parameter set to `true` will fail if `externalTexture.isZeroCopy` is not true.
*/
function checkNonStandardIsZeroCopyIfAvailable(): { checkNonStandardIsZeroCopy?: true }[] {
if (
typeof GPUExternalTexture !== 'undefined' &&
GPUExternalTexture.prototype.hasOwnProperty('isZeroCopy')
) {
return [{}, { checkNonStandardIsZeroCopy: true }];
} else {
return [{}];
}
}
function createVideoFrameWithDisplayScale(
t: GPUTest,
displayScale: 'smaller' | 'same' | 'larger'
): { frame: VideoFrame; displayWidth: number; displayHeight: number } {
const canvas = createCanvas(t, 'onscreen', kWidth, kHeight);
const canvasContext = canvas.getContext('2d');
if (canvasContext === null) {
t.skip(' onscreen canvas 2d context not available');
}
const ctx = canvasContext;
const rectWidth = Math.floor(kWidth / 2);
const rectHeight = Math.floor(kHeight / 2);
ctx.fillStyle = `rgba(255, 0, 0, 1.0)`;
ctx.fillRect(0, 0, rectWidth, rectHeight);
ctx.fillStyle = `rgba(0, 255, 0, 1.0)`;
ctx.fillRect(rectWidth, 0, kWidth - rectWidth, rectHeight);
ctx.fillStyle = `rgba(0, 0, 255, 1.0)`;
ctx.fillRect(0, rectHeight, rectWidth, kHeight - rectHeight);
ctx.fillStyle = `rgba(255, 0, 255, 1.0)`;
ctx.fillRect(rectWidth, rectHeight, kWidth - rectWidth, kHeight - rectHeight);
const imageData = ctx.getImageData(0, 0, kWidth, kHeight);
let displayWidth = kWidth;
let displayHeight = kHeight;
switch (displayScale) {
case 'smaller':
displayWidth = Math.floor(kWidth / 2);
displayHeight = Math.floor(kHeight / 2);
break;
case 'same':
displayWidth = kWidth;
displayHeight = kHeight;
break;
case 'larger':
displayWidth = kWidth * 2;
displayHeight = kHeight * 2;
break;
default:
unreachable();
}
const frameInit: VideoFrameBufferInit = {
format: 'RGBA',
codedWidth: kWidth,
codedHeight: kHeight,
displayWidth,
displayHeight,
timestamp: 0,
};
const frame = new VideoFrame(imageData.data.buffer, frameInit);
return { frame, displayWidth, displayHeight };
}
g.test('importExternalTexture,sample')
.desc(
`
Tests that we can import an HTMLVideoElement/VideoFrame into a GPUExternalTexture, sample from it
for several combinations of video format, video color spaces and dst color spaces.
`
)
.params(u =>
u
.combineWithParams(checkNonStandardIsZeroCopyIfAvailable())
.combine('videoName', kVideoNames)
.combine('sourceType', ['VideoElement', 'VideoFrame'] as const)
.combine('dstColorSpace', kPredefinedColorSpace)
)
.fn(async t => {
const { videoName, sourceType, dstColorSpace } = t.params;
if (sourceType === 'VideoFrame' && typeof VideoFrame === 'undefined') {
t.skip('WebCodec is not supported');
}
const videoElement = getVideoElement(t, videoName);
await startPlayingAndWaitForVideo(videoElement, async () => {
const source =
sourceType === 'VideoFrame'
? await getVideoFrameFromVideoElement(t, videoElement)
: videoElement;
const colorAttachment = t.createTextureTracked({
format: kFormat,
size: { width: kWidth, height: kHeight, depthOrArrayLayers: 1 },
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
});
const pipeline = createExternalTextureSamplingTestPipeline(t);
const bindGroup = createExternalTextureSamplingTestBindGroup(
t,
t.params.checkNonStandardIsZeroCopy,
source,
pipeline,
dstColorSpace
);
const commandEncoder = t.device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: colorAttachment.createView(),
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(6);
passEncoder.end();
t.device.queue.submit([commandEncoder.finish()]);
const srcColorSpace = kVideoInfo[videoName].colorSpace;
const presentColors = kVideoExpectedColors[srcColorSpace][dstColorSpace];
const expect = kVideoInfo[videoName].display;
ttu.expectSinglePixelComparisonsAreOkInTexture(t, { texture: colorAttachment }, [
{
coord: { x: kWidth * 0.25, y: kHeight * 0.25 },
exp: convertToUnorm8(presentColors[expect.topLeftColor]),
},
{
coord: { x: kWidth * 0.75, y: kHeight * 0.25 },
exp: convertToUnorm8(presentColors[expect.topRightColor]),
},
{
coord: { x: kWidth * 0.25, y: kHeight * 0.75 },
exp: convertToUnorm8(presentColors[expect.bottomLeftColor]),
},
{
coord: { x: kWidth * 0.75, y: kHeight * 0.75 },
exp: convertToUnorm8(presentColors[expect.bottomRightColor]),
},
]);
});
});
g.test('importExternalTexture,sample_non_YUV_video_frame')
.desc(
`
Tests that we can import an VideoFrame with non-YUV pixel format into a GPUExternalTexture and sample it.
`
)
.params(u =>
u
.combine('videoFrameFormat', ['RGBA', 'RGBX', 'BGRA', 'BGRX'] as const)
)
.fn(t => {
const { videoFrameFormat } = t.params;
if (typeof VideoFrame === 'undefined') {
t.skip('WebCodec is not supported');
}
const canvas = createCanvas(t, 'onscreen', kWidth, kHeight);
const canvasContext = canvas.getContext('2d');
if (canvasContext === null) {
t.skip(' onscreen canvas 2d context not available');
}
const ctx = canvasContext as CanvasRenderingContext2D;
const rectWidth = Math.floor(kWidth / 2);
const rectHeight = Math.floor(kHeight / 2);
ctx.fillStyle = `rgba(255, 0, 0, 1.0)`;
ctx.fillRect(0, 0, rectWidth, rectHeight);
ctx.fillStyle = `rgba(0, 255, 0, 1.0)`;
ctx.fillRect(rectWidth, 0, kWidth - rectWidth, rectHeight);
ctx.fillStyle = `rgba(0, 0, 255, 1.0)`;
ctx.fillRect(0, rectHeight, rectWidth, kHeight - rectHeight);
ctx.fillStyle = `rgba(255, 0, 255, 1.0)`;
ctx.fillRect(rectWidth, rectHeight, kWidth - rectWidth, kHeight - rectHeight);
const imageData = ctx.getImageData(0, 0, kWidth, kHeight);
const frameInit: VideoFrameBufferInit = {
format: videoFrameFormat,
codedWidth: kWidth,
codedHeight: kHeight,
timestamp: 0,
};
const frame = new VideoFrame(imageData.data.buffer, frameInit);
let textureFormat: GPUTextureFormat = 'rgba8unorm';
if (videoFrameFormat === 'BGRA' || videoFrameFormat === 'BGRX') {
textureFormat = 'bgra8unorm';
}
const colorAttachment = t.createTextureTracked({
format: textureFormat,
size: { width: kWidth, height: kHeight, depthOrArrayLayers: 1 },
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
});
const pipeline = createExternalTextureSamplingTestPipeline(t, textureFormat);
const bindGroup = createExternalTextureSamplingTestBindGroup(
t,
undefined ,
frame,
pipeline,
'srgb'
);
const commandEncoder = t.device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: colorAttachment.createView(),
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(6);
passEncoder.end();
t.device.queue.submit([commandEncoder.finish()]);
const expected = {
topLeft: new Uint8Array([255, 0, 0, 255]),
topRight: new Uint8Array([0, 255, 0, 255]),
bottomLeft: new Uint8Array([0, 0, 255, 255]),
bottomRight: new Uint8Array([255, 0, 255, 255]),
};
ttu.expectSinglePixelComparisonsAreOkInTexture(t, { texture: colorAttachment }, [
{
coord: { x: kWidth * 0.25, y: kHeight * 0.25 },
exp: expected.topLeft,
},
{
coord: { x: kWidth * 0.75, y: kHeight * 0.25 },
exp: expected.topRight,
},
{
coord: { x: kWidth * 0.25, y: kHeight * 0.75 },
exp: expected.bottomLeft,
},
{
coord: { x: kWidth * 0.75, y: kHeight * 0.75 },
exp: expected.bottomRight,
},
]);
});
g.test('importExternalTexture,video_frame_display_size_diff_with_coded_size')
.desc(
`
Tests that we can import a VideoFrame with display size different with its coded size, and
sampling works without validation errors.
`
)
.params(u =>
u
.combine('displayScale', ['smaller', 'same', 'larger'] as const)
)
.fn(t => {
if (typeof VideoFrame === 'undefined') {
t.skip('WebCodec is not supported');
}
const { frame } = createVideoFrameWithDisplayScale(t, t.params.displayScale);
const colorAttachment = t.createTextureTracked({
format: kFormat,
size: { width: kWidth, height: kHeight, depthOrArrayLayers: 1 },
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
});
const pipeline = createExternalTextureSamplingTestPipeline(t, kFormat);
const bindGroup = createExternalTextureSamplingTestBindGroup(
t,
undefined ,
frame,
pipeline,
'srgb'
);
const commandEncoder = t.device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: colorAttachment.createView(),
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(6);
passEncoder.end();
t.device.queue.submit([commandEncoder.finish()]);
const expected = {
topLeft: new Uint8Array([255, 0, 0, 255]),
topRight: new Uint8Array([0, 255, 0, 255]),
bottomLeft: new Uint8Array([0, 0, 255, 255]),
bottomRight: new Uint8Array([255, 0, 255, 255]),
};
ttu.expectSinglePixelComparisonsAreOkInTexture(t, { texture: colorAttachment }, [
{
coord: { x: kWidth * 0.25, y: kHeight * 0.25 },
exp: expected.topLeft,
},
{
coord: { x: kWidth * 0.75, y: kHeight * 0.25 },
exp: expected.topRight,
},
{
coord: { x: kWidth * 0.25, y: kHeight * 0.75 },
exp: expected.bottomLeft,
},
{
coord: { x: kWidth * 0.75, y: kHeight * 0.75 },
exp: expected.bottomRight,
},
]);
frame.close();
});
g.test('importExternalTexture,video_frame_display_size_from_textureDimensions')
.desc(
`
Tests that textureDimensions() for texture_external matches VideoFrame display size.
`
)
.params(u =>
u
.combine('displayScale', ['smaller', 'same', 'larger'] as const)
)
.fn(t => {
if (typeof VideoFrame === 'undefined') {
t.skip('WebCodec is not supported');
}
const { frame, displayWidth, displayHeight } = createVideoFrameWithDisplayScale(
t,
t.params.displayScale
);
const externalTexture = t.device.importExternalTexture({
source: frame,
colorSpace: 'srgb',
});
const storageBuffer = t.createBufferTracked({
size: 8,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
const pipeline = t.device.createComputePipeline({
layout: 'auto',
compute: {
module: t.device.createShaderModule({
code: `
@group(0) @binding(0) var t : texture_external;
@group(0) @binding(1) var<storage, read_write> outDims : array<u32>;
@compute @workgroup_size(1) fn main() {
let d = textureDimensions(t);
outDims[0] = d.x;
outDims[1] = d.y;
}
`,
}),
entryPoint: 'main',
},
});
const bindGroup = t.device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: externalTexture },
{ binding: 1, resource: { buffer: storageBuffer } },
],
});
const encoder = t.device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(1);
pass.end();
t.device.queue.submit([encoder.finish()]);
t.expectGPUBufferValuesEqual(storageBuffer, new Uint32Array([displayWidth, displayHeight]));
frame.close();
});
g.test('importExternalTexture,sampleWithVideoFrameWithVisibleRectParam')
.desc(
`
Tests that we can import VideoFrames and sample the correct sub-rectangle when visibleRect
parameters are present.
`
)
.params(u =>
u
.combineWithParams(checkNonStandardIsZeroCopyIfAvailable())
.combine('videoName', kVideoNames)
.combine('dstColorSpace', kPredefinedColorSpace)
)
.fn(async t => {
const { videoName, dstColorSpace } = t.params;
const videoElement = getVideoElement(t, videoName);
await startPlayingAndWaitForVideo(videoElement, async () => {
const source = await getVideoFrameFromVideoElement(t, videoElement);
const visibleRect = source.visibleRect!;
const srcColorSpace = kVideoInfo[videoName].colorSpace;
const presentColors = kVideoExpectedColors[srcColorSpace][dstColorSpace];
const expect = kVideoInfo[videoName].coded;
const cropParams = [
{
subRect: {
x: visibleRect.x,
y: visibleRect.y,
width: visibleRect.width / 2,
height: visibleRect.height / 2,
},
color: convertToUnorm8(presentColors[expect.topLeftColor]),
},
{
subRect: {
x: visibleRect.x + visibleRect.width / 2,
y: visibleRect.y,
width: visibleRect.width / 2,
height: visibleRect.height / 2,
},
color: convertToUnorm8(presentColors[expect.topRightColor]),
},
{
subRect: {
x: visibleRect.x,
y: visibleRect.y + visibleRect.height / 2,
width: visibleRect.width / 2,
height: visibleRect.height / 2,
},
color: convertToUnorm8(presentColors[expect.bottomLeftColor]),
},
{
subRect: {
x: visibleRect.x + visibleRect.width / 2,
y: visibleRect.y + visibleRect.height / 2,
width: visibleRect.width / 2,
height: visibleRect.height / 2,
},
color: convertToUnorm8(presentColors[expect.bottomRightColor]),
},
];
for (const cropParam of cropParams) {
const subRect = new VideoFrame(source, { visibleRect: cropParam.subRect });
const colorAttachment = t.createTextureTracked({
format: kFormat,
size: { width: kWidth, height: kHeight, depthOrArrayLayers: 1 },
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
});
const pipeline = createExternalTextureSamplingTestPipeline(t);
const bindGroup = createExternalTextureSamplingTestBindGroup(
t,
t.params.checkNonStandardIsZeroCopy,
subRect,
pipeline,
dstColorSpace
);
const commandEncoder = t.device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: colorAttachment.createView(),
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(6);
passEncoder.end();
t.device.queue.submit([commandEncoder.finish()]);
ttu.expectSinglePixelComparisonsAreOkInTexture(t, { texture: colorAttachment }, [
{ coord: { x: kWidth * 0.2, y: kHeight * 0.2 }, exp: cropParam.color },
{ coord: { x: kWidth * 0.8, y: kHeight * 0.2 }, exp: cropParam.color },
{ coord: { x: kWidth * 0.2, y: kHeight * 0.8 }, exp: cropParam.color },
{ coord: { x: kWidth * 0.8, y: kHeight * 0.8 }, exp: cropParam.color },
]);
subRect.close();
}
source.close();
});
});
g.test('importExternalTexture,compute')
.desc(
`
Tests that we can import an HTMLVideoElement/VideoFrame into a GPUExternalTexture and use it in a
compute shader, for several combinations of video format, video color spaces and dst color spaces.
`
)
.params(u =>
u
.combineWithParams(checkNonStandardIsZeroCopyIfAvailable())
.combine('videoName', kVideoNames)
.combine('sourceType', ['VideoElement', 'VideoFrame'] as const)
.combine('dstColorSpace', kPredefinedColorSpace)
)
.fn(async t => {
const { videoName, sourceType, dstColorSpace } = t.params;
if (sourceType === 'VideoFrame' && typeof VideoFrame === 'undefined') {
t.skip('WebCodec is not supported');
}
const videoElement = getVideoElement(t, videoName);
await startPlayingAndWaitForVideo(videoElement, async () => {
const source =
sourceType === 'VideoFrame'
? await getVideoFrameFromVideoElement(t, videoElement)
: videoElement;
const externalTexture = t.device.importExternalTexture({
source,
colorSpace: dstColorSpace,
});
if (t.params.checkNonStandardIsZeroCopy) {
expectZeroCopyNonStandard(t, externalTexture);
}
const outputTexture = t.createTextureTracked({
format: 'rgba8unorm',
size: [2, 2, 1],
usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.STORAGE_BINDING,
});
const pipeline = t.device.createComputePipeline({
layout: 'auto',
compute: {
module: t.device.createShaderModule({
code: `
override frameWidth : i32 = 0;
override frameHeight : i32 = 0;
@group(0) @binding(0) var t : texture_external;
@group(0) @binding(1) var outImage : texture_storage_2d<rgba8unorm, write>;
@compute @workgroup_size(1) fn main() {
let coordTopLeft = vec2<i32>(frameWidth / 4, frameHeight / 4);
let coordTopRight = vec2<i32>(frameWidth / 4 * 3, frameHeight / 4);
let coordBottomLeft = vec2<i32>(frameWidth / 4, frameHeight / 4 * 3);
let coordBottomRight = vec2<i32>(frameWidth / 4 * 3, frameHeight / 4 * 3);
var yellow : vec4<f32> = textureLoad(t, coordTopLeft);
textureStore(outImage, vec2<i32>(0, 0), yellow);
var red : vec4<f32> = textureLoad(t, coordTopRight);
textureStore(outImage, vec2<i32>(0, 1), red);
var blue : vec4<f32> = textureLoad(t, coordBottomLeft);
textureStore(outImage, vec2<i32>(1, 0), blue);
var green : vec4<f32> = textureLoad(t, coordBottomRight);
textureStore(outImage, vec2<i32>(1, 1), green);
return;
}
`,
}),
entryPoint: 'main',
constants: {
frameWidth:
sourceType === 'VideoFrame'
? (source as VideoFrame).displayWidth
: (source as HTMLVideoElement).videoWidth,
frameHeight:
sourceType === 'VideoFrame'
? (source as VideoFrame).displayHeight
: (source as HTMLVideoElement).videoHeight,
},
},
});
const bg = t.device.createBindGroup({
entries: [
{ binding: 0, resource: externalTexture },
{ binding: 1, resource: outputTexture.createView() },
],
layout: pipeline.getBindGroupLayout(0),
});
const encoder = t.device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bg);
pass.dispatchWorkgroups(1);
pass.end();
t.device.queue.submit([encoder.finish()]);
const srcColorSpace = kVideoInfo[videoName].colorSpace;
const presentColors = kVideoExpectedColors[srcColorSpace][dstColorSpace];
const expect = kVideoInfo[videoName].display;
ttu.expectSinglePixelComparisonsAreOkInTexture(t, { texture: outputTexture }, [
{ coord: { x: 0, y: 0 }, exp: convertToUnorm8(presentColors[expect.topLeftColor]) },
{ coord: { x: 0, y: 1 }, exp: convertToUnorm8(presentColors[expect.topRightColor]) },
{ coord: { x: 1, y: 0 }, exp: convertToUnorm8(presentColors[expect.bottomLeftColor]) },
{ coord: { x: 1, y: 1 }, exp: convertToUnorm8(presentColors[expect.bottomRightColor]) },
]);
});
});
g.test('importExternalTexture,cameraCapture')
.desc(
`
Tests that we can import an VideoFrame from webcam into a GPUExternalTexture, sample from it and
compared with 2d canvas rendering result.
`
)
.params(u =>
u
.combineWithParams(checkNonStandardIsZeroCopyIfAvailable())
.combine('path', ['HTMLVideoElement', 'MediaStreamTrackProcessor'] as const)
.combine('dstColorSpace', kPredefinedColorSpace)
.combine('constraints', [
true,
{ width: 64, height: 48 },
{ width: 100, height: 300 },
] as const)
)
.fn(async t => {
const { path, dstColorSpace, constraints } = t.params;
const kDebugShowCanvasesOnScreen = false;
let source: HTMLVideoElement | VideoFrame;
let frameWidth: number, frameHeight: number;
switch (path) {
case 'HTMLVideoElement': {
const video = await getVideoElementFromCamera(t, constraints, true);
frameWidth = video.videoWidth;
frameHeight = video.videoHeight;
source = video;
break;
}
case 'MediaStreamTrackProcessor': {
const frame = await getVideoFrameFromCamera(t, constraints);
frameWidth = frame.displayWidth;
frameHeight = frame.displayHeight;
source = frame;
break;
}
default:
unreachable();
}
const webgpuCanvas = createCanvas(t, 'onscreen', frameWidth, frameHeight);
if (kDebugShowCanvasesOnScreen) {
document.body.append(document.createElement('br'));
document.body.append(webgpuCanvas);
}
const webgpuContext = webgpuCanvas.getContext('webgpu')!;
webgpuContext.configure({
device: t.device,
format: kFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
const colorAttachment = webgpuContext.getCurrentTexture();
const pipeline = createExternalTextureSamplingTestPipeline(t);
const bindGroup = createExternalTextureSamplingTestBindGroup(
t,
t.params.checkNonStandardIsZeroCopy,
source,
pipeline,
dstColorSpace
);
const commandEncoder = t.device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: colorAttachment.createView(),
clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(6);
passEncoder.end();
t.device.queue.submit([commandEncoder.finish()]);
const canvas = createCanvas(t, 'onscreen', frameWidth, frameHeight);
if (kDebugShowCanvasesOnScreen) {
document.body.append(canvas);
}
const canvasContext = canvas.getContext('2d', { colorSpace: dstColorSpace });
if (canvasContext === null) {
t.skip(' onscreen canvas 2d context not available');
}
const ctx = canvasContext as CanvasRenderingContext2D;
ctx.drawImage(source, 0, 0, frameWidth, frameHeight);
const imageData = ctx.getImageData(0, 0, frameWidth, frameHeight, {
colorSpace: dstColorSpace,
});
const expectedView = t.getExpectedDstPixelsFromSrcPixels({
srcPixels: imageData.data,
srcOrigin: [0, 0],
srcSize: [frameWidth, frameHeight],
dstOrigin: [0, 0],
dstSize: [frameWidth, frameHeight],
subRectSize: [frameWidth, frameHeight],
format: 'rgba8unorm',
flipSrcBeforeCopy: false,
srcDoFlipYDuringCopy: false,
conversion: {
srcPremultiplied: false,
dstPremultiplied: true,
},
});
ttu.expectTexelViewComparisonIsOkInTexture(t, { texture: colorAttachment }, expectedView, [
frameWidth,
frameHeight,
1,
]);
});