
Da OCR ai pixel senza perdere il fallback
GeekBye ha smesso di leggere i tuoi screenshot con l'OCR e ha iniziato a inviare l'immagine reale ai modelli di visione — che è la parte facile. La parte istruttiva è l'ottimizzazione che ha saltato l'OCR del tutto per gli utenti con visione, ha rotto senza rumore il fallback verso i modelli senza visione, ed è dovuta essere disfatta fino a una versione dove la rete di sicurezza gira in parallelo e non costa nulla.
C'è una vera biforcazione nel cammino per qualunque app che ti lascia catturare qualcosa e chiedere a un'IA in proposito. Puoi eseguire l'OCR — estrarre il testo sul dispositivo, inviare una stringa minuscola, e buttare via l'immagine. Oppure puoi inviare i pixel — spedire l'immagine reale e lasciare che un modello capace di visione la legga. L'OCR è economico e leggero ma cieco a tutto ciò che non è testo: il layout, il diagramma, l'indentazione del codice, la sottolineatura rossa sotto l'errore. I pixel preservano tutto questo ma costano più byte e richiedono un modello che sappia vedere. GeekBye è partita dal lato OCR di quella biforcazione e, attraverso la v1.8.6 e la v1.8.7, è passata al lato dei pixel. Quel passaggio è la parte facile di questa storia. La parte interessante è l'ottimizzazione che è arrivata con esso, ha rotto il fallback, ed è dovuta essere disfatta.
Il prima: leggere il testo, lasciare l'immagine
Fino a questo arco, uno screenshot diventava una risposta così: premi la scorciatoia di cattura, il client esegue l'OCR — il framework Vision di Apple tramite un binario Swift VisionOCR su macOS, uno script PowerShell su Windows, entrambi dietro un'unica astrazione IOcrEngine — e il testo estratto viene avvolto in una stringa e inviato al backend come screenshotText. L'immagine in sé non lasciava mai la macchina. È efficiente, e per un muro di testo semplice va bene. Ma uno screenshot raramente è solo testo. È un annuncio di lavoro con un layout a due colonne, un problema di programmazione con un diagramma, un errore con una stack trace la cui indentazione è l'indizio. L'OCR appiattisce tutto ciò in una trascrizione riga per riga e il modello risponde a una domanda più magra di quella che hai fatto.
Il passaggio: inviare i pixel (v1.8.6)
La v1.8.6 ha aggiunto l'altro percorso. Un nuovo imageOptimizer.ts, costruito su sharp e quindi multipiattaforma, fa esattamente tre cose a uno screenshot grezzo:
sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })
Ridimensionare ad al massimo 1920px di larghezza (mai ingrandire una cattura più piccola), convertire PNG in JPEG, quality 80. L'output è base64 grezzo con il media type portato in un campo separato, e viaggia verso il backend in due nuovi campi della richiesta accanto al vecchio: screenshotImage e screenshotImageMediaType, indipendenti da screenshotText. Il backend ora può ricevere un'immagine, del testo, o entrambi.
Quel passo di ottimizzazione non è cosmetico. Un PNG di screenshot grezzo è 3–8 MB; il JPEG ridimensionato è all'incirca 200–500 KB. È un ordine di grandezza in meno sull'upload, e taglia anche il conto del modello — i modelli di visione dividono un'immagine in riquadri secondo la sua risoluzione e fatturano a riquadro, quindi limitare la larghezza a 1920 e ricomprimere riduce direttamente sia i byte sul filo sia i token d'immagine che paghi, senza perdita apprezzabile di leggibilità per il testo a schermo.
Come fa l'app a sapere se il modello può anche solo vedere? Il backend lo annuncia. La risposta di /api/config ha guadagnato un blocco ai.vision indicizzato per piano, e il client mette in cache un singolo booleano — aiSupportsVision — a ogni fetch di config. Quando la visione è supportata, ottimizza l'immagine; quando non lo è, ripiega sull'OCR. Quindi la capacità è un fatto del backend, messo in cache lato client, e la decisione per screenshot di eseguire o saltare l'OCR viene presa sul client a partire da quella flag. Il backend resta padrone di quale modello risponde davvero.
L'ottimizzazione troppo furba
È qui che diventa istruttivo. La prima versione del percorso di visione eseguiva l'OCR e l'ottimizzazione dell'immagine insieme, in parallelo. Poi un commit «perf» l'ha stretta in un o-l'uno-o-l'altro: se il piano supporta la visione, ottimizza l'immagine e salta l'OCR del tutto; se non lo supporta, esegui l'OCR e salta il lavoro sull'immagine. Sulla carta è la vittoria ovvia — perché estrarre testo di cui il modello di visione non ha bisogno? Il changelog lo pubblicizza persino: «OCR is skipped when the AI can read the image directly».
Il problema è la parola fallback. Il backend non ha un solo modello; ne ha uno primario e uno di fallback, e il fallback potrebbe essere un modello senza visione. Immagina la sequenza: il modello primario del tuo piano supporta la visione, così il client salta orgogliosamente l'OCR e invia solo l'immagine. Il provider primario ha un singhiozzo. Il backend ripiega su un modello di solo testo — al quale ora viene consegnata un'immagine che non può leggere e nessun testo, perché il client aveva ottimizzato via il testo. Il percorso veloce aveva cancellato senza rumore la rete di sicurezza. Puoi abbandonare senza pericolo un percorso veloce ridondante; non puoi abbandonare la cosa che il percorso veloce aggirava come scorciatoia.
La correzione: condizionare sul fallback, non sul primario
La correzione è la parte che vale la pena rubare. Non è stata tornare a eseguire sempre l'OCR — ciò butterebbe via la vittoria per il caso comune. È stata rendere il salto condizionato a una seconda flag di capacità: il fallback supporta la visione? La config del backend ha guadagnato un blocco fallbackVision; il client mette in cache aiFallbackSupportsVision accanto alla prima flag. Ora la decisione ha quattro rami:
- Primario e fallback vedono entrambi → ottimizza l'immagine, salta l'OCR. Il percorso veloce e comune.
- Il primario vede ma il fallback è cieco → ottimizza l'immagine ed esegui l'OCR, come assicurazione per il fallback.
- L'ottimizzazione dell'immagine solleva un'eccezione → OCR come fallback duro.
- Il piano non ha alcuna visione → solo OCR.
E il secondo ramo — il caso dell'assicurazione — è dove conta l'ultima mossa. Eseguire l'OCR dopo l'ottimizzazione dell'immagine ne aggiungerebbe la latenza sopra. Così i due girano insieme sotto un unico Promise.all: l'ottimizzazione dell'immagine e l'OCR si sovrappongono, e l'OCR che esiste solo nel caso il backend ne abbia bisogno finisce entro il tempo in cui l'immagine veniva comunque ottimizzata. La rete di sicurezza è gratuita in tempo reale ogni volta che non viene usata. È tutto il trucco in una frase: tieni il fallback economico, condizionalo a se il tuo provider di fallback ne ha davvero bisogno, e parallelizzalo così che non costi nulla fino al giorno in cui ti salva.
Due trappole più piccole scattate dal salto
Saltare in modo aggressivo il lavoro rinviato ha il dono di far riemergere ogni punto che dava per scontato in silenzio che il lavoro sarebbe avvenuto. Due sono uscite in revisione della PR nella stessa settimana.
Un deadlock di coda. La coda degli screenshot considerava un elemento «pronto» quando aveva del testo OCR. Gli screenshot con visione ora non ottenevano mai testo OCR — di proposito — così il controllo di completezza hasIncompleteOCR aspettava per sempre e la coda si bloccava. La correzione ridefinisce «pronto» come ha un'immagine ottimizzata o ha del testo OCR, che è ciò che la pipeline ora garantisce davvero.
Una flag di capacità obsoleta. aiSupportsVision è in cache, il che significa che può diventare obsoleta esattamente nella direzione sbagliata: un utente fa logout, o un fetch di config fallisce, e il client resta a credere di poter ancora inviare immagini a un modello che non le supporta più. La flag ora si resetta al logout e al fallimento del fetch di config — una capacità in cache deve avere una storia per quando la cosa che descrive scompare.
Una parola sui numeri
Perché l'onestà è il punto di questa serie: i messaggi di commit portano cifre — ottimizzazione dell'immagine «~30ms» contro OCR «~100–150ms», payload «~200–500 KB vs ~3–8 MB PNG» — e sono plausibili, ma sono le stime inline dell'autore stesso, non l'output di un benchmark. Non c'è da nessuna parte nella storia una latenza end-to-end misurata prima/dopo. Tratta l'argomento della velocità come una sana intenzione di design, non un risultato provato. La riduzione della dimensione del payload è l'unico numero di cui puoi fidarti, perché cade dritto dritto dal ridimensionare e ricomprimere.
Tre cose che questa release ci ha insegnato
- Inviare i pixel batte leggere il testo — quando il modello può vedere. L'OCR scarta tutto ciò che uno screenshot comunica oltre ai caratteri. Un modello di visione legge il layout, il diagramma e l'indentazione, e un JPEG ottimizzato lo rende sostenibile. Ma è una scommessa sulla capacità, ed è per questo che serve un fallback.
- Puoi ottimizzare rimuovendo un percorso veloce, mai la rete di sicurezza. Saltare l'OCR per gli utenti con visione era corretto per il primario e sbagliato per il fallback. Il bug non era il salto; era saltare basandosi sulla capacità del primario quando quella che contava era quella del fallback.
- Una rete di sicurezza gratuita è una rete parallelizzata. Condiziona il lavoro di assicurazione a se serve davvero, poi eseguilo accanto al percorso veloce così che si sovrapponga. Fatta bene, la degradazione graziosa non costa nulla fino al giorno in cui è l'unico motivo per cui la tua funzionalità funziona ancora.
Per il capitolo precedente della storia v1, i due modi di fallimento di un overlay click-through (v1.8.5); e per tutto l'arco, l'anatomia del pubblicare software fino alla perfezione.