Imported from saulotarsobc/sc-ptz-control (
AGENTS.md). Install upstream withnpx skills add saulotarsobc/sc-ptz-control. Copyright stays with the author.
AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
# Desenvolvimento
pnpm build:bridge # compila o sidecar C# (obrigatório antes do primeiro `pnpm dev`)
pnpm dev # Vite + Electron (HMR); o main sobe o sidecar automaticamente
pnpm build # tsc + vite build
pnpm lint # ESLint
# Câmera virtual (uma vez, opcional em dev)
pnpm build:vcam # CMake + MSVC -> native/ScPtzVCam/build/Release/ScPtzVCam.dll
pnpm install:vcam # registra o COM em HKLM — exige terminal ADMINISTRADOR
# Distribuição
pnpm publish:bridge # dotnet publish self-contained -> native/PtzBridge/publish/
pnpm dist # dist:prepare -> electron-builder -> out/
# (dist:prepare = generate-electron-builder -> build:vcam -> publish:bridge -> build)
# Release
pnpm release # tag + changelog + Release no GitHub + build com --publish always
pnpm release:dry # simula tudo sem alterar nada
pnpm release:notes # imprime só o changelog
Não há testes automatizados contra o equipamento. pnpm lint, pnpm build e
pnpm build:bridge são as verificações locais mínimas. O projeto permanece na linha
TypeScript 5.9 porque o typescript-eslint 8 ainda não suporta TypeScript 7.
Arquitetura
SC PTZ Control controla câmeras PTZ em NVR/DVR Intelbras pelo NetSDK nativo (protocolo privado Dahua na porta 37777), não pela API HTTP CGI. O SDK dá o que o CGI não dá: PTZ completo com velocidade (pan/tilt/zoom/foco/íris), H.264 decodificado localmente e vídeo ao vivo.
Electron main (backend/main.ts)
└─ spawn: native/PtzBridge/…/PtzBridge.exe --port 0 --token <hex>
│ stdout linha 1: {"ready":true,"port":51234}
│
└─ sidecar C# escutando em 127.0.0.1 (token obrigatório)
├─ /ws/control JSON — login, PTZ, presets, config, câmera virtual, eventos
├─ /ws/video?channel=N binário — frames NV12
├─ /api/thumb/{ch}/{n} GET/PUT/DELETE — miniaturas JPEG
└─ câmera virtual — NV12 720p em memória compartilhada (fora da rede)
└─ preload: window.ptz.getBridge() → {port, token}
Renderer (React 19 + Mantine 9) abre as duas WebSockets direto em 127.0.0.1.
O sidecar é o único dono do estado: sessão do SDK, configuração e miniaturas. O main do
Electron é só um lançador. O renderer não fala com o NVR — por isso webSecurity fica ligado
(diferente das versões anteriores, que precisavam desligá-lo para o Digest funcionar no browser).
Práticas Electron da v6
As referências normativas são os guias oficiais de performance e segurança. Preserve estas invariantes:
- A janela aparece antes de o sidecar terminar de subir; o renderer representa
startinge recebe a conclusão poronBridgeState— uma leitura única do estado recriaria a corrida de startup. electron-updateré importado dinamicamente apósdid-finish-load, fora do caminho crítico.- O main não usa IPC síncrono nem I/O síncrono em operações de runtime.
contextIsolation: true,nodeIntegration: false,sandbox: trueewebSecurityligado.- O preload expõe funções específicas, nunca
ipcRendererou o evento IPC bruto. - Todo
ipcMain.handlevalidasender,senderFramee o frame principal. - CSP restringe scripts à própria aplicação e rede do renderer somente ao sidecar em loopback.
- Permissões Chromium são negadas por padrão; navegações e novas janelas são bloqueadas.
- URLs externas passam por allowlist estrita antes de
shell.openExternal. - A rota principal é eager; telas secundárias usam code-splitting com skeleton sem layout shift.
Antes de otimizar novamente, meça startup, CPU, memória e bundle. Não troque segurança por desempenho e não mova frames de vídeo pelo IPC do Electron: o WebSocket local evita esse salto.
O sidecar C# (native/PtzBridge/)
.NET 8, Windows x64, sem nenhuma dependência NuGet. A v6 exige o NetSDK e não possui fallback RTSP/FFmpeg: um build sem o SDK deve falhar cedo para não trocar o pipeline de baixa latência.
| Arquivo | Papel |
|---|---|
Nvr/INvrBackend.cs |
Contrato interno do NetSDK e do pipeline de vídeo |
NetSdk/SdkHost.cs |
CLIENT_Init/Cleanup com contagem de referência + callbacks globais |
NetSdk/NvrClient.cs |
Backend NETClient: login, real-play, PTZ e presets |
NetSdk/PlaySdkNative.cs |
P/Invoke da dhplay.dll (decodificador Windows) |
Sdk/YuvScaler.cs |
I420 → NV12 reduzido, com mapas nearest-neighbor pré-computados |
Sdk/AppConfig.cs |
%APPDATA%/sc-ptz-control/config.json + caminhos de miniatura |
Platform/AppPaths.cs |
Caminhos persistentes do Windows |
Streaming/VideoHub.cs |
Um stream por canal, ligado/desligado por contagem de assinantes |
Server/NvrService.cs |
Orquestra tudo; serializa as chamadas ao SDK sob um lock |
Server/PtzWatchdog.cs |
Parada automática do PTZ (ver abaixo) |
Server/Http.cs |
HTTP/1.1 mínimo + handshake de WebSocket sobre TcpListener |
Server/BridgeServer.cs |
Roteamento, token, CORS, miniaturas |
VirtualCamera/VirtualCameraService.cs |
Orquestra a câmera Media Foundation do Windows 11 |
VirtualCamera/NoSignalFrame.cs |
Quadro preto com "Sem sinal!", sem dependência gráfica nativa |
O .csproj pode compilar o wrapper oficial NetSDKCS direto da pasta de demos do SDK e copiar
as DLLs nativas para junto do .exe no Windows. Ele resolve NetSdkRoot como
..\..\..\helpers\NetSDK 3.050\…, caminho usado no monorepo. Fora dele, informe o SDK
explicitamente:
dotnet build native/PtzBridge -p:NetSdkRoot="C:\caminho\para\...190304"
Sem helpers/, o bridge não compila. Essa exigência impede que uma distribuição Windows seja
gerada sem as DLLs nativas e caia silenciosamente num caminho mais lento.
Detalhes que não podem ser perdidos
Pipeline de vídeo (ChannelStream) — real-play com hWnd = IntPtr.Zero, que faz o SDK
entregar o stream cru em vez de desenhar numa janela:
PLAY_GetFreePort → SetStreamOpenMode(REALTIME) → OpenStream(4MB)
→ SetDecCBStream(VÍDEO) → SetDecCallBackEx → PLAY_Play(hWnd=0)
StartRealPlay(ch, IntPtr.Zero) → SetRealDataCallBack(RAW_DATA)
→ OnRawData: PLAY_InputData → OnDecodedFrame: I420 → YuvScaler → NV12 → WebSocket
Duas invariantes: os delegates de callback ficam em campos (o SDK guarda o ponteiro nativo e o GC coletaria um lambda local) e nenhuma exceção pode escapar dos callbacks para o código nativo.
Watchdog de PTZ (PtzWatchdog) — comando contínuo tem prazo de 1200 ms por (canal, eixo). Sem
re-arme, o backend emite a parada sozinho; o HoldButton do frontend re-arma a cada 500 ms. Perder
o cliente de controle solta tudo na hora. Sem isso, um renderer travado deixaria o motor girando
indefinidamente — que é o comportamento do play-nvr e um risco real com o comando vindo pela rede.
Reconexão — o SDK reconecta sozinho (CLIENT_SetAutoReconnect), mas depois disso o handle de
login continua válido enquanto os de real-play estão mortos. Por isso VideoHub.ResumeAll()
reemite StartRealPlay em vez de só religar o callback.
Canais são 1-based no protocolo e na UI; a conversão para a base 0 do SDK acontece só na borda
do NvrService.
Contrato binário do vídeo — cabeçalho PNV2 de 20 bytes (VideoFrameHeader no C#, as
constantes no topo de useVideoStream.ts) seguido do NV12. Além de dimensões e sequência, leva
timestamp da fonte e FPS do decoder. Mexeu num lado, mexa no outro.
Callback de decode — a thread nativa não escala, não envia WebSocket e não escreve no MMF.
Ela copia o I420 para LatestI420FramePump, cuja fila de tamanho um preserva sempre o frame mais
recente. Preview e câmera virtual têm workers independentes; atraso em um destino não pode segurar
o decoder nem o outro destino.
Câmera virtual (native/ScPtzVCam/ + VirtualCamera/)
Publica o canal ativo como SC PTZ Virtual Cam (OBS, Meet, Teams) no Windows 11, usando
Media Foundation e o buffer compartilhado abaixo.
ChannelStream.I420Ready (fonte cheia) → LatestI420FramePump → YuvScaler(1280)
→ NV12 1280x720
→ SharedFrameWriter → %ProgramData%\ScPtzControl\vcam-frames.bin (triplo buffer)
→ ScPtzVCam.dll carregada pelo Frame Server → IMFMediaStream::RequestSample
- Um real-play só. A câmera virtual entra como assinante do
VideoHub, então ela reaproveita o decode do preview. O que ela NÃO reaproveita é a escala: assina oI420Ready(fonte crua) para ir direto a 720p, porque passar pelo preview reduzido (maxVideoWidth, 960 por padrão) reamostraria duas vezes e borraria a imagem. - Ser assinante também é o que mantém o vídeo no ar com os controles escondidos.
- Sem imagem ≠ sem câmera. Ligar com o NVR fora do ar não falha: o dispositivo sobe e um
timer de 200 ms publica o quadro
NoSignalFrame(preto + "Sem sinal!"). A assinatura fica pendente eEnsureSubscribed()a resolve quando a sessão sobe. O quadro liso da media source nativa só aparece se o aplicativo inteiro estiver fechado. - O CLSID é identidade.
{FF324BA5-…}aparece emGuids.h,scripts/install-vcam.ps1ebuild/installer.nsh— os três precisam concordar, e ele é diferente do CLSID do play-nvr de propósito: as duas câmeras convivem na mesma máquina. - Contrato do buffer em
SharedFrame.heSharedFrameProtocol.cs(magicSPV1, 128 bytes de cabeçalho, 3 slots). Mexeu num, mexa no outro. - Registro em HKLM é obrigatório e é o único passo que exige elevação — o instalador NSIS
faz (
build/installer.nsh), em dev é oscripts/install-vcam.ps1. Sem ele,MFCreateVirtualCameradevolveREGDB_E_CLASSNOTREGe o botão mostra o que fazer. - Câmera de sessão: existe enquanto o sidecar viver. Fechar o app remove o dispositivo.
Renderer (src/)
Quatro rotas em HashRouter: / (presets + controles), /hall-map, /settings, /help.
src/context/BridgeProvider.tsx— estado compartilhado entre as telas: sidecar, enlace, configuração, status da sessão, canal, velocidade e câmera virtual. Fica fora doRouterpara a sessão não reiniciar a cada navegação. O preload empurra mudanças do sidecar poronBridgeState; o estado da câmera virtual também não é consultado em laço: o backend empurra o eventovcamao ligar, desligar e ao entrar/sair do "sem sinal".src/services/bridge/client.ts— WebSocket de controle: correlação porid, fila enquanto reconecta, backoff.src/services/bridge/usePresets.ts— lista de presets com a URL da miniatura resolvida.src/components/LiveView/useVideoStream.ts— mantém apenas o frame mais recente e desenha norequestAnimationFramecomVideoFramedo WebCodecs (NV12 direto, conversão YUV→RGB na GPU); há um caminho manual emImageDatacomo reserva. Oframe.close()é obrigatório — sem ele cada frame vaza memória de GPU.src/components/PtzPad/HoldButton.tsx— captura de ponteiro (nãomouseleave) para o "soltar" chegar mesmo com o cursor fora do botão, e o re-arme do watchdog.
Miniaturas são capturadas do frame que já está na tela (canvas.toBlob) e enviadas por
PUT /api/thumb. É bem mais rápido que pedir ao equipamento — o SnapPictureEx do SDK é
assíncrono, limitado a D1 e aceita uma requisição por vez.
Estado local — o mapa de assentos e a preferência de exibir os controles (localStorage,
src/services/storage.ts). Configuração e miniaturas são do sidecar. As versões anteriores
guardavam credenciais em texto puro e até 100 JPEGs em base64 no localStorage, estourando a cota.
Presets são só números. Não há nome — foi uma decisão explícita do usuário, então não reintroduza o campo achando que é uma melhoria.
Release e atualização automática
scripts/release.ps1 (com changelog.ps1 e common.ps1) publica da máquina local: tag,
changelog por tipo de commit, Release no GitHub e build com upload dos assets. Todas as etapas
são idempotentes — reexecutar com a mesma versão não duplica nada.
- O upload tem que passar pelo
electron-builder --publish always(o scriptrelease:publish), não porgh release upload. É o electron-builder que gera e sobe olatest.ymle o.blockmap, e sem olatest.ymlna release latest oelectron-updaternão enxerga versão nenhuma. EP_GH_IGNORE_TIME=trueé definida em volta desse passo. Sem ela o electron-builder se recusa a subir assets numa release publicada há mais de 2 horas e encerra com sucesso, apenas logando um aviso — a republicação falharia em silêncio.- owner/repo saem do
repositorydo package.json.generate-electron-builder.tsmonta o blocopublisha partir dele e ocommon.ps1lê o mesmo campo, então o script e o publisher não têm como divergir. Oelectron-builder.jsoné gerado e git-ignorado; não serve de fonte. - A verificação final baixa o
latest.ymlsem autenticação, que é o que o app do usuário faz. Release em rascunho ou repositório privado dá 404 ali e o update não chega a ninguém. electron-updaterfica fora do bundle do main (externalize()emvite.config.ts): ele carrega o updater da plataforma porrequiredinâmico. É um plugin comresolveIdem vez debuild.rollupOptions.externalporque o vite-plugin-electron lêrolldownOptionsno Vite 8 erollupOptionsno Vite 7, descartando em silêncio a chave que não corresponde à versão. Como é dependência de produção, o electron-builder o copia mesmo com ofilesrestrito adist/**.UpdateStatusestá duplicado embackend/updater.tsesrc/types/index.ts— mesmo contrato dos dois lados do preload, como acontece comBridgeState.- A atualização exige UAC. O instalador é
perMachine(registra a câmera virtual em HKLM), eautoInstallOnAppQuitestá ligado para a DLL da câmera não ficar defasada em relação ao app.
.github/workflows/deploy.yml é um caminho antigo, disparado por push na branch deploy (que não
existe). Ele cria tags vX.Y.Z-<run_number> e não sobe latest.yml — se voltar a rodar, quebra a
cadeia de auto-update ao virar a release latest.
Convenções
- Português (pt-BR) em comentários, documentação e textos de interface.
- Comentário explica o porquê (uma invariante, um contorno), não narra o código.
"type": "module"— todo arquivo Node usa ESM.