> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pictor.cloud/llms.txt
> Use this file to discover all available pages before exploring further.

# Mostrar a câmera ao vivo

> Duas chamadas e o player. Código que roda.

Duas chamadas: descobrir a câmera e pedir o endereço do vídeo. O resto é o
player.

<Steps>
  <Step title="Liste as câmeras da conta">
    ```bash theme={null}
    curl -s -H "Authorization: Bearer $PICTOR_KEY" \
      "$PICTOR_API/api/devices?limit=100"
    ```

    Guarde o `device_id` de cada uma: é o identificador que todas as outras
    rotas aceitam.
  </Step>

  <Step title="Peça o endereço do vídeo, no momento de exibir">
    ```bash theme={null}
    curl -s -H "Authorization: Bearer $PICTOR_KEY" \
      "$PICTOR_API/api/devices/CAM-001/media-url"
    ```

    ```json theme={null}
    {
      "device_id": "CAM-001",
      "device_uuid": "0b8f…",
      "whep_url": "https://app.pictor.cloud/media/s1/api/webrtc?src=CAM-001&exp=…&t=…",
      "hls_url":  "https://app.pictor.cloud/media/s1/api/stream.m3u8?src=CAM-001&exp=…&t=…",
      "expires_in": 600
    }
    ```
  </Step>

  <Step title="Toque">
    `hls_url` é o caminho curto: playlist comum, qualquer player abre. Use
    `whep_url` quando o atraso de alguns segundos não servir. O diagrama abaixo
    diz qual dos dois é o seu caso.
  </Step>
</Steps>

<Tip>
  As duas URLs vêm **absolutas e completas**. Use-as como vieram: não junte com o
  endereço da API e não reescreva o host. O vídeo é servido por um endereço
  diferente do da API, e a assinatura é calculada sobre a URL inteira.
</Tip>

A escolha entre as duas é uma pergunta só, e a maioria das centrais para no
`hls_url`:

```mermaid theme={null}
flowchart LR
    D{"O seu caso exige menos de<br/>um segundo de atraso?"}
    D -->|"Não, o caso mais comum"| H["hls_url<br/>playlist comum: qualquer player abre,<br/>atravessa qualquer rede,<br/>alguns segundos de atraso"]
    D -->|"Sim, o operador reage ao vivo"| W["whep_url<br/>não é um src: você faz POST da oferta SDP<br/>e recebe a resposta no corpo,<br/>abaixo de um segundo"]
    W -.->|"não rendeu nesta câmera,<br/>e repetir não resolve"| H
```

## O player, inteiro

WHEP é WebRTC com uma única troca HTTP: `POST` da sua oferta SDP no `whep_url`,
resposta SDP no corpo, vídeo na tela. Não precisa de biblioteca.

É o mesmo caminho que o console do Pictor usa em produção, com as duas decisões
não óbvias comentadas no código.

```html theme={null}
<video id="cam" autoplay muted playsinline controls></video>

<script>
// O console do Pictor usa 5 s. Passado esse tempo sem PRIMEIRO QUADRO, ele
// desiste do WebRTC e vai para o HLS.
const ORCAMENTO_MS = 5000;

async function abrir(deviceId) {
  // O endereço vem do SEU backend, nunca do navegador: a chave de API não pode
  // ir para o cliente. Veja "Onde a chave mora", abaixo.
  const r = await fetch(`/minha-api/cameras/${deviceId}/url`);
  if (!r.ok) return mostrarFalha(await r.json());
  const { whep_url, hls_url } = await r.json();

  try {
    await tocarWhep(whep_url);
  } catch (motivo) {
    console.warn('WebRTC não rendeu:', motivo, '— caindo para HLS');
    await tocarHls(hls_url);
  }
}

function tocarWhep(url) {
  return new Promise((ok, falhou) => {
    const video = document.getElementById('cam');
    const pc = new RTCPeerConnection();
    let relogio;

    const desistir = (motivo) => {
      clearTimeout(relogio);
      pc.close();
      falhou(motivo);
    };

    // PRIMEIRO QUADRO é a única definição honesta de "funcionou".
    // `connectionState: 'connected'` NÃO garante imagem: a conexão sobe, o
    // vídeo não decodifica, e o operador fica olhando um retângulo preto com o
    // status verde. Quem resolve o timer é o evento `playing`.
    video.addEventListener('playing', () => { clearTimeout(relogio); ok(pc); }, { once: true });

    pc.addTransceiver('video', { direction: 'recvonly' });
    pc.addTransceiver('audio', { direction: 'recvonly' });
    pc.ontrack = (ev) => {
      if (!video.srcObject) {
        video.srcObject = ev.streams[0];
        video.play().catch(() => {});
      }
    };
    pc.onconnectionstatechange = () => {
      if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
        desistir('peer_failed');
      }
    };

    (async () => {
      // Manda a oferta SEM esperar o ICE terminar. Os candidatos continuam
      // chegando depois da resposta; esperar aqui só adia o primeiro quadro.
      const oferta = await pc.createOffer();
      await pc.setLocalDescription(oferta);

      const resp = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/sdp' },
        body: pc.localDescription.sdp ?? '',
      });
      if (!resp.ok) return desistir(`whep_http_${resp.status}`);

      await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() });
      relogio = setTimeout(() => desistir('sem_primeiro_quadro'), ORCAMENTO_MS);
    })().catch(() => desistir('negociacao'));
  });
}

async function tocarHls(url) {
  const v = document.getElementById('cam');
  // Safari e iOS tocam HLS nativo; o resto precisa de hls.js.
  if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; return; }
  const { default: Hls } = await import('https://cdn.jsdelivr.net/npm/hls.js@1/+esm');
  const hls = new Hls({ lowLatencyMode: true });
  hls.loadSource(url);
  hls.attachMedia(v);
}
</script>
```

