chromium/content/test/data/gpu/webcodecs/webcodecs_common.js

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

"use strict";

// Use 16x16 aligned resolution since some platforms require that.
// See https://crbug.com/1084702.
// Also, some platforms require a resolution that isn't tiny (e.g. 160) to
// use hardware acceleration.
const FRAME_WIDTH = 640;
const FRAME_HEIGHT = 480;

class TestHarness {
  finished = false;
  success = false;
  skipped = false;
  message = 'ok';
  logs = [];
  logWindow = null;

  constructor() {}

  skip(message) {
    this.skipped = true;
    this.finished = true;
    this.message = message;
    this.log('Test skipped: ' + message);
  }

  reportSuccess() {
    this.finished = true;
    this.success = true;
    this.log('Test completed');
  }

  reportFailure(error) {
    this.finished = true;
    this.success = false;
    this.message = error.toString();
    this.log(this.message);
  }

  assert(condition, msg) {
    if (!condition)
      this.reportFailure("Assertion failed: " + msg);
  }

  assert_eq(val1, val2, msg) {
    if (val1 != val2) {
      this.reportFailure(`Assertion failed: ${msg}. ${val1} != ${val2}.`);
    }
  }

  summary() {
    return this.message + "\n\n" + this.logs.join("\n");
  }

  log(msg) {
    this.logs.push(msg);
    console.log(msg);
    if (this.logWindow === null)
      this.logWindow = document.querySelector('textarea');
    if (this.logWindow)
      this.logWindow.value += msg + '\n';
  }

  run(arg) {
    main(arg).then(
        _ => {
          if (!this.finished)
            this.reportSuccess();
        },
        error => {
          if (!this.finished)
            this.reportFailure(error);
        });
  }
};
var TEST = new TestHarness();

function waitForNextFrame() {
  return new Promise((resolve, _) => {
    window.requestAnimationFrame(resolve);
  });
}

function fourColorsFrame(ctx, width, height, text) {
  const kYellow = "#FFFF00";
  const kRed = "#FF0000";
  const kBlue = "#0000FF";
  const kGreen = "#00FF00";

  ctx.fillStyle = kYellow;
  ctx.fillRect(0, 0, width / 2, height / 2);

  ctx.fillStyle = kRed;
  ctx.fillRect(width / 2, 0, width / 2, height / 2);

  ctx.fillStyle = kBlue;
  ctx.fillRect(0, height / 2, width / 2, height / 2);

  ctx.fillStyle = kGreen;
  ctx.fillRect(width / 2, height / 2, width / 2, height / 2);

  ctx.fillStyle = 'white';
  ctx.font = (height / 10) + 'px sans-serif';
  ctx.fillText(text, width / 2, height / 2);
}

function peekPixel(ctx, x, y) {
  if (ctx.readPixels) {
    let pixels = new Uint8Array(4);
    ctx.readPixels(x, ctx.drawingBufferHeight - y, 1, 1,
                   ctx.RGBA, ctx.UNSIGNED_BYTE, pixels);
    return Array.from(pixels);
  }
  if (ctx.getImageData) {
    let settings = {colorSpaceConversion: 'none'};
    return ctx.getImageData(x, y, 1, 1, settings).data;
  }
}

function compareColors(actual, expected, tolerance, msg) {
  let channel = ['R', 'G', 'B', 'A'];
  for (let i = 0; i < 4; i++) {
    if (Math.abs(actual[i] - expected[i]) > tolerance) {
      TEST.reportFailure(msg +
       ` channel: ${channel[i]} actual: ${actual[i]} expected: ${expected[i]}`);
    }
  }
}

function checkFourColorsFrame(ctx, width, height, tolerance) {
  const kYellow = [0xFF, 0xFF, 0x00, 0xFF];
  const kRed = [0xFF, 0x00, 0x00, 0xFF];
  const kBlue = [0x00, 0x00, 0xFF, 0xFF];
  const kGreen = [0x00, 0xFF, 0x00, 0xFF];

  let m = 10; // margin from the frame's edge
  compareColors(peekPixel(ctx, m, m), kYellow,
                      tolerance, 'top left corner is yellow');
  compareColors(peekPixel(ctx, width - m, m), kRed,
                      tolerance, 'top right corner is red');
  compareColors(peekPixel(ctx, m, height - m), kBlue,
                      tolerance, 'bottom left corner is blue');
  compareColors(peekPixel(ctx, width - m, height - m), kGreen,
                      tolerance, 'bottom right corner is green');
}

