Uma visão sobre a utilização da API MediaDevices com Stimulus

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
  1. Abra o Firefox.
  2. Na barra de endereços, digite:
about:config

e pressione Enter.

  1. Clique no botão “Aceitar o risco e continuar” (se aparecer).
  2. Na barra de pesquisa, procure por:
media.devices.insecure.enabled
media.getusermedia.insecure.enabled
  1. Se o valor estiver como false, clique duas vezes para alterá-lo para true.

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 comando navigator.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 categoria custom, que não impõe restrições às dimensões do vídeo. A manipulação de aspectRatio 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 um input do tipo file 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