GrCameraCapture

Пакет: @feugene/granularity-mediaспутникГруппа: Прочее

Берут, когда аватар без файлового менеджера.

Когда брать

  • аватар без файлового менеджера — пользователь на телефоне, и «сфотографировать» быстрее, чем искать снимок в галерее;
  • документ или визитка в форме — тыловой камерой, а распознавание уже на сервере;
  • подтверждение присутствия — селфи в заявке, где важен сам факт съёмки здесь и сейчас;
  • быстрый снимок товара — карточка заполняется прямо со склада.

Когда взять другое

НужноБерите
Принять готовый файл: зона переноса, очередь, проверкиGrFileUpload / GrFormFile
Выбрать кадр из уже полученной картинкиGrImageCrop
Показать картинку во весь экран, ничего не меняяGrImageViewer

Камера не включается сама

autoStart по умолчанию выключен. Запрос разрешения, всплывший без действия пользователя, отклоняют не глядя — а второй раз браузер уже не спросит: решение запоминается для сайта целиком, и починить это из приложения нельзя. Поэтому первым делом компонент показывает кнопку, и только она запускает запрос.

У каждого отказа своя причина и свой следующий шаг

getUserMedia отвечает DOMException, и по имени видно, что именно случилось. Компонент разводит четыре исхода, потому что действия пользователя в них разные:

СостояниеЧто произошлоЧто делать пользователю
deniedотказ или политикаразрешить в настройках сайта
missingкамеры нетничего — кнопки повтора нет
busyустройство занято другим приложениемзакрыть то приложение и повторить
insecureстраница открыта не по HTTPSничего: API отсутствует

Последнее — не отказ. На http:// объекта navigator.mediaDevices не существует вовсе, и сообщение «разрешите доступ» отправило бы человека искать настройку, которой нет.

Разбор идёт по имени исключения, а не по тексту: текст локализован браузером и меняется между версиями. Имена же разные браузеры выбирают по-своему — занятое устройство Safari называет NotReadableError, Firefox — AbortError.

Кадр не подгоняется под окно

Камеры на разных устройствах отдают разные размеры и соотношения, поэтому подгонять кадр под фиксированное окно бессмысленно: на одном телефоне срезалось бы одно, на другом другое. Компонент показывает и снимает то, что отдала камера, а рамка принимает её соотношение — до первого кадра держится 4:3, чтобы место в раскладке было видно (размеры кадра до loadedmetadata — нули).

aspectRatio при этом не исчез, но означает другое: пожелание камере. Оно уходит в getUserMedia как ideal, и устройство, которое умеет запрошенное, отдаст его само. exact тут брать нельзя — он даёт OverconstrainedError, то есть состояние «камеры нет» на исправной камере с другим соотношением.

Нужен ровно квадрат независимо от устройства — это следующий шаг, а не этот компонент: GrImageCrop режет уже снятое.

`output` — габарит, а не точный размер

output.width без height — обычный заказ («аватар шириной 800»), и вторая сторона считается из соотношения кадра. Взятая из исходника, она растянула бы картинку: кадр 640×480 при width: 800 дал бы холст 800×480, то есть изображение, растянутое по горизонтали на четверть.

Обе стороны — тоже не буквальный размер, а рамка, в которую кадр вписывается: пропорции съёмки в этом пакете не искажаются никогда.

Превью зеркалится, снимок — нет

Фронтальную камеру показывают зеркально: человек привык видеть себя как в зеркале, и незеркальное превью читается как чужое лицо. На снимок это отражение не переносится — иначе текст на визитке или документе уехал бы в зазеркалье, а снимают тыловой камерой ровно это.

Управляется пропом mirror; без него зеркалится только facing="user".

Поток гаснет вместе с компонентом

Живой трек держит индикатор камеры включённым, даже когда компонента уже нет на экране: браузер гасит его только по stop() каждой дорожки. Поэтому размонтирование останавливает поток — не «на всякий случай», а потому что иначе у пользователя горит лампочка на странице, где камеры нет.

Границы

  • нет распознавания — ни лиц, ни кодов: это отдельная задача и отдельный вес;
  • нет записи видео. Компонент про кадр, а не про поток; запись потребовала бы MediaRecorder, форматов и управления длительностью;
  • нет выбора устройства списком. deviceId принимается пропом, а перечисление камер (enumerateDevices) остаётся за приложением: до первого разрешения имена устройств браузер всё равно не отдаёт;
  • нет обрезки. Кадр снимается целиком; вырезать из него нужное — работа GrImageCrop.

Установка

npm i @feugene/granularity-media

Импорт

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

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 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>

Документация компонентаВсе компоненты