Steven
Steven8 min de leitura

De OCR a pixels sem perder o fallback

A GeekBye parou de ler suas capturas com OCR e passou a enviar a imagem real para modelos de visão — que é a parte fácil. A parte instrutiva é a otimização que pulou o OCR por completo para os usuários com visão, quebrou sem alarde o fallback para modelos sem visão, e teve que ser desfeita até uma versão onde a rede de segurança roda em paralelo e não custa nada.

Engenharia
AI
Desktop
Lançamentos da GeekBye
De OCR a pixels sem perder o fallback

Há uma bifurcação de verdade no caminho para qualquer app que te deixa capturar algo e perguntar a uma IA sobre aquilo. Você pode rodar OCR — extrair o texto no próprio dispositivo, enviar uma string minúscula, e jogar a imagem fora. Ou você pode enviar os pixels — mandar a imagem real e deixar um modelo capaz de ver lê-la. O OCR é barato e pequeno mas cego a tudo o que não é texto: o layout, o diagrama, a indentação do código, o rabisco vermelho embaixo do erro. Os pixels preservam tudo isso mas custam mais bytes e exigem um modelo que consiga ver. A GeekBye começou do lado do OCR dessa bifurcação e, ao longo da v1.8.6 e da v1.8.7, caminhou até o lado dos pixels. Essa mudança é a parte fácil desta história. A parte interessante é a otimização que veio junto, quebrou o fallback, e teve que ser desfeita.

O antes: ler o texto, largar a imagem

Até este arco, uma captura virava uma resposta assim: você aperta o atalho de captura, o cliente roda OCR — o framework Vision da Apple através de um binário Swift VisionOCR no macOS, um script de PowerShell no Windows, ambos atrás de uma única abstração IOcrEngine — e o texto extraído é envolto numa string e enviado ao backend como screenshotText. A imagem em si nunca saía da máquina. Isso é eficiente, e para um paredão de texto puro está tudo bem. Mas uma captura raramente é só texto. É uma vaga de emprego com um layout de duas colunas, um problema de programação com um diagrama, um erro com uma stack trace cuja indentação é a pista. O OCR achata tudo isso numa transcrição linha a linha e o modelo responde a uma pergunta mais rasa do que a que você fez.

A mudança: enviar os pixels (v1.8.6)

A v1.8.6 adicionou o outro caminho. Um novo imageOptimizer.ts, construído sobre sharp e portanto multiplataforma, faz exatamente três coisas com uma captura em bruto:

sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })

Redimensionar para no máximo 1920px de largura (nunca ampliar uma captura menor), converter PNG para JPEG, quality 80. A saída é base64 em bruto com o media type carregado num campo separado, e ela viaja ao backend em dois novos campos da requisição ao lado do antigo: screenshotImage e screenshotImageMediaType, independentes de screenshotText. O backend agora pode receber uma imagem, texto, ou os dois.

Esse passo de otimização não é cosmético. Um PNG de captura em bruto tem 3–8 MB; o JPEG redimensionado fica em torno de 200–500 KB. Isso é uma ordem de magnitude a menos no upload, e também corta a conta do modelo — os modelos de visão dividem uma imagem em ladrilhos conforme a resolução e cobram por ladrilho, então limitar a largura a 1920 e recomprimir reduz diretamente tanto os bytes no fio quanto os tokens de imagem que você paga, sem perda apreciável de legibilidade para o texto na tela.

Como o app sabe se o modelo sequer consegue ver? O backend anuncia. A resposta de /api/config ganhou um bloco ai.vision indexado por plano, e o cliente faz cache de um único booleano — aiSupportsVision — a cada fetch de config. Quando há visão, otimiza a imagem; quando não há, recorre ao OCR. Então a capacidade é um fato do backend, cacheado no cliente, e a decisão por captura de rodar ou pular o OCR é tomada no cliente a partir dessa flag. O backend continua dono de qual modelo de fato responde.

A otimização que se achou esperta demais

Aqui é onde fica instrutivo. A primeira versão do caminho de visão rodava o OCR e a otimização de imagem juntos, em paralelo. Depois um commit «perf» apertou tudo num ou-um-ou-outro: se o plano suporta visão, otimiza a imagem e pula o OCR por completo; se não, roda o OCR e pula o trabalho de imagem. No papel isso é a vitória óbvia — para que extrair texto de que o modelo de visão não precisa? O changelog até anuncia: «OCR is skipped when the AI can read the image directly».

