Ir para o conteúdo

Captura de dispositivo

Quatro APIs do navegador que não custam dependência nenhuma: a câmera lendo códigos (BarcodeScanner, useBarcodeScanner), vídeo gravado (useVideoRecorder), a tela compartilhada (useScreenCapture) e fala virando texto (useSpeechRecognition).

A fatia inteira mede 5,40 KB brotli — e cada uma dessas quatro coisas é uma API que o navegador já tem, não uma biblioteca que o SDK embarcou.

Se você só quer ler um código de barras, pule para Ler códigos

O resto da página é vídeo, tela e fala, e a camada de baixo de cada uma.

A captura de áudio mora em outra página

Microfone, gravação de voz, medidor de nível e WAV estão em Áudio. As duas páginas compartilham o mesmo motor de gravação e a mesma taxonomia de erro — o que muda é o dispositivo.

Ler códigos: comece pelo componente

O SDK já tinha o QRCode, que só codifica. O BarcodeScanner fecha o ciclo:

import { BarcodeScanner } from "tempest-react-sdk";

export function LeitorDeProdutos({ onProduto }: { onProduto: (gtin: string) => void }) {
  return (
    <BarcodeScanner
      formats={["ean_13", "qr_code", "code_128"]}
      onScan={({ rawValue, format }) => {
        if (format === "ean_13") onProduto(rawValue);
      }}
      footer={<small>Aponte para o código de barras da embalagem.</small>}
      unsupported={<CampoDigitarCodigo onSubmit={onProduto} />}
    />
  );
}

O que você ganha sem escrever nada:

Você fez O componente faz
nada Visor de proporção fixa, cantos de mira e um indicador de varredura
nada Loop de detecção que não se sobrepõe — nunca enfileira uma leitura sobre a anterior
nada Suprime a repetição do mesmo código; um valor diferente passa na hora
nada Lanterna, quando a câmera tem uma
nada Erro de câmera classificado, com um botão de tentar de novo
unsupported Onde não há decodificador, mostra o seu caminho alternativo em vez de uma tela preta

Montar o leitor abre a câmera — monte só quando o usuário pedir

O useCameraStream adquire no mount, então renderizar o scanner é disparar o prompt. Um prompt que o usuário não provocou é a forma mais confiável de ganhar um Block permanente — e depois disso o getUserMedia rejeita sem nunca mais perguntar, o que também queima a próxima feature que precisar da câmera. O padrão é um botão que revela o leitor:

const [lendo, setLendo] = useState(false);

return lendo ? (
  <BarcodeScanner onScan={aceitar} />
) : (
  <Button onClick={() => setLendo(true)}>Ler código</Button>
);

BarcodeDetector não existe em metade dos navegadores

Essa é a parte que decide o desenho da sua tela, então está aqui e não numa nota de pé:

Motor Tem BarcodeDetector?
Chromium no Android e ChromeOS sim
Chromium no macOS em geral sim
Chromium no Windows e Linux não
Firefox (qualquer sistema) não
Qualquer navegador no iOS (todos são WebKit por baixo, Chrome incluído) não

O SDK não embarca decodificador nenhum, e isso é uma escolha, não uma lacuna: um leitor de QR é correção de erro Reed–Solomon mais correção de perspectiva mais busca de padrão de localização, e as opções honestas eram um build WASM que todo consumidor deste SDK carregaria, ou nada. Então o que existe é a costura:

import { BarcodeDetector } from "barcode-detector/pure"; // ou seu wrapper de zxing-wasm
import { BarcodeScanner } from "tempest-react-sdk";

<BarcodeScanner
  detector={new BarcodeDetector({ formats: ["qr_code", "ean_13"] })}
  onScan={aceitar}
/>;

Qualquer objeto com um detect(source) que resolva com { rawValue } serve — é o que a interface BarcodeDetectorLike diz, e é o que os testes do SDK exercitam com um decodificador de verdade.

Você não precisa de decodificador nenhum para o caso mais comum

Em telas de operação (conferência de carga, PDV, inventário) o alvo é um celular Android, onde a API existe. unsupported cobre o desktop do escritório com um campo de digitar — que costuma ser o que o operador prefere de teclado, aliás.

Props

Prop Tipo Default O que faz
onScan (result: BarcodeScanResult) => void Cada leitura aceita.
formats BarcodeFormat[] DEFAULT_BARCODE_FORMATS = ["qr_code","ean_13","code_128"] Cada símbolo extra é trabalho por frame. ALL_BARCODE_FORMATS traz o domínio inteiro, para montar um seletor.
paused boolean false Para de procurar sem soltar a câmera.
detector BarcodeDetectorLike Polyfill injetado.
intervalMs number 200 Frequência com que um frame é examinado.
repeatDelayMs number 2500 Janela em que o mesmo valor não dispara de novo.
torch boolean true Oferece a lanterna quando a câmera tem uma.
aspectRatio number 4 / 3 Proporção do visor.
locale "pt-BR" \| "en" "pt-BR" Rótulos.
footer · unsupported ReactNode Instrução e caminho alternativo.
onError (error: unknown) => void Frame que o motor recusou (rotineiro).

