/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; const XHTML_NS = "http://www.w3.org/1999/xhtml"; const FRAME_SCRIPT_URL = "resource://gfxsanity/gfxFrameScript.js"; const PARENT_URL = "chrome://gfxsanity/content/sanityparent.html"; const PAGE_WIDTH = 160; const PAGE_HEIGHT = 234; const LEFT_EDGE = 8; const TOP_EDGE = 8; const CANVAS_WIDTH = 32; const CANVAS_HEIGHT = 64; // If those values are ever changed, make sure to update // WMFVideoMFTManager::CanUseDXVA accordingly. const DECODE_WIDTH = 132; const DECODE_HEIGHT = 132; const MEDIA_ENGINE_PREF = "media.wmf.media-engine.enabled"; const TIMEOUT_MS = 20000; /** Glean.gfx.sanityTest values. */ export const SanityCheckResult = { Passed: 0, FailedRender: 1, FailedVideoDecode: 2, Crashed: 3, Timeout: 4, FailedToRun: 5, FailedVideoEncode: 6, PassedNoHardwareEncoder: 7, // internal }; export const CodecTestResult = { Passed: "passed", Failed: "failed", Unsupported: "unsupported", }; const ENCODE_WIDTH = 320; const ENCODE_HEIGHT = 240; const ENCODE_CODEC = "avc1.42001E"; const ENCODE_TIMEOUT_MS = 5000; const ENCODE_REPLY_TIMEOUT_MS = 10000; /** Error thrown for timeouts. */ class TimeoutError extends Error {} function withTimeout(promise, timeoutMs, message) { let timer; const timeout = new Promise((resolve, reject) => { timer = setTimeout(() => reject(new TimeoutError(message)), timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } function testPixel(ctx, x, y, r, g, b, a, fuzz) { var data = ctx.getImageData(x, y, 1, 1); if ( Math.abs(data.data[0] - r) <= fuzz && Math.abs(data.data[1] - g) <= fuzz && Math.abs(data.data[2] - b) <= fuzz && Math.abs(data.data[3] - a) <= fuzz ) { return true; } return false; } function takeWindowSnapshot(win, ctx) { // TODO: drawWindow reads back from the gpu's backbuffer, which won't catch issues with presenting // the front buffer via the window manager. Ideally we'd use an OS level API for reading back // from the desktop itself to get a more accurate test. var flags = ctx.DRAWWINDOW_DRAW_CARET | ctx.DRAWWINDOW_DRAW_VIEW | ctx.DRAWWINDOW_USE_WIDGET_LAYERS; ctx.drawWindow(win, 0, 0, PAGE_WIDTH, PAGE_HEIGHT, "rgb(255,255,255)", flags); } // Verify that all the 4 coloured squares of the video // render as expected (with a tolerance of 64 to allow for // yuv->rgb differences between platforms). // // The video is DECODE_WIDTH*DECODE_HEIGHT, and is split into quadrants of // different colours. The top left of the video is LEFT_EDGE,TOP_EDGE+CANVAS_HEIGHT // and we test a pixel into the middle of each quadrant to avoid // blending differences at the edges. // // We allow massive amounts of fuzz for the colours since // it can depend hugely on the yuv -> rgb conversion, and // we don't want to fail unnecessarily. function verifyDecodeRendering(ctx) { return ( testPixel( ctx, LEFT_EDGE + DECODE_WIDTH / 4, TOP_EDGE + CANVAS_HEIGHT + DECODE_HEIGHT / 4, 255, 255, 255, 255, 64 ) && testPixel( ctx, LEFT_EDGE + (3 * DECODE_WIDTH) / 4, TOP_EDGE + CANVAS_HEIGHT + DECODE_HEIGHT / 4, 0, 255, 0, 255, 64 ) && testPixel( ctx, LEFT_EDGE + DECODE_WIDTH / 4, TOP_EDGE + CANVAS_HEIGHT + (3 * DECODE_HEIGHT) / 4, 0, 0, 255, 255, 64 ) && testPixel( ctx, LEFT_EDGE + (3 * DECODE_WIDTH) / 4, TOP_EDGE + CANVAS_HEIGHT + (3 * DECODE_HEIGHT) / 4, 255, 0, 0, 255, 64 ) ); } // Verify that the middle of the layers test is the color we expect. // It's a red CANVAS_WIDTHxCANVAS_HEIGHT square, test a pixel deep into the // square to prevent fuzzing, and another outside the expected limit of the // square to check that scaling occurred properly. The square is drawn LEFT_EDGE // pixels from the window's left edge and TOP_EDGE from the window's top edge. function verifyLayersRendering(ctx) { return ( testPixel( ctx, LEFT_EDGE + CANVAS_WIDTH / 2, TOP_EDGE + CANVAS_HEIGHT / 2, 255, 0, 0, 255, 64 ) && testPixel( ctx, LEFT_EDGE + CANVAS_WIDTH, TOP_EDGE + CANVAS_HEIGHT / 2, 255, 255, 255, 255, 64 ) ); } /** * Snapshot the test window and verify the compositor rendered it correctly. * * @param {Window} win The sanity test window. * @returns {number} A SanityCheckResult. */ function testCompositor(win) { const canvas = win.document.createElementNS(XHTML_NS, "canvas"); canvas.setAttribute("width", PAGE_WIDTH); canvas.setAttribute("height", PAGE_HEIGHT); const ctx = canvas.getContext("2d"); takeWindowSnapshot(win, ctx); if (!verifyLayersRendering(ctx)) { return SanityCheckResult.FailedRender; } if (!verifyDecodeRendering(ctx)) { return SanityCheckResult.FailedVideoDecode; } return SanityCheckResult.Passed; } function encodeConfig() { return { codec: ENCODE_CODEC, width: ENCODE_WIDTH, height: ENCODE_HEIGHT, hardwareAcceleration: "prefer-hardware", avc: { format: "annexb" }, }; } function makeEncodeCanvas(win, width, height) { const canvas = win.document.createElementNS(XHTML_NS, "canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx.fillStyle = "rgb(0, 0, 255)"; ctx.fillRect(0, 0, width, height); ctx.fillStyle = "rgb(255, 255, 0)"; ctx.fillRect(0, 0, width / 2, height / 2); ctx.fillStyle = "rgb(255, 0, 0)"; ctx.fillRect(width / 2, height / 2, width / 2, height / 2); return canvas; } /** * Encode a single keyframe with a hardware-only H.264 encoder. * * @param {Window} win The window whose WebCodecs implementation to use. * @returns {Promise<{status: string, reason: string}>} */ export async function testVideoEncode(win) { if (!win.VideoEncoder || !win.VideoFrame) { return { status: CodecTestResult.Unsupported, reason: "WebCodecs is not available", }; } const config = encodeConfig(); let support; try { support = await withTimeout( win.VideoEncoder.isConfigSupported(config), ENCODE_TIMEOUT_MS, "isConfigSupported timed out" ); } catch (e) { return { status: CodecTestResult.Unsupported, reason: `isConfigSupported failed: ${e}`, }; } if (!support || !support.supported) { return { status: CodecTestResult.Unsupported, reason: "no hardware H.264 encoder for this configuration", }; } const chunks = []; let encoderError = null; let encoder; try { encoder = new win.VideoEncoder({ output: chunk => { chunks.push({ type: chunk.type, byteLength: chunk.byteLength }); }, error: e => { encoderError = e; }, }); encoder.configure(config); const canvas = makeEncodeCanvas(win, ENCODE_WIDTH, ENCODE_HEIGHT); const frame = new win.VideoFrame(canvas, { timestamp: 0 }); try { encoder.encode(frame, { keyFrame: true }); } finally { frame.close(); } await withTimeout( encoder.flush(), ENCODE_TIMEOUT_MS, "encoder flush timed out" ); } catch (e) { return { status: CodecTestResult.Failed, reason: `encode failed: ${encoderError ?? e}`, }; } finally { try { encoder?.close(); } catch (e) { // The encoder may already be closed after an error. } } if (encoderError) { return { status: CodecTestResult.Failed, reason: `encode errored: ${encoderError}`, }; } if (!chunks.some(chunk => chunk.type === "key" && chunk.byteLength > 0)) { return { status: CodecTestResult.Failed, reason: `encode produced no keyframe (${chunks.length} chunks)`, }; } return { status: CodecTestResult.Passed, reason: "" }; } /** * Open the offscreen window the checks run against. * * @returns {Window} */ function openSanityTestWindow() { const win = Services.ww.openWindow( null, PARENT_URL, "Test Page", "width=" + PAGE_WIDTH + ",height=" + PAGE_HEIGHT + ",chrome,titlebar=0,dialog=1", null ); const appWin = win.docShell.treeOwner .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIAppWindow); // Request fast snapshot at RenderCompositor of WebRender. // Since readback of Windows DirectComposition is very slow. appWin.needFastSnaphot(); // There's no clean way to have an invisible window and ensure it's always painted. // Instead, move the window far offscreen so it doesn't show up during launch. win.moveTo(100000000, 1000000000); // In multi-screens with different dpi setup, the window may have been // incorrectly resized. win.resizeTo(PAGE_WIDTH, PAGE_HEIGHT); return win; } function waitForWindowLoad(win) { const loaded = () => win.document.readyState == "complete" && win.document.documentURI == PARENT_URL; if (loaded()) { return Promise.resolve(); } return new Promise(resolve => { win.addEventListener( "load", function onLoad() { if (!loaded()) { return; } win.removeEventListener("load", onLoad, true); resolve(); }, true ); }); } function attachTestBrowser(win) { const browser = win.document.createXULElement("browser"); browser.setAttribute("type", "content"); browser.setAttribute("disableglobalhistory", "true"); browser.toggleAttribute( "remote", Services.appinfo.browserTabsRemoteAutostart ); browser.style.width = PAGE_WIDTH + "px"; browser.style.height = PAGE_HEIGHT + "px"; win.document.documentElement.appendChild(browser); return browser; } /** * Open a test window, run every sanity check against it, and close it again. * * Returns plain data, so a content scoped test can drive the identical checks * through SpecialPowers without handling a chrome window itself. * * @returns {Promise} A SanityCheckResult. */ export async function runSanityTest() { let win; try { win = openSanityTestWindow(); return await runSanityChecks(win); } catch (e) { return SanityCheckResult.FailedToRun; } finally { win?.close(); } } /** * Run every sanity check against an already open test window. * * The encoder is only probed when rendering and decoding are healthy as * gfxPlatform force disables hardware encoding alongside decoding. * * @param {Window} win A window from openSanityTestWindow(). * @returns {Promise} A SanityCheckResult. */ async function runSanityChecks(win) { let mediaEnginePrefVal = 0; const restoreMediaEngine = () => { if (mediaEnginePrefVal != 0) { Services.prefs.setIntPref(MEDIA_ENGINE_PREF, mediaEnginePrefVal); mediaEnginePrefVal = 0; } }; try { return await withTimeout( (async () => { await waitForWindowLoad(win); const browser = attachTestBrowser(win); const mm = browser.messageManager; const contentLoaded = new Promise(resolve => mm.addMessageListener("gfxSanity:ContentLoaded", function onLoaded() { mm.removeMessageListener("gfxSanity:ContentLoaded", onLoaded); resolve(); }) ); // The media engine does not support capturing an image to a canvas, // which the compositor check relies on. mediaEnginePrefVal = Services.prefs.getIntPref(MEDIA_ENGINE_PREF, 0); if (mediaEnginePrefVal != 0) { Services.prefs.setIntPref(MEDIA_ENGINE_PREF, 0); } let result; try { mm.loadFrameScript(FRAME_SCRIPT_URL, false); await contentLoaded; result = testCompositor(win); } finally { restoreMediaEngine(); } if (result != SanityCheckResult.Passed) { return result; } const encoderResult = new Promise(resolve => mm.addMessageListener( "gfxSanity:EncoderResult", function onResult(message) { mm.removeMessageListener("gfxSanity:EncoderResult", onResult); resolve(message.data); } ) ); mm.sendAsyncMessage("gfxSanity:RunEncoderTest"); let encoder; try { encoder = await withTimeout( encoderResult, ENCODE_REPLY_TIMEOUT_MS, "encode check did not reply" ); } catch (e) { return SanityCheckResult.FailedVideoEncode; } switch (encoder.status) { case CodecTestResult.Failed: return SanityCheckResult.FailedVideoEncode; case CodecTestResult.Unsupported: return SanityCheckResult.PassedNoHardwareEncoder; default: return SanityCheckResult.Passed; } })(), TIMEOUT_MS, "sanity test timed out" ); } catch (e) { return e instanceof TimeoutError ? SanityCheckResult.Timeout : SanityCheckResult.FailedToRun; } finally { // If we timeout, we may need to restore this here too. restoreMediaEngine(); } }