A API MediaDevices fornece interfaces para interagir com dispositivos de mídia conectados, permitindo acesso à mídia gerada pelo hardware. Neste artigo, veremos como a biblioteca JavaScript Stimulus pode nos ajudar a integrar essa API em um ambiente Rails.
Iremos criar um controller
para solicitar a permissão do usuário para acessar dispositivos de mídia conectados, capturar um stream de vídeo na melhor qualidade possível e extrair uma foto de um frame do vídeo. Além disso, desenvolveremos um helper
que facilitará a integração desse componente, permitindo salvar a foto capturada utilizando ActiveStorage. Este artigo assume que seu ambiente Rails e Stimulus já está configurado.
O básico sobre Stimulus
Se você já sabe como criar um controller
Stimulus e conectá-lo na página, sinta-se à vontade para pular para o próximo tópico.
O primeiro passo é criar o controller
Stimulus e conectá-lo com HTML. O Rails possui um comando para agilizar a criação:
rails g stimulus camera
O que esse comando faz é criar o arquivo camera_controller.js
e registrar o controller
em index.js
. Bem simples. Segue abaixo o controller
gerado:
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
connect() {
}
}
O Stimulus interage com a página através do HTML de forma bastante prática. Veja o exemplo abaixo:
<div data-controller="camera"></div>
Quando essa div
for renderizada na DOM, o controller
de nome camera
será conectado. Por isso, é essencial garantir que ele esteja devidamente registrado no index.js
.
Nosso ponto de partida
Acessar a câmera é um processo relativamente simples. Basta utilizar o seguinte comando:
const constraints = { video: true }
await navigator.mediaDevices.getUserMedia(constraints)
Ao executar esse comando, o navegador solicitará permissão ao usuário para acessar os dispositivos de mídia conectados. Caso a permissão seja concedida, a API selecionará automaticamente o dispositivo que melhor atenda às restrições definidas.
Esse comando retorna um objeto MediaStream
, que representa o fluxo de mídia capturado pelo dispositivo. Esse fluxo é composto por tracks, e podemos utilizá-lo diretamente como fonte de um elemento <video>
, permitindo a reprodução em tempo real.
Vejamos como podemos fazer isso com Stimulus:
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
static targets = ["display"]
async connect() {
if ("mediaDevices" in navigator && "getUserMedia" in navigator.mediaDevices) {
const constraints = { video: true }
const stream = await navigator.mediaDevices.getUserMedia(constraints)
this.displayTarget.srcObject = stream
}
}
}
Para tornar essa funcionalidade reutilizável em um projeto Rails, podemos criar um helper
que encapsula a estrutura HTML e conecta o controller
Stimulus automaticamente:
# camera_helper.rb
module CameraHelper
def camera
content_tag(:div, data: { controller: "camera" }) do
tag.video(autoplay: true, data: { "camera-target": "display" })
end
end
end
Agora, sempre que esse helper
for chamado em uma view, ele adicionará automaticamente o suporte à câmera na página. Assim que o usuário conceder a permissão para acessar os dispositivos, o stream de vídeo será exibido em tempo real.
Os navegadores costumam limitar o acesso à alguns recursos em websites que não possuem o protocolo HTTPS, e via de regra, o localhost utiliza apenas o HTTP. Para contornar isso no seu ambiente de desenvolvimento, é possível executar os navegadores sem as restrições de segurança.
Google Chrome
Para usuários de Windows:
"C:\Program Files\Google\Chrome\Application\chrome.exe" --unsafely-treat-insecure-origin-as-secure="http://localhost:3000/"
Para usuários de Linux:
google-chrome --unsafely-treat-insecure-origin-as-secure="http://localhost:3000/"
Firefox
- Abra o Firefox.
- Na barra de endereços, digite:
about:config
e pressione Enter.
- Clique no botão “Aceitar o risco e continuar” (se aparecer).
- Na barra de pesquisa, procure por:
media.devices.insecure.enabled
media.getusermedia.insecure.enabled
- Se o valor estiver como
false
, clique duas vezes para alterá-lo paratrue
.
Controles básicos
Agora que já aprendemos a utilizar a API MediaDevices para capturar imagens, podemos avançar um pouco mais e adicionar controles básicos para iniciar e encerrar a câmera. No Stimulus, uma ação é uma função JavaScript vinculada a um evento de um elemento HTML. Qualquer função pública dentro de um controller
pode ser chamada, desde que esteja corretamente associada a um elemento da página.
Vamos criar dois botões: um para iniciar e outro para interromper a captura de vídeo. As ações associadas a esses botões serão play()
e stop()
.
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
static targets = ["display"]
videoStream = null
async play() {
await this.#setVideoStream()
}
stop() {
this.videoStream.getTracks().forEach((track) => track.stop())
}
async #setVideoStream() {
if ("mediaDevices" in navigator && "getUserMedia" in navigator.mediaDevices) {
const constraints = { video: true }
this.videoStream = await navigator.mediaDevices.getUserMedia(constraints)
this.displayTarget.srcObject = this.videoStream
}
}
}
As funções play()
e stop()
serão nossas ações executadas por elementos HTML. Já a função #setVideoStream()
é privada e não pode ser chamada diretamente pelos elementos. Essa função é responsável por criar o fluxo de mídia e armazená-lo na variável videoStream
. Essa variável pode ser acessada por outros métodos, como stop()
, que a utiliza para encerrar o fluxo de vídeo corretamente. Dessa forma, garantimos que o objeto MediaStream
fique acessível enquanto o componente estiver ativo.
Agora, precisamos criar os botões que irão ativar essas funções no Stimulus:
# camera_helper.rb
module CameraHelper
def camera
content_tag(:div, data: { controller: "camera" }) do
concat(camera_display)
concat(camera_actions)
end
end
def camera_display
tag.video(autoplay: true, data: { "camera-target": "display" })
end
def camera_actions
content_tag(:div) do
concat(tag.button("play", data: { action: "click->camera#play" }, type: "button"))
concat(tag.button("stop", data: { action: "click->camera#stop" }, type: "button"))
end
end
end
As ações são acionadas pelo atributo data-action
nos botões, que define qual função deve ser chamada ao ocorrer um evento, sendo que o evento não é obrigatório. O evento pode ser especificado no atributo, mas, em muitos casos, o próprio elemento HTML já define um evento padrão (como click
para botões e input
para campos de formulário). Com essa implementação, temos um componente funcional que permite ao usuário iniciar e parar a transmissão de vídeo de forma intuitiva.
Qualidade de captura
A API permite manipular a qualidade de captura através de parâmetros, conhecidos como constraints
, que são fornecidos na inicialização de um MediaStream
. A API tentará configurar o streaming de forma a atender o máximo possível das restrições impostas. Algumas das constraints
mais utilizadas são width
e height
.
Navegadores diferentes podem disponibilizar
constraints
diferentes. É possível checar o que está disponível em um navegador específico através do comandonavigator.mediaDevices.getSupportedConstraints()
. Útil para garantir compatibilidade entre navegadores.
Meu caso de uso requeria capturas no formato 3:4, então pensei em estabelecer um valor fixo de pixels de 540×720, porém, isso significa que câmeras melhores não seriam usadas em sua capacidade máxima e câmeras inferiores seriam incapazes de gerar imagens no formato desejado. Para garantir a melhor captura de vídeo possível, decidi calibrar a qualidade usando uma lista de resoluções 3:4 conhecidas. O processo de calibração foi projetado para encontrar a melhor qualidade de captura para cada dispositivo, dentro das restrições de formato. Segue abaixo o processo de calibração:
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
// ...
async #setVideoStream() {
if ("mediaDevices" in navigator && "getUserMedia" in navigator.mediaDevices) {
const calibration = await this.#calibrate()
const resolutions = this.#cameraResolutions()
const streamQuality = resolutions[calibration]
const constraints = { video: streamQuality }
this.videoStream = await navigator.mediaDevices.getUserMedia(constraints)
this.displayTarget.srcObject = this.videoStream
}
}
async #calibrate() {
const resolutions = this.#cameraResolutions()
const keys = Object.keys(resolutions)
for (let i = keys.length - 1; i >= 0; i--) {
let key = keys[i]
let resolution = resolutions[key]
try {
await navigator.mediaDevices.getUserMedia({ video: resolution })
return key
} catch (error) {
continue
}
}
throw new DOMException("Sem resoluções válidas", "NoResolutionError")
}
#cameraResolutions() {
return {
custom: null, // no restrictions
sd: { width: { exact: 360 }, height: { exact: 480 } }, // 640x480
hd: { width: { exact: 540 }, height: { exact: 720 } }, // 1280x720
fhd: { width: { exact: 810 }, height: { exact: 1080 } }, // 1920x1080
qhd: { width: { exact: 1080 }, height: { exact: 1440 } }, // 2560x1440
uhd: { width: { exact: 1620 }, height: { exact: 2160 } } // 3840x2160
}
}
}
Antes de vincular o MediaStream
ao video
, é realizada uma calibração baseada em uma lista de resoluções 3:4, entregando a melhor qualidade possível para o dispositivo usado. A calibração percorre a lista, da maior qualidade conhecida até a menor, e se não for possível criar o fluxo de mídia com tais dimensões, levanta o erro OverconstrainedError
. Nesse caso, será testada a qualidade logo abaixo, até que a primeira válida seja encontrada.
Otimização com Cookies
Realizar a calibração sempre desperdiça recursos e aumenta o tempo necessário para o carregamento. Para otimizar o componente, considere salvar a qualidade mais adequada em cookies
e reutilizá-la em futuras interações.
Salvar a qualidade no cookie ajuda a evitar calibrações desnecessárias, reduzindo o tempo de carregamento nas visitas subsequentes. No entanto, é importante garantir que o cookie seja atualizado caso o dispositivo ou preferências do usuário mudem. Você pode limpar ou atualizar o cookie quando necessário, garantindo que ele sempre contenha a melhor qualidade de captura disponível para o dispositivo.
Situações em que existem mais de um dispositivo
Ao definir uma medida fixa para as dimensões, a API tentará configurar o streaming de forma a atender o máximo possível das restrições impostas. Isso pode resultar no uso de diferentes câmeras, caso o dispositivo tenha mais de uma. Se houver múltiplas câmeras conectadas, considere utilizar o parâmetro
deviceId
para escolher um dispositivo específico, evitando que a API selecione a câmera incorreta.Ao definir um
deviceId
, você garante que a captura de vídeo será feita a partir de um dispositivo específico. Isso pode ser útil quando há múltiplas câmeras conectadas ao dispositivo, como câmeras frontal e traseira.Limitações no Firefox
O Firefox não permite manipular o
aspectRatio
da captura, o que impede a criação de uma captura 3:4. Para contornar isso, criamos a categoriacustom
, que não impõe restrições às dimensões do vídeo. A manipulação deaspectRatio
funciona corretamente em navegadores baseados em Chromium, como o Chrome.Para checar as configurações do
MediaStream
existente, utilize o seguinte comando:MediaStream.getVideoTracks()[0].getSettings()
.
Capturar foto
O processo de captura consiste em extrair um frame do vídeo e colocar em um canvas
para exibi-lo em tela. No caso de uso em questão, iremos exibir a imagem capturada sobre o display
. Portanto, também será necessário permitir que o usuário exclua a imagem.
Abaixo, temos a implementação em JavaScript:
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
static targets = ["display"]
videoStream = null
canvas = null
snapshot = null
// ...
capture() {
const canvas = document.createElement("canvas")
canvas.width = this.displayTarget.videoWidth
canvas.height = this.displayTarget.videoHeight
canvas.getContext("2d").drawImage(this.displayTarget, 0, 0)
this.canvas = canvas
this.#setSnapshot()
}
eraseSnapshot() {
this.snapshot.remove()
this.snapshot = undefined
this.#toggleDisplay()
}
// ...
#setSnapshot() {
const div = document.createElement("div")
const img = document.createElement("img")
img.src = this.canvas.toDataURL("image/png")
div.append(img, this.#eraseButton())
this.snapshot = div
this.#toggleDisplay()
this.stop()
this.displayTarget.after(div)
}
#eraseButton() {
const button = document.createElement("button")
button.textContent = "erase snapshot"
button.type = "button"
button.dataset["action"] = "click->camera#eraseSnapshot"
return button
}
#toggleDisplay() {
const displayStyle = this.displayTarget.style.display
this.displayTarget.style.display = displayStyle === "none" ? "block" : "none"
}
}
Adicionamos à classe dois atributos: canvas
, que armazena a imagem capturada, e snapshot
, que representa o elemento div
que contém a imagem e o botão de exclusão. O canvas
será usado para criar o snapshot, que servirá apenas para exibição em tela.
A função capture()
cria o canvas
a partir do vídeo e o armazena na classe. Em seguida, ela gera o snapshot
, que substitui o displayTarget
(o elemento video
na tela). Além disso, um botão de exclusão é adicionado ao lado da imagem.
Em relação ao HTML, basta adicionar um botão vinculado à ação de capturar a imagem:
# camera_helper.rb
module CameraHelper
# ...
def camera_actions
content_tag(:div) do
concat(tag.button("play", data: { action: "click->camera#play" }, type: "button"))
concat(tag.button("stop", data: { action: "click->camera#stop" }, type: "button"))
concat(tag.button("capture", data: { action: "click->camera#capture" }, type: "button"))
end
end
end
Upload com ActiveStorage
Iremos utilizar o ActiveStorage para realizar o upload da foto. É requerido que o model se relacione com o arquivo da seguinte forma:
has_one_attached :photo
No formulário, será preciso um input
do tipo file
para enviar o arquivo. No entanto, quem irá preencher esse campo não será o usuário, mas sim o nosso controller
Stimulus. Por isso, o input
deve estar presente na estrutura do controller
Stimulus. Quando o usuário capturar uma imagem, ela será inserida automaticamente no input
, que enviará o arquivo ao servidor quando o formulário for submetido.
Primeiro, o JavaScript:
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
static targets = ["display", "input"]
// ...
async capture() {
const canvas = document.createElement("canvas")
canvas.width = this.displayTarget.videoWidth
canvas.height = this.displayTarget.videoHeight
canvas.getContext("2d").drawImage(this.displayTarget, 0, 0)
this.canvas = canvas
await this.#upload()
this.#setSnapshot()
}
eraseSnapshot() {
this.snapshot.remove()
this.snapshot = undefined
this.#clearInput()
this.#toggleDisplay()
}
// ...
async #upload() {
const blob = await new Promise(resolve => this.canvas.toBlob(resolve))
const file = new File([blob], "photo")
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.inputTarget.files = dataTransfer.files
}
#clearInput() {
this.inputTarget.files = new DataTransfer().files
}
}
Começamos adicionando um novo target chamado input. Ao inserir em nosso HTML um elemento input
e atribui-lo à esse target, somos capazes de manipulá-lo facilmente. Então, executamos a função #upload()
durante a função #capture()
, que agora se tornou assíncrona. Criamos um blob
a partir do canvas
e um file
a partir do blob
, para então inseri-lo no input
em um DataTransfer
. Quando o formulário for enviado ao servidor, o arquivo também será. Para uma melhor experiência, excluir o snapshot também remove o arquivo do input
.
Abaixo veremos as modificações em nosso helper
:
# camera_helper.rb
module CameraHelper
def camera(form, attribute)
content_tag(:div, data: { controller: "camera" }) do
concat(camera_display)
concat(camera_input(form, attribute))
concat(camera_actions)
end
end
# ...
def camera_input(form, attribute)
form.input(attribute, input_html: { data: { "camera-target": "input" } })
end
# ...
end
O formulário utiliza a gem
simple_form
, que automaticamente cria uminput
do tipofile
para atributos de arquivo.
Com essa abordagem, conseguimos integrar o ActiveStorage ao Stimulus de forma transparente. Para uma experiência ainda melhor, podemos ocultar o input
, tornando-o invisível ao usuário.
Conservação de recursos
Para evitar o desperdício de recursos do sistema e o aquecimento do dispositivo de mídia, lembre-se de fechar o streaming sempre que não estiver sendo usado. Se a câmera permanecer ligada por longos períodos de tempo, ela pode reduzir a qualidade da captura, além de comprometer a vida útil do dispositivo. Utilizando o Stimulus, isso pode ser facilmente alcançado da seguinte maneira:
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
// ...
disconnect() {
this.stop()
}
stop() {
this.videoStream.getTracks().forEach((track) => track.stop())
}
// ...
}
disconnect()
é uma função executada sempre que o elemento controller
é removido da DOM
, garantindo que nossa câmera seja desligada quando não for mais necessária.
Versão final do código:
// camera_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="camera"
export default class extends Controller {
static targets = ["display", "input"]
videoStream = null
canvas = null
snapshot = null
disconnect() {
this.stop()
}
async play() {
await this.#setVideoStream()
}
stop() {
this.videoStream.getTracks().forEach((track) => track.stop())
}
async capture() {
const canvas = document.createElement("canvas")
canvas.width = this.displayTarget.videoWidth
canvas.height = this.displayTarget.videoHeight
canvas.getContext("2d").drawImage(this.displayTarget, 0, 0)
this.canvas = canvas
await this.#upload()
this.#setSnapshot()
}
eraseSnapshot() {
this.snapshot.remove()
this.snapshot = undefined
this.#clearInput()
this.#toggleDisplay()
}
async #setVideoStream() {
if ("mediaDevices" in navigator && "getUserMedia" in navigator.mediaDevices) {
const calibration = await this.#calibrate()
const resolutions = this.#cameraResolutions()
const streamQuality = resolutions[calibration]
const constraints = { video: streamQuality }
this.videoStream = await navigator.mediaDevices.getUserMedia(constraints)
this.displayTarget.srcObject = this.videoStream
}
}
async #calibrate() {
const resolutions = this.#cameraResolutions()
const keys = Object.keys(resolutions)
for (let i = keys.length - 1; i >= 0; i--) {
let key = keys[i]
let resolution = resolutions[key]
try {
await navigator.mediaDevices.getUserMedia({ video: resolution })
return key
} catch (error) {
continue
}
}
throw new DOMException("Sem resoluções válidas", "NoResolutionError")
}
#cameraResolutions() {
return {
custom: null, // no restrictions
sd: { width: { exact: 360 }, height: { exact: 480 } }, // 640x480
hd: { width: { exact: 540 }, height: { exact: 720 } }, // 1280x720
fhd: { width: { exact: 810 }, height: { exact: 1080 } }, // 1920x1080
qhd: { width: { exact: 1080 }, height: { exact: 1440 } }, // 2560x1440
uhd: { width: { exact: 1620 }, height: { exact: 2160 } } // 3840x2160
}
}
#setSnapshot() {
const div = document.createElement("div")
const img = document.createElement("img")
img.src = this.canvas.toDataURL("image/png")
div.append(img, this.#eraseButton())
this.snapshot = div
this.#toggleDisplay()
this.stop()
this.displayTarget.after(div)
}
#eraseButton() {
const button = document.createElement("button")
button.textContent = "erase snapshot"
button.type = "button"
button.dataset["action"] = "click->camera#eraseSnapshot"
return button
}
#toggleDisplay() {
const displayStyle = this.displayTarget.style.display
this.displayTarget.style.display = displayStyle === "none" ? "block" : "none"
}
async #upload() {
const blob = await new Promise(resolve => this.canvas.toBlob(resolve))
const file = new File([blob], "photo")
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.inputTarget.files = dataTransfer.files
}
#clearInput() {
this.inputTarget.files = new DataTransfer().files
}
}
# camera_helper.rb
module CameraHelper
def camera(form, attribute)
content_tag(:div, data: { controller: "camera" }) do
concat(camera_display)
concat(camera_input(form, attribute))
concat(camera_actions)
end
end
def camera_display
tag.video(autoplay: true, data: { "camera-target": "display" })
end
def camera_input(form, attribute)
form.input(attribute, input_html: { data: { "camera-target": "input" } })
end
def camera_actions
content_tag(:div) do
concat(tag.button("play", data: { action: "click->camera#play" }, type: "button"))
concat(tag.button("stop", data: { action: "click->camera#stop" }, type: "button"))
concat(tag.button("capture", data: { action: "click->camera#capture" }, type: "button"))
end
end
end
Considerações finais
Integrar a API MediaDevices com Stimulus e Rails nos permite criar um componente simples, reutilizável e altamente funcional para captura de mídia diretamente no navegador. Através do uso de Stimulus, conseguimos estruturar nossa lógica de forma organizada, garantindo uma experiência fluida para o usuário sem a necessidade de bibliotecas mais complexas.
Por fim, vale lembrar que cada navegador pode ter diferentes permissões e restrições de segurança ao lidar com dispositivos de mídia. Portanto, sempre teste sua implementação em diferentes ambientes e certifique-se de que a experiência do usuário seja consistente e segura.
Se você deseja expandir essa abordagem, pode considerar aprimoramentos como detecção facial, filtros de imagem em tempo real ou até mesmo integração com WebRTC para chamadas de vídeo. O potencial da API MediaDevices vai muito além da simples captura de fotos, abrindo portas para diversas aplicações interativas e inovadoras.
Escrito por: Fillipe Palhares