chromium/ash/webui/common/resources/cr_picture/webcam_utils.js

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

import {convertImageSequenceToPng, convertImageSequenceToPngBinary} from './png.js';

/**
 * Dimensions for camera capture.
 */
export const CAPTURE_SIZE = {
  height: 576,
  width: 576,
};

/**
 * Interval between frames for camera capture (milliseconds).
 * @const
 */
export const CAPTURE_INTERVAL_MS = 1000 / 10;

/**
 * Duration of camera capture (milliseconds).
 * @type {number}
 */
export const CAPTURE_DURATION_MS = 1000;

/**
 * Default media constraints to request the front facing webcam.
 */
export const kDefaultVideoConstraints = {
  facingMode: 'user',
  height: {ideal: CAPTURE_SIZE.height},
  width: {ideal: CAPTURE_SIZE.width},
};

/**
 * Stops all video tracks associated with a MediaStream object.
 * @param {?MediaStream} stream
 */
export function stopMediaTracks(stream) {
  if (stream) {
    stream.getVideoTracks().forEach(track => track.stop());
  }
}


/**
 * Allocates a canvas for capturing a single still frame at a specific size.
 * @param {{width: number, height: number}} size Frame size.
 * @return {!HTMLCanvasElement} The allocated canvas.
 */
function allocateFrame(size) {
  const canvas =
      /** @type {!HTMLCanvasElement} */ (document.createElement('canvas'));
  canvas.width = size.width;
  canvas.height = size.height;
  const ctx = /** @type {!CanvasRenderingContext2D} */ (
      canvas.getContext('2d', {alpha: false}));
  // Flip frame horizontally.
  ctx.translate(size.width, 0);
  ctx.scale(-1.0, 1.0);
  return canvas;
}

/**
 * Captures a single still frame from a <video> element, placing it at the
 * current drawing origin of a canvas context.
 * @param {!HTMLVideoElement} video Video element to capture from.
 * @param {!HTMLCanvasElement} canvas Canvas to save frame in.
 * @return {!HTMLCanvasElement} The canvas frame was saved in.
 */
function writeVideoFrameToCanvas(video, canvas) {
  const ctx =
      /** @type {!CanvasRenderingContext2D} */ (
          canvas.getContext('2d', {alpha: false}));
  const width = video.videoWidth;
  const height = video.videoHeight;
  if (width < canvas.width || height < canvas.height) {
    console.error(
        'Video capture size too small: ' + width + 'x' + height + '!');
  }
  const src = {};
  if (width / canvas.width > height / canvas.height) {
    // Full height, crop left/right.
    src.height = height;
    src.width = height * canvas.width / canvas.height;
  } else {
    // Full width, crop top/bottom.
    src.width = width;
    src.height = width * canvas.height / canvas.width;
  }
  src.x = (width - src.width) / 2;
  src.y = (height - src.height) / 2;
  ctx.drawImage(
      video, src.x, src.y, src.width, src.height, 0, 0, canvas.width,
      canvas.height);
  return canvas;
}

/**
 * Captures one frame from |video| every |intervalMs|, [numFrames] times.
 * @param {!HTMLVideoElement} video
 * @param {{height: number, width: number}} captureSize
 * @param {number} intervalMs
 * @param {number} numFrames
 * @return {!Promise<!Array<!HTMLCanvasElement>>} an array of canvas elements of
 *     the captured frames
 */
export async function captureFrames(video, captureSize, intervalMs, numFrames) {
  return new Promise((resolve) => {
    if (numFrames <= 0) {
      throw new Error('numFrames must be greater than 0');
    }
    if (intervalMs <= 0) {
      throw new Error('intervalMs must be greater than 0');
    }
    /** Pre-allocate all frames needed for capture. */
    const frames = [];
    while (frames.length < numFrames) {
      frames.push(allocateFrame(captureSize));
    }

    const capturedFrames = [];

    const interval = window.setInterval(() => {
      /** Stop capturing frames when all allocated frames have been consumed. */
      if (frames.length) {
        capturedFrames.push(writeVideoFrameToCanvas(video, frames.pop()));
      } else {
        window.clearInterval(interval);
        resolve(capturedFrames);
      }
    }, intervalMs);
  });
}

/**
 * Mirrors the array around the last element by appending a reversed copy of
 * itself (minus the first and last element). This makes playback appear
 * continuous when played in a loop.
 * @example ['a', 'b', 'c'] => ['a', 'b', 'c', 'b']
 * @param {!Array<T>} arr
 * @return {!Array<T>}
 * @template T
 */
function mirror(arr) {
  return arr.concat(arr.slice(1, -1).reverse());
}

/**
 * Encode frames and convert to animated PNG image as a data URL.
 * @param {!Array<!HTMLCanvasElement>} frames The frames to convert to image.
 * @return {!string} The data URL for image.
 */
export function convertFramesToPng(frames) {
  /** Encode captured frames. */
  const encodedImages = frames.map(function(frame) {
    return frame.toDataURL('image/png');
  });

  /** No need for further processing if single frame. */
  if (encodedImages.length === 1) {
    return encodedImages[0];
  }

  /** Create forward/backward image sequence. */
  const forwardBackwardImageSequence = mirror(encodedImages);

  /** Convert image sequence to animated PNG. */
  return convertImageSequenceToPng(forwardBackwardImageSequence);
}

/**
 * Encode frames and convert to animated PNG image as a Uint8Array.
 * @param {!Array<!HTMLCanvasElement>} frames
 * @return {Uint8Array}
 */
export function convertFramesToPngBinary(frames) {
  const encodedImages = frames.map(frame => frame.toDataURL('image/png'));
  return convertImageSequenceToPngBinary(mirror(encodedImages));
}