// Paints |count| black dots on the |ctx|, so their presence can be validated
// later. This is an analog of the most basic bar code.
function putBlackDots(ctx, width, height, count) {
  ctx.fillStyle = 'black';
  const dot_size = 10;
  const step = dot_size * 3;

  for (let i = 1; i <= count; i++) {
    let x = i * step;
    let y = step * (x / width + 1);
    x %= width;
    ctx.fillRect(x, y, dot_size, dot_size);
  }
}

// Validates that frame has |count| black dots in predefined places.
function validateBlackDots(frame, count) {
  const width = frame.displayWidth;
  const height = frame.displayHeight;
  let cnv = new OffscreenCanvas(width, height);
  var ctx = cnv.getContext('2d', { willReadFrequently : true });
  ctx.drawImage(frame, 0, 0);
  const dot_size = 10;
  const step = dot_size * 3;

  for (let i = 1; i <= count; i++) {
    let x = i * step + dot_size / 2;
    let y = step * (x / width + 1) + dot_size / 2;
    x %= width;
    let rgba = ctx.getImageData(x, y, 1, 1).data;
    const tolerance = 40;
    if (rgba[0] > tolerance || rgba[1] > tolerance || rgba[2] > tolerance) {
      // The dot is too bright to be a black dot.
      return false;
    }
  }
  return true;
}


// Base class for video frame sources.
class FrameSource {
  constructor() {}

  async getNextFrame() {
    return null;
  }

  close() {}
}

// Source of video frames coming from taking snapshots of a canvas.
class CanvasSource extends FrameSource {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
    this.canvas = new OffscreenCanvas(width, height);
    this.ctx = this.canvas.getContext('2d', {colorSpace: 'srgb'});
    this.timestamp = 0;
    this.duration = 16666;  // 1/60 s
    this.frame_index = 0;
  }

  async getNextFrame() {
    fourColorsFrame(this.ctx, this.width, this.height,
                    this.timestamp.toString());
    putBlackDots(this.ctx, this.width, this.height, this.frame_index);
    let result = new VideoFrame(this.canvas, {timestamp: this.timestamp});
    this.timestamp += this.duration;
    this.frame_index++;
    return result;
  }
}

// Source of video frames coming from MediaStreamTrack.
class StreamSource extends FrameSource {
  constructor(track) {
    super();
    this.media_processor = new MediaStreamTrackProcessor(track);
    this.reader = this.media_processor.readable.getReader();
  }

  async getNextFrame() {
    const result = await this.reader.read();
    const frame = result.value;
    return frame;
  }

  close() {
    if (this.reader)
      this.reader.cancel();
  }
}

class ArrayBufferSource extends FrameSource {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
    this.canvas = new OffscreenCanvas(width, height);
    this.ctx = this.canvas.getContext(
        '2d', {willReadFrequently: true, colorSpace: 'srgb'});
    this.timestamp = 0;
    this.duration = 16666;  // 1/60 s
    this.frame_index = 0;
  }

  async getNextFrame() {
    fourColorsFrame(
        this.ctx, this.width, this.height, this.timestamp.toString());
    putBlackDots(this.ctx, this.width, this.height, this.frame_index);
    const imageData = this.ctx.getImageData(0, 0, this.width, this.height);
    const buffer = imageData.data;
    let init = {
      format: 'RGBA',
      timestamp: this.timestamp,
      codedWidth: this.width,
      codedHeight: this.height,
      // This describes sRGB color-space used by the canvas
      colorSpace: {
        fullRange: true,
        matrix: 'rgb',
        primaries: 'bt709',
        transfer: 'iec61966-2-1'
      },
      transfer: [buffer.buffer]
    };
    this.timestamp += this.duration;
    this.frame_index++;
    return new VideoFrame(buffer, init);
  }
}

