import { Fixture, SkipTestCase } from '../../common/framework/fixture.js'; import { getResourcePath } from '../../common/framework/resources.js'; import { makeTable } from '../../common/util/data_tables.js'; import { timeout } from '../../common/util/timeout.js'; import { ErrorWithExtra, raceWithRejectOnTimeout } from '../../common/util/util.js'; import { GPUTest } from '../gpu_test.js'; declare global { interface HTMLMediaElement { // Add captureStream() support for HTMLMediaElement from // https://w3c.github.io/mediacapture-fromelement/#dom-htmlmediaelement-capturestream captureStream(): MediaStream; } } export const kVideoInfo = /* prettier-ignore */ makeTable( ['mimeType' ] as const, [undefined ] as const, { // All video names 'four-colors-vp8-bt601.webm': ['video/webm; codecs=vp8' ], 'four-colors-theora-bt601.ogv': ['video/ogg; codecs=theora' ], 'four-colors-h264-bt601.mp4': ['video/mp4; codecs=avc1.4d400c'], 'four-colors-vp9-bt601.webm': ['video/webm; codecs=vp9' ], 'four-colors-vp9-bt709.webm': ['video/webm; codecs=vp9' ], 'four-colors-vp9-bt2020.webm': ['video/webm; codecs=vp9' ], 'four-colors-h264-bt601-rotate-90.mp4': ['video/mp4; codecs=avc1.4d400c'], 'four-colors-h264-bt601-rotate-180.mp4': ['video/mp4; codecs=avc1.4d400c'], 'four-colors-h264-bt601-rotate-270.mp4': ['video/mp4; codecs=avc1.4d400c'], } as const); export type VideoName = keyof typeof kVideoInfo; // Expectation values about converting video contents to sRGB color space. // Source video color space affects expected values. // The process to calculate these expected pixel values can be found: // https://github.com/gpuweb/cts/pull/2242#issuecomment-1430382811 // and https://github.com/gpuweb/cts/pull/2242#issuecomment-1463273434 const kBt601PixelValue = { red: new Float32Array([0.972945567233341, 0.141794376683341, -0.0209589916711088, 1.0]), green: new Float32Array([0.248234279433399, 0.984810378661784, -0.0564701319494314, 1.0]), blue: new Float32Array([0.10159735826538, 0.135451122863674, 1.00262982899724, 1.0]), yellow: new Float32Array([0.995470750775951, 0.992742114518355, -0.0774291236205402, 1.0]), }; function convertToUnorm8(expectation: Float32Array): Uint8Array { const unorm8 = new Uint8ClampedArray(expectation.length); for (let i = 0; i < expectation.length; ++i) { unorm8[i] = Math.round(expectation[i] * 255.0); } return new Uint8Array(unorm8.buffer); } // kVideoExpectations uses unorm8 results const kBt601Red = convertToUnorm8(kBt601PixelValue.red); const kBt601Green = convertToUnorm8(kBt601PixelValue.green); const kBt601Blue = convertToUnorm8(kBt601PixelValue.blue); const kBt601Yellow = convertToUnorm8(kBt601PixelValue.yellow); export const kVideoExpectations = [ { videoName: 'four-colors-vp8-bt601.webm', _redExpectation: kBt601Red, _greenExpectation: kBt601Green, _blueExpectation: kBt601Blue, _yellowExpectation: kBt601Yellow, }, { videoName: 'four-colors-theora-bt601.ogv', _redExpectation: kBt601Red, _greenExpectation: kBt601Green, _blueExpectation: kBt601Blue, _yellowExpectation: kBt601Yellow, }, { videoName: 'four-colors-h264-bt601.mp4', _redExpectation: kBt601Red, _greenExpectation: kBt601Green, _blueExpectation: kBt601Blue, _yellowExpectation: kBt601Yellow, }, { videoName: 'four-colors-vp9-bt601.webm', _redExpectation: kBt601Red, _greenExpectation: kBt601Green, _blueExpectation: kBt601Blue, _yellowExpectation: kBt601Yellow, }, { videoName: 'four-colors-vp9-bt709.webm', _redExpectation: new Uint8Array([255, 0, 0, 255]), _greenExpectation: new Uint8Array([0, 255, 0, 255]), _blueExpectation: new Uint8Array([0, 0, 255, 255]), _yellowExpectation: new Uint8Array([255, 255, 0, 255]), }, ] as const; export const kVideoRotationExpectations = [ { videoName: 'four-colors-h264-bt601-rotate-90.mp4', _topLeftExpectation: kBt601Red, _topRightExpectation: kBt601Green, _bottomLeftExpectation: kBt601Yellow, _bottomRightExpectation: kBt601Blue, }, { videoName: 'four-colors-h264-bt601-rotate-180.mp4', _topLeftExpectation: kBt601Green, _topRightExpectation: kBt601Blue, _bottomLeftExpectation: kBt601Red, _bottomRightExpectation: kBt601Yellow, }, { videoName: 'four-colors-h264-bt601-rotate-270.mp4', _topLeftExpectation: kBt601Blue, _topRightExpectation: kBt601Yellow, _bottomLeftExpectation: kBt601Green, _bottomRightExpectation: kBt601Red, }, ] as const; /** * Starts playing a video and waits for it to be consumable. * Returns a promise which resolves after `callback` (which may be async) completes. * * @param video An HTML5 Video element. * @param callback Function to call when video is ready. * * Adapted from https://github.com/KhronosGroup/WebGL/blob/main/sdk/tests/js/webgl-test-utils.js */ export function startPlayingAndWaitForVideo( video: HTMLVideoElement, callback: () => unknown | Promise ): Promise { return raceWithRejectOnTimeout( new Promise((resolve, reject) => { const callbackAndResolve = () => void (async () => { try { await callback(); resolve(); } catch (ex) { reject(ex); } })(); if (video.error) { reject( new ErrorWithExtra('Video.error: ' + video.error.message, () => ({ error: video.error })) ); return; } video.addEventListener( 'error', event => reject(new ErrorWithExtra('Video received "error" event', () => ({ event }))), true ); if (video.requestVideoFrameCallback) { video.requestVideoFrameCallback(() => { callbackAndResolve(); }); } else { // If requestVideoFrameCallback isn't available, check each frame if the video has advanced. const timeWatcher = () => { if (video.currentTime > 0) { callbackAndResolve(); } else { requestAnimationFrame(timeWatcher); } }; timeWatcher(); } video.loop = true; video.muted = true; video.preload = 'auto'; video.play().catch(reject); }), 2000, 'Video never became ready' ); } /** * Fire a `callback` when the script animation reaches a new frame. * Returns a promise which resolves after `callback` (which may be async) completes. */ export function waitForNextTask(callback: () => unknown | Promise): Promise { const { promise, callbackAndResolve } = callbackHelper(callback, 'wait for next task timed out'); timeout(() => { callbackAndResolve(); }, 0); return promise; } /** * Fire a `callback` when the video reaches a new frame. * Returns a promise which resolves after `callback` (which may be async) completes. * * MAINTENANCE_TODO: Find a way to implement this for browsers without requestVideoFrameCallback as * well, similar to the timeWatcher path in startPlayingAndWaitForVideo. If that path is proven to * work well, we can consider getting rid of the requestVideoFrameCallback path. */ export function waitForNextFrame( video: HTMLVideoElement, callback: () => unknown | Promise ): Promise { const { promise, callbackAndResolve } = callbackHelper(callback, 'waitForNextFrame timed out'); if ('requestVideoFrameCallback' in video) { video.requestVideoFrameCallback(() => { callbackAndResolve(); }); } else { throw new SkipTestCase('waitForNextFrame currently requires requestVideoFrameCallback'); } return promise; } export async function getVideoFrameFromVideoElement( test: Fixture, video: HTMLVideoElement ): Promise { if (video.captureStream === undefined) { test.skip('HTMLVideoElement.captureStream is not supported'); } return raceWithRejectOnTimeout( new Promise(resolve => { const videoTrack: MediaStreamVideoTrack = video.captureStream().getVideoTracks()[0]; const trackProcessor: MediaStreamTrackProcessor = new MediaStreamTrackProcessor({ track: videoTrack, }); const transformer: TransformStream = new TransformStream({ transform(videoFrame, _controller) { videoTrack.stop(); resolve(videoFrame); }, flush(controller) { controller.terminate(); }, }); const trackGenerator: MediaStreamTrackGenerator = new MediaStreamTrackGenerator({ kind: 'video', }); trackProcessor.readable .pipeThrough(transformer) .pipeTo(trackGenerator.writable) .catch(() => {}); }), 2000, 'Video never became ready' ); } /** * Create HTMLVideoElement based on VideoName. Check whether video is playable in current * browser environment. * Returns a HTMLVideoElement. * * @param t: GPUTest that requires getting HTMLVideoElement * @param videoName: Required video name * */ export function getVideoElement(t: GPUTest, videoName: VideoName): HTMLVideoElement { const videoElement = document.createElement('video'); const videoInfo = kVideoInfo[videoName]; if (videoElement.canPlayType(videoInfo.mimeType) === '') { t.skip('Video codec is not supported'); } const videoUrl = getResourcePath(videoName); videoElement.src = videoUrl; return videoElement; } /** * Helper for doing something inside of a (possibly async) callback (directly, not in a following * microtask), and returning a promise when the callback is done. * MAINTENANCE_TODO: Use this in startPlayingAndWaitForVideo (and make sure it works). */ function callbackHelper( callback: () => unknown | Promise, timeoutMessage: string ): { promise: Promise; callbackAndResolve: () => void } { let callbackAndResolve: () => void; const promiseWithoutTimeout = new Promise((resolve, reject) => { callbackAndResolve = () => void (async () => { try { await callback(); // catches both exceptions and rejections resolve(); } catch (ex) { reject(ex); } })(); }); const promise = raceWithRejectOnTimeout(promiseWithoutTimeout, 2000, timeoutMessage); return { promise, callbackAndResolve: callbackAndResolve! }; }