BarcodeScanResult = { rawValue, format, boundingBox, cornerPoints }boundingBox é null e cornerPoints é [] quando o motor não reporta geometria.

Os formatos que importam aqui

Três carregam o peso no Brasil: ean_13 é o código de barras de todo produto embalado, qr_code é onde viaja um "copia e cola" do Pix, e code_128 é a etiqueta de uma remessa. O default é exatamente esses três.

Pedir um formato que o motor não tem faz o construtor lançar

NotSupportedError, que parece bug no seu código. A lista de formatos é do decodificador da plataforma, não do navegador, então dois Chromium em dois sistemas respondem diferente. O hook resolve isso pedindo a interseção — e você pode olhar antes:

import { getSupportedBarcodeFormats } from "tempest-react-sdk";

const formatos = await getSupportedBarcodeFormats(); // [] onde não há decodificador

Supressão de repetição não é detalhe

Um símbolo fica em quadro enquanto o usuário mantém a câmera ali. Sem supressão, o mesmo código dispara cinco vezes por segundo — e ligado em "adicionar ao carrinho" isso é um bug que o cliente paga. repeatDelayMs (2,5 s por padrão) é a janela em que o mesmo valor é ignorado; um valor diferente nunca é.

Se a sua tela abre uma confirmação depois da leitura, use paused enquanto ela estiver aberta — para de procurar sem soltar a câmera, então fechar a confirmação não custa outro round-trip de permissão.

Lanterna

import { useTorch } from "tempest-react-sdk";

const torch = useTorch(stream);
{torch.supported && <button onClick={() => void torch.toggle()}>Lanterna</button>}

A lanterna não é um dispositivo — é uma constraint numa track viva

Não há o que controlar antes de existir stream de câmera, e ela desaparece quando o stream é liberado. É por isso que supported só pode ser respondido depois: o mesmo código dá true na câmera traseira de um Android e false na frontal do mesmo aparelho. Onde nem getCapabilities() nem getSettings() mencionam torch, o hook diz false em vez de oferecer um botão que não faz nada.

Montando você mesmo: useBarcodeScanner

import { useBarcodeScanner } from "tempest-react-sdk";

export function LeitorProprio() {
  const scanner = useBarcodeScanner({
    formats: ["ean_13"],
    onScan: ({ rawValue }) => console.log(rawValue),
  });

  if (!scanner.supported) return <p>Este navegador não decodifica códigos.</p>;

  return (
    <>
      <video ref={scanner.videoRef} muted playsInline style={{ width: "100%" }} />
      <p>{scanner.scanning ? "Procurando…" : scanner.status}</p>
      {scanner.error && <p role="alert">{scanner.error.message}</p>}
    </>
  );
}

O loop se re-agenda depois que cada detect() resolve, não num setInterval: decodificar às vezes leva mais que o intervalo, e um intervalo enfileiraria chamadas mais rápido do que o motor as drena até a aba travar.

Gravar vídeo

useVideoRecorder é o useAudioRecorder com uma track de vídeo e videoBitsPerSecond — o relógio que desconta pausa, o stop() que resolve com todos os chunks na mão e a negociação de container são os mesmos, porque são o mesmo motor.

import { useScreenCapture, useVideoRecorder } from "tempest-react-sdk";

export function GravadorDeTela() {
  const screen = useScreenCapture({ preferCurrentTab: true });
  const rec = useVideoRecorder(screen.stream, {
    maxDurationMs: 120_000,
    videoBitsPerSecond: 2_500_000,
    onRecorded: ({ blob, durationMs }) => enviar(blob, durationMs),
  });

  return (
    <>
      <button onClick={screen.start}>Compartilhar tela</button>
      <button disabled={!rec.ready} onClick={rec.start}>Gravar</button>
      <button disabled={rec.status !== "recording"} onClick={() => void rec.stop()}>
        Parar
      </button>
      <span>{(rec.durationMs / 1000).toFixed(1)} s</span>
    </>
  );
}

ready fica false até existir stream, então a UI inteira pode ser renderizada desabilitada enquanto o usuário ainda não escolheu a tela.

O container que sai não é o que você pediu

VideoRecording.mimeType é o que o navegador reportou, não o que foi negociado: entregamos video/webm;codecs=vp9,opus e o Chromium responde video/webm;codecs=vp9 quando não há áudio. Use o valor que voltou para nomear o arquivo e para o Content-Type do upload. A ordem de preferência é VP9 → VP8 → WebM → MP4/H.264 (o último existe para o Safari, que não produz outra coisa).