class HBDArrayBufferSource extends FrameSource {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
    this.timestamp = 0;
    this.duration = 16666;  // 1/60 s
    this.frame_index = 0;
  }

  async getNextFrame() {
    const kDepth = 10;
    const kShift = kDepth - 8;
    // 8-bit YUV colors assuming BT.709 matrix and sRGB primaries.
    const kYellow = [219, 16, 138];
    const kRed = [63, 102, 240];
    const kBlue = [32, 240, 118];
    const kGreen = [173, 42, 26];

    const width = this.width;
    const height = this.height;
    const halfW = Math.ceil(width / 2);
    const halfH = Math.ceil(height / 2);
    const qtrW = Math.ceil(width / 4);
    const qtrH = Math.ceil(height / 4);
    const data = new Uint8Array((width * height + 2 * halfW * halfH) * 2);
    const view = new DataView(data.buffer)

    let i = 0;

    // Y plane.
    for (let y = 0; y < height; ++y) {
      const colors = y < halfH ? [kYellow, kRed] : [kBlue, kGreen];
      for (let x = 0; x < width; ++x) {
        const color = x < halfW ? colors[0] : colors[1];
        // Note: Rounding is not quite accurate due to shifting rather than
        // scaling, in addition to using already rounded YUV values.
        view.setUint16(i, color[0] << kShift, true);
        i += 2;
      }
    }

    // U plane.
    for (let y = 0; y < halfH; ++y) {
      const colors = y < qtrH ? [kYellow, kRed] : [kBlue, kGreen];
      for (let x = 0; x < halfW; ++x) {
        const color = x < qtrW ? colors[0] : colors[1];
        view.setUint16(i, color[1] << kShift, true);
        i += 2;
      }
    }

    // V plane.
    for (let y = 0; y < halfH; ++y) {
      const colors = y < qtrH ? [kYellow, kRed] : [kBlue, kGreen];
      for (let x = 0; x < halfW; ++x) {
        const color = x < qtrW ? colors[0] : colors[1];
        view.setUint16(i, color[2] << kShift, true);
        i += 2;
      }
    }

    const init = {
      format: `I420P${kDepth}`,
      timestamp: this.timestamp,
      codedWidth: width,
      codedHeight: height,
      // sRGB with BT.709 YUV matrix.
      colorSpace: {
        matrix: 'bt709',
        primaries: 'bt709',
        transfer: 'iec61966-2-1',
        fullRange: false,
      },
      transfer: [data.buffer]
    };
    this.timestamp += this.duration;
    this.frame_index++;
    return new VideoFrame(data, init);
  }
}

// Source of video frames coming from either hardware of software decoder.
class DecoderSource extends FrameSource {
  constructor(decoderConfig, chunks) {
    super();
    this.decoderConfig = decoderConfig;
    this.frames = [];
    this.error = null;
    this.decoder = new VideoDecoder(
        {error: this.onError.bind(this), output: this.onFrame.bind(this)});
    this.decoder.configure(this.decoderConfig);
    while (chunks.length != 0)
      this.decoder.decode(chunks.shift());
    this.decoder.flush();
  }

  onError(error) {
    TEST.log(error);
    this.error = error;
    if (this.next) {
      this.next.reject(error);
      this.next = null;
    }
  }

  onFrame(frame) {
    if (this.next) {
      this.next.resolve(frame);
      this.next = null;
    } else {
      this.frames.push(frame);
    }
  }

  async getNextFrame() {
    if (this.next)
      return this.next.promise;

    if (this.frames.length > 0)
      return this.frames.shift();

    if (this.error)
      throw this.error;

    let next = {};
    this.next = next;
    this.next.promise = new Promise((resolve, reject) => {
      next.resolve = resolve;
      next.reject = reject;
    });

    return next.promise;
  }

  close() {
    if (this.decoder)
      this.decoder.close();
  }
}

function createCanvasCaptureSource(width, height) {
  let canvas = document.createElement('canvas');
  canvas.id = 'canvas-for-capture';
  canvas.width = width;
  canvas.height = height;
  document.body.appendChild(canvas);

  let ctx = canvas.getContext('2d');
  let drawOneFrame = function(time) {
    fourColorsFrame(ctx, width, height, time.toString());
    window.requestAnimationFrame(drawOneFrame);
  };
  window.requestAnimationFrame(drawOneFrame);

  const stream = canvas.captureStream(60);
  const track = stream.getVideoTracks()[0];
  return new StreamSource(track);
}

