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;unsupportedcobre quem não tem, edetectoraceita um polyfill.useBarcodeScanner— a metade de decodificação, sobreuseCameraStream; loop que não se sobrepõe,formatsresolvidos pela interseção com o motor.useTorch— a lanterna é constraint de track viva, não dispositivo;supportedsó vale depois do stream.useVideoRecorder— mesmo motor do áudio: relógio que desconta pausa,stop()com todos os chunks, container negociado. UsetimesliceMspara captura longa.useScreenCapture— seletor fechado é cancelamento (não erro);endedé o único sinal de que o usuário parou;surface/hasAudiodizem 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
- Áudio — microfone, gravação de voz, WAV, saída de som
- Componentes utilitários — o
QRCode, que codifica - Vision (ONNX) — o
useCameraStreamque este módulo reaproveita