GrCameraCapture
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
| Need | Take |
|---|---|
| Accept a ready file: a drop zone, a queue, checks | GrFileUpload / GrFormFile |
| Choose a crop out of a picture already obtained | GrImageCrop |
| Show a picture full screen without changing anything | GrImageViewer |
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:
| State | What happened | What the user should do |
|---|---|---|
denied | a refusal or a policy | allow it in the settings of the site |
missing | there is no camera | nothing — there is no retry button |
busy | the device is taken by another application | close that application and retry |
insecure | the page is not opened over HTTPS | nothing: 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.
deviceIdis 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-mediaImport
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
<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>