chromium/ash/webui/camera_app_ui/resources/js/device/mode/index.ts

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

import {
  assert,
  assertExists,
  assertInstanceof,
} from '../../assert.js';
import * as expert from '../../expert.js';
import {DeviceOperator} from '../../mojo/device_operator.js';
import {CaptureIntent} from '../../mojo/type.js';
import * as state from '../../state.js';
import {
  Mode,
  Resolution,
} from '../../type.js';
import {getFpsRangeFromConstraints} from '../../util.js';
import {StreamConstraints} from '../stream_constraints.js';

import {
  ModeBase,
  ModeFactory,
} from './mode_base.js';
import {
  PhotoFactory,
  PhotoHandler,
} from './photo.js';
import {PortraitFactory, PortraitHandler} from './portrait.js';
import {
  ScanFactory,
  ScanHandler,
} from './scan.js';
import {
  VideoFactory,
  VideoHandler,
} from './video.js';

export type{PhotoHandler, PhotoResult} from './photo.js';
export {getDefaultScanCorners} from './scan.js';
export type{ScanHandler} from './scan.js';
export {setAvc1Parameters, Video} from './video.js';
export type{GifResult, VideoHandler, VideoResult} from './video.js';

/**
 * Callback to trigger mode switching. Should return whether mode switching
 * succeed.
 */
export type DoSwitchMode = () => Promise<boolean>;

export type CaptureHandler =
    PhotoHandler&PortraitHandler&ScanHandler&VideoHandler;

/**
 * Parameters for capture settings.
 */
interface CaptureParams {
  mode: Mode;
  constraints: StreamConstraints;
  captureResolution: Resolution|null;
  videoSnapshotResolution: Resolution|null;
}

/**
 * The abstract interface for the mode configuration.
 */
interface ModeConfig {
  /**
   * @return Resolves to boolean indicating whether the mode is supported by
   *     video device with specified `deviceId`.
   */
  isSupported(deviceId: string|null): Promise<boolean>;

  isSupportPTZ(captureResolution: Resolution, previewResolution: Resolution):
      boolean;

  /**
   * Makes video capture device prepared for capturing in this mode.
   *
   * @param constraints Constraints for preview stream.
   */
  prepareDevice(constraints: StreamConstraints, captureResolution: Resolution):
      Promise<void>;

  /**
   * Gets factory to create capture object for this mode.
   */
  getCaptureFactory(): ModeFactory;

  /**
   * Mode to be fallbacked to when fail to configure this mode.
   */
  readonly fallbackMode: Mode;
}

/**
 * Mode controller managing capture sequence of different camera mode.
 */
export class Modes {
  /**
   * Capture controller of current camera mode.
   */
  current: ModeBase|null = null;

  /**
   * Parameters to create mode capture controller.
   */
  private captureParams: CaptureParams|null = null;

  /**
   * Mode classname and related functions and attributes.
   */
  private readonly allModes: {[mode in Mode]: ModeConfig};

  private handler: CaptureHandler|null = null;

  constructor() {
    // Workaround for b/184089334 on PTZ camera to use preview frame as photo
    // result.
    function checkSupportPTZForPhotoMode(
        captureResolution: Resolution, previewResolution: Resolution) {
      return captureResolution.equals(previewResolution);
    }

    /**
     * Prepares the device for the specific `resolution` and `captureIntent`.
     */
    async function prepareDeviceForPhoto(
        constraints: StreamConstraints, resolution: Resolution,
        captureIntent: CaptureIntent): Promise<void> {
      const deviceOperator = DeviceOperator.getInstance();
      if (deviceOperator === null) {
        return;
      }
      const deviceId = constraints.deviceId;
      await deviceOperator.setCaptureIntent(deviceId, captureIntent);
      await deviceOperator.setStillCaptureResolution(deviceId, resolution);
    }

    this.allModes = {
      [Mode.VIDEO]: {
        getCaptureFactory: () => {
          const params = this.getCaptureParams();
          return new VideoFactory(
              params.constraints, params.captureResolution,
              params.videoSnapshotResolution, assertExists(this.handler));
        },
        isSupported: () => Promise.resolve(true),
        isSupportPTZ: () => true,
        prepareDevice: async (constraints) => {
          const deviceOperator = DeviceOperator.getInstance();
          if (deviceOperator === null) {
            return;
          }
          const deviceId = constraints.deviceId;
          await deviceOperator.setCaptureIntent(
              deviceId, CaptureIntent.kVideoRecord);

          if (await deviceOperator.isBlobVideoSnapshotEnabled(deviceId)) {
            await deviceOperator.setStillCaptureResolution(
                deviceId,
                assertExists(this.getCaptureParams().videoSnapshotResolution));
          }

          // TODO(wtlee): To set the fps range to the default value, we should
          // remove the frameRate from constraints instead of using incomplete
          // range.
          const {minFps, maxFps} =
              getFpsRangeFromConstraints(constraints.video?.frameRate);
          await deviceOperator.setFpsRange(deviceId, minFps, maxFps);
        },
        fallbackMode: Mode.PHOTO,
      },
      [Mode.PHOTO]: {
        getCaptureFactory: () => {
          const params = this.getCaptureParams();
          return new PhotoFactory(
              params.constraints, params.captureResolution,
              assertExists(this.handler));
        },
        isSupported: () => Promise.resolve(true),
        isSupportPTZ: checkSupportPTZForPhotoMode,
        prepareDevice: async (constraints, resolution) => prepareDeviceForPhoto(
            constraints, resolution, CaptureIntent.kStillCapture),
        fallbackMode: Mode.SCAN,
      },
      [Mode.PORTRAIT]: {
        getCaptureFactory: () => {
          const params = this.getCaptureParams();
          return new PortraitFactory(
              params.constraints, params.captureResolution,
              assertExists(this.handler));
        },
        isSupported: async (deviceId) => {
          if (deviceId === null) {
            return false;
          }
          const deviceOperator = DeviceOperator.getInstance();
          if (deviceOperator === null) {
            return false;
          }
          return deviceOperator.isPortraitModeSupported(deviceId);
        },
        isSupportPTZ: checkSupportPTZForPhotoMode,
        prepareDevice: async (constraints, resolution) => prepareDeviceForPhoto(
            constraints, resolution, CaptureIntent.kPortraitCapture),
        fallbackMode: Mode.PHOTO,
      },
      [Mode.SCAN]: {
        getCaptureFactory: () => {
          const params = this.getCaptureParams();
          return new ScanFactory(
              params.constraints, params.captureResolution,
              assertExists(this.handler));
        },
        isSupported: async () => Promise.resolve(true),
        isSupportPTZ: checkSupportPTZForPhotoMode,
        prepareDevice: async (constraints, resolution) => prepareDeviceForPhoto(
            constraints, resolution, CaptureIntent.kStillCapture),
        fallbackMode: Mode.PHOTO,
      },
    };

    expert.addObserver(
        expert.ExpertOption.SAVE_METADATA, () => this.updateSaveMetadata());
  }