async function prepareDecoderSource(
    frames_to_encode, width, height, codec, acceleration) {
  if (!acceleration)
    acceleration = 'no-preference';
  const encoder_config = {
    codec: codec,
    width: width,
    height: height,
    bitrate: 10000000,
    framerate: 24
  };

  if (codec.startsWith('avc1')) {
    encoder_config.avc = {format: 'annexb'};
  } else if (codec.startsWith('hvc1')) {
    encoder_config.hevc = {format: 'annexb'};
  }

  let decoder_config = {
    codec: codec,
    codedWidth: width,
    codedHeight: height,
    visibleRegion: {left: 0, top: 0, width: width, height: height},
    hardwareAcceleration: acceleration
  };

  try {
    let support = await VideoDecoder.isConfigSupported(decoder_config);
    if (!support.supported)
      return null;
  } catch (e) {
    return null;
  }

  let chunks = [];
  let errors = 0;
  const init = {
    output(chunk, metadata) {
      let config = metadata.decoderConfig;
      if (config) {
        decoder_config = config;
        decoder_config.hardwareAcceleration = acceleration;
      }
      chunks.push(chunk);
    },
    error(e) {
      errors++;
      TEST.log(e);
    }
  };

  let encoder = new VideoEncoder(init);
  encoder.configure(encoder_config);
  let innerSource = new ArrayBufferSource(width, height);

  for (let i = 0; i < frames_to_encode; i++) {
    let frame = await innerSource.getNextFrame();
    encoder.encode(frame, {keyFrame: false});
    frame.close();
  }
  try {
    await encoder.flush();
    encoder.close();
    innerSource.close();
  } catch (e) {
    errors++;
    TEST.log(e);
  }

  if (errors > 0)
    return null;

  return new DecoderSource(decoder_config, chunks);
}

async function createFrameSource(type, width, height) {
  switch (type) {
    case 'camera': {
      let constraints = {audio: false, video: {width: width, height: height}};
      let stream =
          await window.navigator.mediaDevices.getUserMedia(constraints);
      var track = stream.getTracks()[0];
      return new StreamSource(track);
    }
    case 'capture': {
      return createCanvasCaptureSource(width, height);
    }
    case 'offscreen': {
      return new CanvasSource(width, height);
    }
    case 'hw_decoder': {
      // Trying to find any hardware decoder supported by the platform.
      let src = await prepareDecoderSource(
          40, width, height, 'avc1.42001E', 'prefer-hardware');
      if (!src)
        src = await prepareDecoderSource(
            40, width, height, 'hvc1.1.6.L123.00', 'prefer-hardware');
      if (!src)
        src = await prepareDecoderSource(
            40, width, height, 'vp8', 'prefer-hardware');
      if (!src) {
        src = await prepareDecoderSource(
            40, width, height, 'vp09.00.10.08', 'prefer-hardware');
      }
      if (!src) {
        TEST.log('Can\'t find a supported hardware decoder.');
      }
      return src;
    }
    case 'sw_decoder': {
      return await prepareDecoderSource(
          40, width, height, 'vp8', 'prefer-software');
    }
    case 'arraybuffer': {
      return new ArrayBufferSource(width, height);
    }
    case 'hbd_arraybuffer': {
      return new HBDArrayBufferSource(width, height);
    }
  }
}

function addManualTestButton(configs) {
  document.addEventListener('DOMContentLoaded', _ => {
    configs.forEach(config => {
      const btn = document.createElement('button');
      const label = document.createTextNode(
          'Run test with config: ' + JSON.stringify(config));
      btn.onclick = function() {
        main(config);
      };
      btn.appendChild(label);
      btn.style.margin = '5px';
      document.body.appendChild(btn);
    });
  }, true);
}

function readProfileFromAvcExtraData(view) {
  if (view.byteLength < 6) {
    // Too short to be a proper AVCDecoderConfigurationRecord
    return null;
  }
  const version = view.getUint8(0);
  if (version != 1) {
    return null;
  }

  const profile = view.getUint8(1);
  return profile;
}