## O orçamento de 5 segundos

<Warning>
  Numa portaria com dezenas de câmeras de fabricantes diferentes, uma parte delas
  **não** vai render por WebRTC. Sem o HLS pronto, o sintoma é o operador esperando
  cinco segundos por tile.
</Warning>

O motivo é o perfil de vídeo. O WebRTC negocia o formato antes de o vídeo
começar, e o que o decodificador do aparelho aceita não é o mesmo em toda câmera.
Quando a negociação fecha num formato que aquele aparelho não decodifica, a
conexão sobe e a imagem não vem: é o caso em que `connectionState` mente.

Três consequências para o seu código:

1. **Meça o primeiro quadro**, não a conexão.
2. **Tenha o `hls_url` em mão antes de tentar o WHEP.** Ele vem na mesma
   resposta.
3. **Não faça retry de WHEP.** Se não rendeu, não vai render nesta câmera neste
   aparelho: vá para o HLS e fique nele.

## Onde a chave mora

<Warning>
  A chave de API **nunca** vai para o navegador. Ela é uma credencial de conta:
  quem a tem lê o inventário inteiro, o diagnóstico e o vídeo.
</Warning>

O desenho é sempre este:

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant N as Navegador
    participant B as Seu backend
    participant P as Pictor Cloud
    participant M as Servidor de mídia
    N->>B: abrir a câmera CAM-001
    B->>P: GET /api/devices/CAM-001/media-url, com a chave de API
    P-->>B: URL de vídeo já assinada, com prazo
    B-->>N: só a URL assinada
    Note over N,B: A chave não cruza esta linha.<br/>A URL assinada cruza.
    N->>M: abre o vídeo direto, na URL assinada
```

O endereço que o seu backend repassa ao navegador **já é seguro de trafegar**:
ele carrega a própria assinatura e vence sozinho. É por isso que ele existe —
para você entregar vídeo sem entregar credencial.

## Quando não abre

A rota não devolve `200` com um endereço mudo. Quando não há como preparar a
transmissão, ela responde `502` com a causa nomeada:

```json theme={null}
{
  "detail": "Não foi possível determinar de onde puxar a imagem desta câmera.",
  "code": "stream_unresolvable",
  "diagnostics_url": "/api/devices/CAM-001/diagnostics"
}
```

| `code`                   | O que aconteceu                                 | O que fazer                                               |
| ------------------------ | ----------------------------------------------- | --------------------------------------------------------- |
| `stream_unresolvable`    | não sabemos de onde puxar a imagem desta câmera | o cadastro está incompleto — siga o `diagnostics_url`     |
| `stream_register_failed` | sabemos de onde puxar, e a preparação falhou    | pode ser transitório; o diagnóstico diz em qual elo parou |

```js theme={null}
function mostrarFalha(erro) {
  // `detail` é escrito para o operador ler. Mostre-o; não traduza.
  const acao = erro.diagnostics_url ? 'Ver diagnóstico' : 'Tentar novamente';
  render(erro.detail, acao);
}
```

<Tip>
  Não faça retry em laço aqui. Cada emissão acorda uma sessão de vídeo, e a rota
  tem teto próprio por causa disso (ver [Limites](/limites)). Falhou, vá para o
  diagnóstico.
</Tip>

## O prazo

`expires_in` é o prazo da **assinatura**, em segundos, não o prazo do vídeo.
Passado ele, o endereço deixa de abrir e você pede outro.

Peça a URL quando for exibir. Pedir em lote para as 40 câmeras do condomínio e
guardar para o clique entrega 39 endereços vencidos e queima o teto de mídia.