O problema é a palavra fallback. O backend não tem um único modelo; tem um primário e um fallback, e o fallback pode ser um modelo sem visão. Imagine a sequência: o modelo primário do seu plano suporta visão, então o cliente pula o OCR todo orgulhoso e envia só a imagem. O provedor primário tem um soluço. O backend recorre a um modelo só de texto — ao qual agora se entrega uma imagem que ele não consegue ler e nenhum texto, porque o cliente tinha otimizado o texto até ele sumir. O caminho rápido tinha apagado sem alarde a rede de segurança. Você pode descartar um caminho rápido redundante sem perigo; não pode descartar aquilo que o caminho rápido contornava como atalho.

A correção: condicionar pelo fallback, não pelo primário

A correção é a parte que vale a pena roubar. Não foi voltar a rodar sempre o OCR — isso jogaria fora a vitória para o caso comum. Foi tornar o pulo condicional a uma segunda flag de capacidade: o fallback suporta visão? A config do backend ganhou um bloco fallbackVision; o cliente faz cache de aiFallbackSupportsVision ao lado da primeira flag. Agora a decisão tem quatro ramos:

  • Primário e fallback ambos veem → otimiza a imagem, pula o OCR. O caminho rápido e comum.
  • O primário vê mas o fallback é cego → otimiza a imagem e roda o OCR, como seguro para o fallback.
  • A otimização de imagem lança exceção → OCR como fallback duro.
  • O plano não tem visão nenhuma → só OCR.

E o segundo ramo — o caso do seguro — é onde o último movimento importa. Rodar o OCR depois da otimização de imagem somaria a latência dele por cima. Então os dois rodam juntos sob um único Promise.all: a otimização de imagem e o OCR se sobrepõem, e o OCR que existe só para o caso de o backend precisar termina dentro do tempo em que a imagem estava sendo otimizada de qualquer jeito. A rede de segurança é gratuita em tempo de relógio sempre que não é usada. É o truque todo em uma frase: mantenha o fallback barato, condicione-o a se o seu provedor de fallback realmente precisa dele, e paralelize-o para que não custe nada até o dia em que ele te salva.

Duas armadilhas menores que o pulo disparou

Pular trabalho adiado de forma agressiva tem o dom de trazer à tona cada lugar que assumiu em silêncio que o trabalho aconteceria. Duas apareceram na revisão de PR na mesma semana.

Um deadlock de fila. A fila de capturas considerava um item «pronto» quando ele tinha texto OCR. As capturas com visão agora nunca recebiam texto OCR — por design — então a verificação de completude hasIncompleteOCR esperava para sempre e a fila travava. A correção redefine «pronto» como tem uma imagem otimizada ou tem texto OCR, que é o que o pipeline agora de fato garante.

Uma flag de capacidade obsoleta. aiSupportsVision é cacheada, o que significa que pode ficar obsoleta exatamente na direção errada: um usuário faz logout, ou um fetch de config falha, e o cliente fica acreditando que ainda pode enviar imagens a um modelo que já não as suporta. A flag agora se reseta no logout e na falha do fetch de config — uma capacidade cacheada precisa ter uma história para quando aquilo que ela descreve desaparece.

Uma palavra sobre os números

Porque a honestidade é o ponto desta série: as mensagens de commit carregam números — otimização de imagem «~30ms» contra OCR «~100–150ms», payloads «~200–500 KB vs ~3–8 MB PNG» — e são plausíveis, mas são as próprias estimativas inline do autor, não a saída de um benchmark. Não há em lugar nenhum do histórico uma latência end-to-end medida antes/depois. Trate o argumento de velocidade como uma intenção de design sensata, não um resultado provado. A redução de tamanho de payload é o único número em que você pode confiar, porque sai diretamente de redimensionar e recomprimir.

Três coisas que esta release nos ensinou

  1. Enviar os pixels vence ler o texto — quando o modelo pode ver. O OCR descarta tudo o que uma captura comunica além dos caracteres. Um modelo de visão lê o layout, o diagrama e a indentação, e um JPEG otimizado torna isso acessível. Mas é uma aposta de capacidade, e é por isso que precisa de um fallback.
  2. Você pode otimizar removendo um caminho rápido, nunca a rede de segurança. Pular o OCR para os usuários com visão estava certo para o primário e errado para o fallback. O bug não era o pulo; era pular com base na capacidade do primário quando a que importava era a do fallback.
  3. Uma rede de segurança gratuita é uma rede paralelizada. Condicione o trabalho de seguro a se ele é realmente necessário, e então rode-o ao lado do caminho rápido para que se sobreponha. Bem feito, a degradação graciosa não custa nada até o dia em que é a única razão pela qual sua funcionalidade ainda funciona.

Para o capítulo anterior da história v1, os dois modos de falha de um overlay click-through (v1.8.5); e para todo o arco, a anatomia de entregar software até a perfeição.