Vídeo enche a memória uma ordem de magnitude mais rápido que áudio

Um minuto de 1080p a 2,5 Mbps é cerca de 19 MB parados na memória até o stop(). Em qualquer captura que possa passar de alguns minutos, use timesliceMs e mande os pedaços embora:

useVideoRecorder(stream, {
  timesliceMs: 5_000,
  onChunk: (chunk) => void upload(chunk),
});

Os chunks não são tocáveis isoladamente: só o conjunto forma um arquivo válido.

Não há medidor de nível aqui, e é de propósito

Medir nível num compartilhamento de tela significa abrir um AudioContext sobre um stream que quase sempre não tem track de áudio, e navegadores limitam contextos vivos (o Chrome permite ~6). Se você grava uma câmera e quer nível, rode o createLevelMeter no mesmo stream.

O gravador não é dono do stream: parar a gravação deixa o compartilhamento vivo, porque um fluxo de suporte normalmente grava, para, deixa a pessoa olhar e grava de novo.

Compartilhar a tela

import { useScreenCapture } from "tempest-react-sdk";

export function BotaoDeCompartilhar() {
  const screen = useScreenCapture({
    preferCurrentTab: true,
    audio: true,
    onCancelled: () => setDica("Você fechou o seletor — nada foi compartilhado."),
    onEnded: () => salvarEFechar(),
  });

  return (
    <>
      <button onClick={screen.start} disabled={screen.status === "sharing"}>
        Compartilhar tela
      </button>
      {screen.status === "sharing" && (
        <p>
          Compartilhando {screen.surface} · áudio: {screen.hasAudio ? "sim" : "não"}
        </p>
      )}
      {screen.error && <p role="alert">{screen.error.message}</p>}
    </>
  );
}

Três estados decidem se isso parece certo, e dois deles são fáceis de perder:

Estado O que aconteceu O que o hook faz
Seletor fechado o usuário desistiu volta para idle, error fica null, chama onCancelled
Parou pela barra do navegador nada na sua UI foi clicado limpa o stream, volta para idle, chama onEnded
Compartilhando escolheu algo surface diz o quê, hasAudio diz se veio áudio

O evento ended é o único sinal de que o usuário parou de compartilhar

O Chrome mostra uma barra própria com "Parar compartilhamento". Quando ela é usada, nenhuma promise rejeita e nada na sua UI foi clicado — o único aviso é o evento ended da track de vídeo. Sem esse listener, o app fica exibindo "gravando" sobre um stream que já morreu. O hook escuta e limpa; você só precisa do onEnded se tiver algo a salvar.

Seletor fechado não é erro — e não existe exceção própria para ele

Fechar o seletor produz o mesmo NotAllowedError que um bloqueio por política, e algumas versões reportam AbortError. O hook trata os dois como cancelamento, porque um prompt de captura de tela é sempre iniciado pelo usuário — nada consegue abri-lo por trás — então a causa esmagadoramente provável é "mudei de ideia", e um toast vermelho para isso pune quem usou o seletor. A rejeição crua vai para onCancelled, se você precisar distinguir um bloqueio de sistema (permissão de gravação de tela do macOS).

Dicas para o seletor

Opção Efeito
displaySurface "monitor", "window" ou "browser" (uma aba) primeiro na lista
preferCurrentTab põe esta aba no topo — o certo para "grave o que você está vendo"
selfBrowserSurface: "exclude" evita a captura em espelho infinito
surfaceSwitching: "include" deixa trocar de superfície no meio, sem novo prompt
systemAudio áudio do sistema quando a tela inteira é compartilhada
audio: true pede o áudio da aba

Tudo aí é dica, nunca garantia

O usuário pode escolher outra coisa, o Firefox ignora as dicas e o áudio de exibição só existe para aba no Chromium (o Safari não tem nenhum). Por isso o hook devolve surface e hasAudio: leia o que aconteceu, não o que você pediu.

Fala → texto

import { useSpeechRecognition } from "tempest-react-sdk";

export function CampoDitado({ onTexto }: { onTexto: (texto: string) => void }) {
  const speech = useSpeechRecognition({
    lang: "pt-BR",
    continuous: true,
    onFinal: (texto) => onTexto(texto),
  });

  if (!speech.supported) return null;

  return (
    <>
      <button onClick={speech.listening ? speech.stop : speech.start}>
        {speech.listening ? "Parar" : "Ditar"}
      </button>
      <p>
        {speech.transcript}
        <em>{speech.interim}</em>
      </p>
      {speech.error && <p role="alert">{speech.error.message}</p>}
    </>
  );
}

