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.
Seção device-capture da gallery — rode localmente para interagir.
Seção video-player da gallery — rode localmente para interagir.
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.
Tocar o que foi gravado: VideoPlayer
O par do AudioPlayer para vídeo. Aceita o Blob direto, sem upload no meio:
import { useScreenCapture, useVideoRecorder, VideoPlayer } from "tempest-react-sdk";
export function GravarERever() {
const screen = useScreenCapture();
const rec = useVideoRecorder(screen.stream);
return (
<>
<button onClick={screen.start}>Compartilhar tela</button>
<button disabled={!rec.ready} onClick={rec.start}>Gravar</button>
<button onClick={() => void rec.stop()}>Parar</button>
{rec.recording && (
<VideoPlayer src={rec.recording.blob} durationMs={rec.recording.durationMs} />
)}
</>
);
}
Transporte completo: play/pause, barra de posição, tempos, mudo + volume,
velocidade e tela cheia. playsInline já vem ligado.
| Prop | O que faz |
|---|---|
src |
URL ou Blob/File. O object URL é revogado na troca e no unmount |
durationMs |
Duração que você já conhece — passe sempre que tiver |
rate / rates / onRateChange |
Velocidade e os presets. rates={[]} esconde o controle |
shiftPitch |
Deixa o pitch subir com a velocidade. Padrão false |
poster |
Capa. Combina com o captureFrame |
tracks |
Legendas, viram <track> |
aspectRatio |
Forma do quadro antes do vídeo reportar a dele. Padrão 16 / 9 |
fullscreen |
Botão de tela cheia onde o navegador suporta. Padrão true |
muted / autoPlay / loop |
Como no elemento |
actions |
Slot no fim da barra — baixar, apagar |
Os presets default saem exportados como DEFAULT_PLAYBACK_RATES ([0.5, 1, 1.5, 2]), para uma tela de ajustes mostrar a lista em vez de repeti-la.
A velocidade reseta ao trocar de src se você escrever só playbackRate
O algoritmo de load do elemento copia defaultPlaybackRate em cima de
playbackRate. Um player que guarda "2×" em estado e escreve só o segundo
perde a escolha do usuário em todo clipe novo — sem erro, sem log. O
VideoPlayer escreve os dois.
Se você montar o seu no lugar deste, é a linha que não pode faltar. E não espere um teste em jsdom pegar: lá os dois valores são independentes e não existe algoritmo de load, então a regressão fica invisível.
Fora de uma faixa útil o navegador pode desligar o áudio
A spec permite ao navegador silenciar quando playbackRate sai de uma faixa
razoável, e o limite varia. Os presets default (0,5× a 2×) ficam dentro
de todos os que medimos; se você oferecer 4× ou 0,1×, teste com áudio no
navegador alvo antes de prometer ao usuário.
Os controles ficam embaixo do quadro, não sobre ele
Controle desenhado sobre vídeo tem de garantir contraste contra pixel
arbitrário, e nenhum token --tempest-* promete isso: todo token de texto
daqui é resolvido contra uma superfície conhecida, e os dois defeitos de
contraste que este SDK entregou foram token de texto usado sobre superfície
onde ele nunca foi checado. Uma barra com superfície própria herda o tema e
é legível por construção.
Se você quer o look de cinema, posicione a sua chrome sobre o quadro — o que ships é o que está certo nos dois temas.
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

