chromium/ash/webui/camera_app_ui/resources/js/device/ptz_controller.ts

// Copyright 2024 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, assertNotReached} from '../assert.js';
import {Flag} from '../flag.js';
import {Point} from '../geometry.js';
import * as loadTimeData from '../models/load_time_data.js';
import {DeviceOperator} from '../mojo/device_operator.js';
import * as state from '../state.js';
import {CropRegionRect, Mode, Resolution} from '../type.js';

enum PTZAttr {
  PAN = 'pan',
  TILT = 'tilt',
  ZOOM = 'zoom',
}

export interface PTZCapabilities {
  pan: MediaSettingsRange;
  tilt: MediaSettingsRange;
  zoom: MediaSettingsRange;
}

interface PTZSettings {
  pan?: number;
  tilt?: number;
  zoom?: number;
}

/**
 * All pan, tilt, and zoom values must be non-empty.
 */
export type StrictPTZSettings = Required<PTZSettings>;

export interface PTZController {
  /**
   * Returns whether pan control is supported.
   */
  canPan(): boolean;

  /**
   * Returns whether tilt control is supported.
   */
  canTilt(): boolean;

  /**
   * Returns whether zoom control is supported.
   */
  canZoom(): boolean;

  /**
   * Returns min, max, and step values for pan, tilt, and zoom controls.
   */
  getCapabilities(): PTZCapabilities;

  /**
   * Returns current pan, tilt, and zoom settings.
   */
  getSettings(): PTZSettings;

  /**
   * Updates PTZ settings when the screen is rotated.
   */
  handleScreenRotationUpdated(): Promise<void>;

  /**
   * Returns whether pan and tilt functionalities are disabled when the
   * video is fully zoomed out.
   */
  isPanTiltRestricted(): boolean;

  /**
   * Resets to the default PTZ value.
   */
  resetPTZ(): Promise<void>;

  /**
   * Applies a new pan value.
   */
  pan(value: number): Promise<void>;

  /**
   * Applies a new tilt value.
   */
  tilt(value: number): Promise<void>;

  /**
   * Applies a new zoom value.
   */
  zoom(value: number): Promise<void>;
}

/**
 * A set of vid:pid of external cameras whose pan and tilt controls are disabled
 * when all zooming out.
 */
const panTiltRestrictedCameras = new Set([
  '046d:0809',
  '046d:0823',
  '046d:0825',
  '046d:082d',
  '046d:0843',
  '046d:085c',
  '046d:085e',
  '046d:0893',
]);

export class MediaStreamPTZController implements PTZController {
  constructor(
      readonly track: MediaStreamTrack,
      readonly defaultPTZ: MediaTrackConstraintSet,
      readonly vidPid: string|null) {}

  canPan(): boolean {
    return this.track.getCapabilities().pan !== undefined;
  }

  canTilt(): boolean {
    return this.track.getCapabilities().tilt !== undefined;
  }

  canZoom(): boolean {
    return this.track.getCapabilities().zoom !== undefined;
  }

  getCapabilities(): PTZCapabilities {
    return this.track.getCapabilities();
  }

  getSettings(): PTZSettings {
    return this.track.getSettings();
  }

  async handleScreenRotationUpdated(): Promise<void> {
    /* Do nothing. */
  }

  isPanTiltRestricted(): boolean {
    return state.get(state.State.USE_FAKE_CAMERA) ||
        (this.vidPid !== null && panTiltRestrictedCameras.has(this.vidPid));
  }

  async resetPTZ(): Promise<void> {
    await this.track.applyConstraints({advanced: [this.defaultPTZ]});
  }

  async pan(value: number): Promise<void> {
    await this.applyPTZ(PTZAttr.PAN, value);
  }

  async tilt(value: number): Promise<void> {
    await this.applyPTZ(PTZAttr.TILT, value);
  }

  async zoom(value: number): Promise<void> {
    await this.applyPTZ(PTZAttr.ZOOM, value);
  }

  private async applyPTZ(attr: PTZAttr, value: number): Promise<void> {
    if (!this.track.enabled) {
      return;
    }
    await this.track.applyConstraints({advanced: [{[attr]: value}]});
  }
}

const DIGITAL_ZOOM_MAX_PAN = 1;
const DIGITAL_ZOOM_MAX_TILT = 1;
const DIGITAL_ZOOM_DEFAULT_MAX_ZOOM = 6;
export const DIGITAL_ZOOM_CAPABILITIES: PTZCapabilities = {
  pan: {min: -DIGITAL_ZOOM_MAX_PAN, max: DIGITAL_ZOOM_MAX_PAN, step: 0.1},
  tilt: {min: -DIGITAL_ZOOM_MAX_TILT, max: DIGITAL_ZOOM_MAX_TILT, step: 0.1},
  zoom: {min: 1, max: DIGITAL_ZOOM_DEFAULT_MAX_ZOOM, step: 0.1},
};
const DIGITAL_ZOOM_DEFAULT_SETTINGS: PTZSettings = {
  pan: 0,
  tilt: 0,
  zoom: 1,
};