O reconhecimento não é local: o Chromium manda o áudio para um servidor do Google

Nada na API diz isso, não existe configuração que mude, e acontece a cada start(). Tudo o que o usuário falar enquanto a sessão está aberta sai do aparelho. Não coloque isso num campo de anotação clínica, de credencial ou de dado financeiro de cliente sem avisar antes — e se o dado não pode sair da sua infraestrutura, esta API é a ferramenta errada e um modelo self-hosted é a certa. Colocar o aviso na interface, ao lado do botão, é o mínimo.

Interim e final

transcript acumula as frases que o motor fechou; interim é o palpite que ele ainda está revisando e é substituído inteiro a cada evento. Renderizar transcript + interim dá o efeito de legenda ao vivo; renderizar só transcript dá o texto confirmado.

Opção Default O que faz
lang "pt-BR" Tag BCP-47.
continuous false Continua depois da primeira frase fechar.
interimResults true Publica o palpite em andamento.
maxAlternatives 1 Quantas leituras por frase pedir.
onResult · onFinal · onError · onEnd Cada atualização, só o texto fechado, falha classificada, fim da sessão.
factory Construa o reconhecedor você mesmo — outro motor, ou um stub em teste.

Erros classificados

kind Causa O que a UI deve fazer
unsupported Firefox e todo motor não-Chromium esconda o botão
not-allowed microfone negado (inclui service-not-allowed) instrução para as configurações do site
no-speech ninguém falou rotineiro — não é falha para reportar
audio-capture nenhum microfone no aparelho diga isso
network o serviço de reconhecimento não respondeu ofereça digitar
aborted cancelado rotineiro
language-not-supported o serviço não fala esse idioma caia para pt-BR ou en-US

Não existe auto-restart, de propósito

Mesmo com continuous: true o motor encerra a sessão sozinho depois de um trecho de silêncio — é timeout do servidor, não bug. Um loop de reinício é como um app acaba segurando o microfone para sempre e, no Chromium, transmitindo áudio para terceiros para sempre. Mostre que parou de ouvir e deixe a pessoa apertar de novo.

Ditando no AIChat

O AIChat não conhece o reconhecimento de fala, e não vai conhecer: isso faria todo consumidor do componente pagar por uma API que manda áudio para terceiros. O que existe é o composerRef — o botão que você põe dentro do composer escreve no campo:

import { useRef } from "react";
import {
  AIChat,
  Button,
  useSpeechRecognition,
  type AIChatComposerHandle,
} from "tempest-react-sdk";

export function ChatComDitado({ messages, onSend }: ChatProps) {
  const composer = useRef<AIChatComposerHandle>(null);
  const speech = useSpeechRecognition({
    continuous: true,
    onFinal: (texto) =>
      composer.current?.setValue(`${composer.current.getValue()} ${texto}`.trim()),
  });

  return (
    <AIChat
      messages={messages}
      onSend={onSend}
      composerRef={composer}
      composerActions={
        <Button
          size="sm"
          variant={speech.listening ? "primary" : "soft"}
          disabled={!speech.supported}
          onClick={speech.listening ? speech.stop : speech.start}
        >
          {speech.listening ? "Ouvindo…" : "Ditar"}
        </Button>
      }
      composerFooter={<small>O áudio ditado sai do dispositivo.</small>}
    />
  );
}

getValue() é o que torna qualquer coisa aditiva possível

O composer é não-controlado de propósito (uma tecla por render do transcrito inteiro seria caro), então sem ler o rascunho a única forma de acrescentar — uma frase ditada, um comando de barra, uma citação colada — seria espelhar o valor inteiro no estado do app e torcer para os dois não divergirem.

Recap

  • BarcodeScanner — leitor completo: visor, mira, lanterna, supressão de repetição, erro classificado. Montar abre a câmera, então monte quando o usuário pedir.
  • BarcodeDetector é Chromium-only. O SDK não embarca decodificador; unsupported cobre quem não tem, e detector aceita um polyfill.
  • useBarcodeScanner — a metade de decodificação, sobre useCameraStream; loop que não se sobrepõe, formats resolvidos pela interseção com o motor.
  • useTorch — a lanterna é constraint de track viva, não dispositivo; supported só vale depois do stream.
  • useVideoRecorder — mesmo motor do áudio: relógio que desconta pausa, stop() com todos os chunks, container negociado. Use timesliceMs para captura longa.
  • useScreenCapture — seletor fechado é cancelamento (não erro); ended é o único sinal de que o usuário parou; surface/hasAudio dizem o que aconteceu de fato.
  • useSpeechRecognition — interim vs final, erros classificados, sem auto-restart. O áudio sai do dispositivo no Chromium.
  • AIChat + composerRef — ditado sem o componente conhecer a API de fala.

Veja também