GrCameraCapture

Package: @feugene/granularity-mediacompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • an avatar without a file manager — the user is on a phone, and “take a photo” is faster than looking for a shot in the gallery;
  • a document or a business card in a form — with the rear camera, and the recognition already on the server;
  • a confirmation of presence — a selfie in an application, where the fact of shooting here and now matters;
  • a quick shot of a product — the card is filled in right from the warehouse.

When to take something else

NeedTake
Accept a ready file: a drop zone, a queue, checksGrFileUpload / GrFormFile
Choose a crop out of a picture already obtainedGrImageCrop
Show a picture full screen without changing anythingGrImageViewer

The camera does not switch itself on

autoStart is off by default. A permission request that popped up without an action from the user is declined without a glance — and a second time the browser will not ask: the decision is remembered for the site as a whole, and it cannot be fixed from the application. The component therefore shows a button first, and only it starts the request.

Every refusal has its own reason and its own next step

getUserMedia answers with a DOMException, and the name shows what exactly happened. The component separates four outcomes, because the user’s actions in them differ:

StateWhat happenedWhat the user should do
denieda refusal or a policyallow it in the settings of the site
missingthere is no cameranothing — there is no retry button
busythe device is taken by another applicationclose that application and retry
insecurethe page is not opened over HTTPSnothing: the API is absent

The last one is not a refusal. On http:// the navigator.mediaDevices object does not exist at all, and a “please allow access” message would send the person looking for a setting that is not there.

The parsing goes by the name of the exception rather than by the text: the text is localised by the browser and changes between versions. The names, though, are chosen differently by different browsers — Safari calls a busy device NotReadableError, and Firefox AbortError.

The frame is not fitted to the window

Cameras on different devices give away different sizes and ratios, so fitting the frame to a fixed window is pointless: on one phone one thing would be cut off, on another something else. The component shows and captures what the camera gave, and the frame accepts its ratio — until the first frame it holds 4:3, so that the room in the layout is visible (the sizes of the frame before loadedmetadata are zeros).

aspectRatio has not disappeared in the process, but it means something else: a wish to the camera. It goes into getUserMedia as ideal, and a device that can do what was asked for will give it by itself. exact must not be taken here — it gives an OverconstrainedError, that is, the “there is no camera” state on a working camera with a different ratio.

If exactly a square is needed regardless of the device, that is the next step rather than this component: GrImageCrop cuts what has already been shot.

`output` is a bounding box rather than an exact size

output.width without height is an ordinary order (“an avatar 800 wide”), and the second side is computed from the ratio of the frame. Taken from the source, it would stretch the picture: a frame of 640×480 with width: 800 would give a canvas of 800×480, that is, an image stretched horizontally by a quarter.

Both sides are not a literal size either but a frame the shot is fitted into: the proportions of the shot are never distorted in this package.

The preview is mirrored, the shot is not

A front camera is shown mirrored: a person is used to seeing themselves as in a mirror, and a non-mirrored preview reads as someone else’s face. That reflection is not carried over to the shot — otherwise the text on a business card or a document would go into the looking glass, and that is exactly what is shot with the rear camera.

It is governed by the mirror prop; without it only facing="user" is mirrored.

The stream goes out together with the component

A live track keeps the camera indicator on even when the component is no longer on the screen: the browser puts it out only on a stop() of every track. Unmounting therefore stops the stream — not “just in case” but because otherwise a lamp is lit for the user on a page where there is no camera.

Limits

  • there is no recognition — neither of faces nor of codes: that is a separate task and a separate weight;
  • there is no video recording. The component is about a frame rather than about a stream; recording would require MediaRecorder, formats and control of the duration;
  • there is no choice of a device from a list. deviceId is accepted as a prop, and the enumeration of cameras (enumerateDevices) is left to the application: before the first permission the browser does not give away the names of the devices anyway;
  • there is no cropping. The frame is captured in full; cutting what is needed out of it is the work of GrImageCrop.

Install

npm i @feugene/granularity-media

Import

import { GrCameraCapture } from '@feugene/granularity-media/components/GrCameraCapture'

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 1

Basic

Basic
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'

import { GrBadge } from '@feugene/granularity'
import type { GrCameraStatus } from '@feugene/granularity-media'

/**
 * Демо специально не включает камеру само: на странице документации это
 * означало бы запрос разрешения у каждого, кто зашёл почитать.
 */
const shot = ref<string | null>(null)
const shotSize = ref<{ width: number, height: number } | null>(null)
const status = ref<GrCameraStatus>('idle')
const camera = useTemplateRef('camera')

function onCapture(blob: Blob) {
  if (shot.value)
    URL.revokeObjectURL(shot.value)

  shot.value = URL.createObjectURL(blob)
}

function onShotLoad(event: Event) {
  const img = event.target as HTMLImageElement
  shotSize.value = { width: img.naturalWidth, height: img.naturalHeight }
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <!--
      Соотношение сторон не задано намеренно: рамка примет его от камеры.
      Зашитое число показало бы обрезанный кадр как настоящий — а камеры отдают
      то 4:3, то 16:9.
    -->
    <GrCameraCapture
      ref="camera"
      :output="{ width: 800, type: 'image/jpeg', quality: 0.9 }"
      @capture="onCapture"
      @status-change="(value: GrCameraStatus) => (status = value)"
    />

    <div class="showcase-demo-panel grid content-start gap-3 rounded-[var(--gr-radius-lg)] border p-4">
      <p class="showcase-demo-text text-sm">
        Состояние: <GrBadge size="sm" tone="neutral">{{ status }}</GrBadge>
      </p>

      <p class="showcase-demo-text text-sm">
        Камера включается только по кнопке. Запрос разрешения, всплывший сам по себе,
        отклоняют не глядя — а второй раз браузер уже не спросит.
      </p>

      <template v-if="shot">
        <img
          :src="shot"
          alt="Снимок с камеры"
          class="w-full rounded-[var(--gr-radius-md)]"
          @load="onShotLoad"
        >
        <p v-if="shotSize" class="showcase-demo-text text-sm">
          Снимок: <strong>{{ shotSize.width }} × {{ shotSize.height }}</strong> — те же пропорции,
          что и у превью. Камеры отдают то 4:3, то 16:9, и кадр не подгоняется под окно: нужен
          ровно квадрат — это <code>GrImageCrop</code> следующим шагом.
        </p>
        <p class="showcase-demo-text text-sm">
          Превью фронтальной камеры зеркальное, а снимок — нет: иначе текст в кадре уехал бы
          в зазеркалье.
        </p>
      </template>
    </div>
  </div>
</template>

Component documentationAll components