/**
 * Calculate the crop region when fully zoomed out for the given aspect ratio.
 * The crop region is calculated based on camera metadata
 * ANDROID_SENSOR_INFO_ACTIVE_ARRAY_SIZE. If the target aspect ratio doesn't
 * match the active array's aspect ratio, the crop region is either cropped
 * vertically or horizontally, and centered within the active array.
 */
function getFullCropRegionForAspectRatio(
    activeArray: Resolution, targetAspectRatio: number): CropRegionRect {
  const {width: originalWidth, height: originalHeight} = activeArray;
  if (activeArray.aspectRatio > targetAspectRatio) {
    // Crop vertically if the original aspect ratio is wider than the target.
    const croppedWidth = Math.round(originalHeight * targetAspectRatio);
    return {
      x: Math.round((originalWidth - croppedWidth) / 2),
      y: 0,
      width: croppedWidth,
      height: originalHeight,
    };
  }
  // Otherwise, crop horizontally.
  const croppedHeight = Math.round(originalWidth / targetAspectRatio);
  return {
    x: 0,
    y: Math.round((originalHeight - croppedHeight) / 2),
    width: originalWidth,
    height: croppedHeight,
  };
}

/**
 * Asserts that all pan, tilt, and zoom fields have values.
 */
export function assertStrictPTZSettings({pan, tilt, zoom}: PTZSettings):
    StrictPTZSettings {
  assert(pan !== undefined);
  assert(tilt !== undefined);
  assert(zoom !== undefined && zoom > 0, `Zoom value ${zoom} is invalid.`);
  return {pan, tilt, zoom};
}

/**
 * Calculate a crop region from given PTZ settings. The crop region result is
 * normalized given full width and full height equal to 1.
 */
function calculateNormalizedCropRegion(ptzSettings: PTZSettings):
    CropRegionRect {
  const {pan, tilt, zoom} = assertStrictPTZSettings(ptzSettings);

  const width = 1 / zoom;
  const height = 1 / zoom;

  // Top-left coordinate of the crop region before the pan and tilt values are
  // applied.
  const startX = (1 - width) / 2;
  const startY = (1 - height) / 2;

  // Move x, y with pan and tilt values. Pan and tilt values are in the range
  // [-1, 1], with pan = -1 being leftmost, and tilt = -1 being bottommost.
  const x = startX + ((pan / DIGITAL_ZOOM_MAX_PAN) * startX);
  const y = startY - ((tilt / DIGITAL_ZOOM_MAX_TILT) * startY);

  // Verify that the calculated crop region is valid.
  const lowerBound = -1e-3;
  const upperBound = 1 + 1e-3;
  assert(x > lowerBound && x < upperBound && y > lowerBound && y < upperBound);
  assert((x + width) < upperBound && (y + height) < upperBound);

  return {x, y, width, height};
}

/**
 * Calculate a crop region from PTZ settings with respect to |fullCropRegion|.
 */
function calculateCropRegion(
    ptzSettings: PTZSettings, fullCropRegion: CropRegionRect): CropRegionRect {
  const normCropRegion = calculateNormalizedCropRegion(ptzSettings);
  const {width: fullWidth, height: fullHeight} = fullCropRegion;

  return {
    x: Math.round(fullCropRegion.x + (normCropRegion.x * fullWidth)),
    y: Math.round(fullCropRegion.y + (normCropRegion.y * fullHeight)),
    width: Math.round(normCropRegion.width * fullWidth),
    height: Math.round(normCropRegion.height * fullHeight),
  };
}

/**
 * Asserts that pan, tilt, or zoom value is within the range defined in
 * |DIGITAL_ZOOM_CAPABILITIES|.
 */
function assertPTZRange(attr: PTZAttr, value: number) {
  const {max: maxValue, min: minValue} = DIGITAL_ZOOM_CAPABILITIES[attr];
  const tolerance = 1e-3;
  assert(
      value >= minValue - tolerance && value <= maxValue + tolerance,
      `${attr} value ${value} is not within the allowed range.`);
}

/**
 * Rotates (x, y) clockwise for |rotation| degree around the coordinate (0, 0).
 */
function rotateClockwise(
    x: number, y: number, rotation: number): [number, number] {
  rotation = rotation % 360;
  switch (rotation) {
    case 0:
      return [x, y];
    case 90:
      return [y, -x];
    case 180:
      return [-x, -y];
    case 270:
      return [-y, x];
    default:
      assertNotReached(`Unexpected rotation: ${rotation}`);
  }
}

/**
 * Rotates PTZ settings clockwise by |rotation| degree.
 */