  initialize(handler: CaptureHandler): void {
    this.handler = handler;
  }

  private getCaptureParams(): CaptureParams {
    assert(this.captureParams !== null);
    return this.captureParams;
  }

  /**
   * Gets all mode candidates. Desired trying sequence of candidate modes is
   * reflected in the order of the returned array.
   */
  async getModeCandidates(deviceId: string|null, startingMode: Mode):
      Promise<Mode[]> {
    const tried = new Set<Mode>();
    const results: Mode[] = [];
    let mode = startingMode;
    while (!tried.has(mode)) {
      tried.add(mode);
      if (await this.isSupported(mode, deviceId)) {
        results.push(mode);
      }
      mode = this.allModes[mode].fallbackMode;
    }
    return results;
  }

  /**
   * Gets factory to create `mode` capture object.
   */
  getModeFactory(mode: Mode): ModeFactory {
    return this.allModes[mode].getCaptureFactory();
  }

  /**
   * @param mode Mode for the capture.
   * @param constraints Constraints for preview stream.
   * @param captureResolution Capture resolution. May be null on device not
   *     support of setting resolution.
   * @param videoSnapshotResolution Video snapshot resolution. May be null on
   *     device not support of setting resolution.
   */
  setCaptureParams(
      mode: Mode, constraints: StreamConstraints,
      captureResolution: Resolution|null,
      videoSnapshotResolution: Resolution|null): void {
    this.captureParams =
        {mode, constraints, captureResolution, videoSnapshotResolution};
  }

  /**
   * Makes video capture device prepared for capturing in this mode.
   */
  async prepareDevice(): Promise<void> {
    if (state.get(state.State.USE_FAKE_CAMERA)) {
      return;
    }
    const {mode, captureResolution, constraints} = this.getCaptureParams();
    return this.allModes[mode].prepareDevice(
        constraints, assertInstanceof(captureResolution, Resolution));
  }

  async isSupported(mode: Mode, deviceId: string|null): Promise<boolean> {
    return this.allModes[mode].isSupported(deviceId);
  }

  isSupportPTZ(
      mode: Mode, captureResolution: Resolution,
      previewResolution: Resolution): boolean {
    return this.allModes[mode].isSupportPTZ(
        captureResolution, previewResolution);
  }

  /**
   * Creates and updates current mode object.
   *
   * @param factory The factory ready for producing mode capture object.
   */
  async updateMode(factory: ModeFactory): Promise<void> {
    if (this.current !== null) {
      await this.current.clear();
      this.disableSaveMetadata();
    }
    this.current = factory.produce();
    await this.updateSaveMetadata();
  }

  /**
   * Clears everything when mode is not needed anymore.
   */
  async clear(): Promise<void> {
    if (this.current !== null) {
      await this.current.clear();
      this.disableSaveMetadata();
    }
    this.captureParams = null;
    this.current = null;
  }

  /**
   * Checks whether to save image metadata or not.
   */
  private async updateSaveMetadata(): Promise<void> {
    if (expert.isEnabled(expert.ExpertOption.SAVE_METADATA)) {
      await this.enableSaveMetadata();
    } else {
      this.disableSaveMetadata();
    }
  }

  /**
   * Enables save metadata of subsequent photos in the current mode.
   */
  private async enableSaveMetadata(): Promise<void> {
    if (this.current !== null) {
      await this.current.addMetadataObserver();
    }
  }

  /**
   * Disables save metadata of subsequent photos in the current mode.
   */
  private disableSaveMetadata(): void {
    if (this.current !== null) {
      this.current.removeMetadataObserver();
    }
  }
}