function rotatePTZ(ptzSettings: PTZSettings, rotation: number): PTZSettings {
  const {pan, tilt, zoom} = assertStrictPTZSettings(ptzSettings);
  const [rotatedPan, rotatedTilt] = rotateClockwise(pan, tilt, rotation);
  return {pan: rotatedPan, tilt: rotatedTilt, zoom};
}

export class DigitalZoomPTZController implements PTZController {
  /**
   * Current PTZ settings based on the camera frame with rotation = 0. This
   * value remains the same regardless of the camera rotation.
   */
  private ptzSettings: PTZSettings = DIGITAL_ZOOM_DEFAULT_SETTINGS;

  /**
   * Current camera frame rotation.
   */
  private cameraRotation: number = 0;

  private constructor(
      private readonly deviceId: string,
      private readonly fullCropRegion: CropRegionRect) {}

  canPan(): boolean {
    return true;
  }

  canTilt(): boolean {
    return true;
  }

  canZoom(): boolean {
    return true;
  }

  getCapabilities(): PTZCapabilities {
    return DIGITAL_ZOOM_CAPABILITIES;
  }

  getSettings(): PTZSettings {
    // Rotates current PTZ settings because pan and tilt values are different in
    // different camera frame rotations.
    return rotatePTZ(this.ptzSettings, this.cameraRotation);
  }

  async handleScreenRotationUpdated(): Promise<void> {
    const deviceOperator = assertExists(DeviceOperator.getInstance());
    this.cameraRotation =
        await deviceOperator.getCameraFrameRotation(this.deviceId);
  }

  isPanTiltRestricted(): boolean {
    // When fully zoomed out, calculated crop region equals to |fullCropRegion|,
    // this means pan and tilt are disabled.
    return true;
  }

  /**
   * Map a point on the preview frame to a corresponding point on the camera
   * frame based on the current crop region.
   *
   * @param point The point in normalize coordidate system, which means both
   *     |x| and |y| are in range [0, 1).
   */
  calculatePointOnCameraFrame(point: Point): Point {
    const activeRegion = calculateNormalizedCropRegion(this.getSettings());
    const x = activeRegion.x + (point.x * activeRegion.width);
    const y = activeRegion.y + (point.y * activeRegion.height);
    return new Point(x, y);
  }

  async resetPTZ(): Promise<void> {
    const deviceOperator = assertExists(DeviceOperator.getInstance());
    await deviceOperator.resetCropRegion(this.deviceId);
    this.ptzSettings = DIGITAL_ZOOM_DEFAULT_SETTINGS;
    state.set(state.State.SUPER_RES_ZOOM, false);
  }

  async pan(value: number): Promise<void> {
    assertPTZRange(PTZAttr.PAN, value);
    const newSettings = {...this.getSettings(), pan: value};
    await this.applyPTZ(newSettings);
  }

  async tilt(value: number): Promise<void> {
    assertPTZRange(PTZAttr.TILT, value);
    const newSettings = {...this.getSettings(), tilt: value};
    await this.applyPTZ(newSettings);
  }

  async zoom(value: number): Promise<void> {
    assertPTZRange(PTZAttr.ZOOM, value);
    const newSettings = {...this.getSettings(), zoom: value};
    await this.applyPTZ(newSettings);
  }

  private async applyPTZ(settings: PTZSettings): Promise<void> {
    if (this.isFullFrame(settings)) {
      return this.resetPTZ();
    }

    const deviceOperator = assertExists(DeviceOperator.getInstance());

    // Rotates |settings| counterclockwise to get the PTZ settings for 0
    // degree camera rotation.
    this.cameraRotation =
        await deviceOperator.getCameraFrameRotation(this.deviceId);
    const baseSettings = rotatePTZ(settings, 360 - this.cameraRotation);

    const cropRegion = calculateCropRegion(baseSettings, this.fullCropRegion);
    await deviceOperator.setCropRegion(this.deviceId, cropRegion);
    this.ptzSettings = baseSettings;

    state.set(state.State.SUPER_RES_ZOOM, this.isSuperResZoomPhotoMode());
  }

  private isSuperResZoomPhotoMode(): boolean {
    return state.get(Mode.PHOTO) && loadTimeData.getChromeFlag(Flag.SUPER_RES);
  }

  private isFullFrame({zoom}: PTZSettings): boolean {
    assert(zoom !== undefined);
    const minZoom = assertExists(DIGITAL_ZOOM_CAPABILITIES.zoom.min);
    const zoomStep = assertExists(DIGITAL_ZOOM_CAPABILITIES.zoom.step);
    return Math.abs(zoom - minZoom) < zoomStep;
  }

  static async create(deviceId: string, aspectRatio: number):
      Promise<DigitalZoomPTZController> {
    const deviceOperator = assertExists(DeviceOperator.getInstance());
    const activeArray = await deviceOperator.getActiveArraySize(deviceId);
    const fullCropRegion =
        getFullCropRegionForAspectRatio(activeArray, aspectRatio);
    return new DigitalZoomPTZController(deviceId, fullCropRegion);
  }
}