
Van OCR naar pixels zonder de fallback te verliezen
GeekBye stopte met het lezen van je screenshots via OCR en begon de daadwerkelijke afbeelding naar vision-modellen te sturen — en dat is het makkelijke deel. Het leerzame deel is de optimalisatie die OCR volledig oversloeg voor vision-gebruikers, ongemerkt de fallback naar niet-vision-modellen brak, en teruggedraaid moest worden naar een versie waarin het vangnet parallel draait en niets kost.
Er is een echte tweesprong voor elke app waarmee je een screenshot maakt en een AI er iets over vraagt. Je kunt OCR draaien — de tekst op het apparaat zelf extraheren, een minuscule string versturen en het plaatje weggooien. Of je kunt de pixels versturen — de daadwerkelijke afbeelding opsturen en een vision-model die laten lezen. OCR is goedkoop en klein maar blind voor alles wat geen tekst is: de lay-out, het diagram, de inspringing van de code, het rode kringeltje onder de fout. Pixels behouden dat allemaal maar kosten meer bytes en vereisen een model dat kan zien. GeekBye begon aan de OCR-kant van die tweesprong en liep, over v1.8.6 en v1.8.7 heen, naar de pixelkant. Die omschakeling is het makkelijke deel van dit verhaal. Het interessante deel is de optimalisatie die ermee meekwam, de fallback brak, en ongedaan gemaakt moest worden.
Daarvoor: lees de tekst, gooi het plaatje weg
Tot deze episode werd een screenshot zo een antwoord: je drukt op de opname-sneltoets, de client draait OCR — Apple's Vision-framework via een Swift-VisionOCR-binary op macOS, een PowerShell-script op Windows, beide achter één IOcrEngine-abstractie — en de geëxtraheerde tekst wordt in een string verpakt en naar de backend gestuurd als screenshotText. De afbeelding zelf verliet de machine nooit. Dat is efficiënt, en voor een muur van platte tekst is het prima. Maar een screenshot is zelden alleen tekst. Het is een vacature met een tweekoloms-lay-out, een programmeeropgave met een diagram, een fout met een stacktrace waarvan de inspringing de aanwijzing is. OCR plet dat allemaal tot een regel-voor-regel-transcriptie en het model beantwoordt een dunnere vraag dan die je stelde.
De omschakeling: verstuur de pixels (v1.8.6)
v1.8.6 voegde het andere pad toe. Een nieuwe imageOptimizer.ts, gebouwd op sharp en daarom cross-platform, doet precies drie dingen met een ruw screenshot:
sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })
Verklein tot hoogstens 1920px breed (schaal een kleinere opname nooit op), converteer PNG naar JPEG, kwaliteit 80. De uitvoer is ruwe base64 met het mediatype in een apart veld, en het reist naar de backend mee in twee nieuwe request-velden naast het oude: screenshotImage en screenshotImageMediaType, onafhankelijk van screenshotText. De backend kan nu een afbeelding, tekst, of beide ontvangen.
Die optimalisatiestap is niet cosmetisch. Een ruwe screenshot-PNG is 3–8 MB; de verkleinde JPEG is ongeveer 200–500 KB. Dat scheelt een orde van grootte op de upload, en het verlaagt ook de rekening van het model — vision-modellen tegelen een afbeelding op basis van de resolutie en rekenen per tegel, dus de breedte op 1920 aftoppen en hercomprimeren verlaagt direct zowel de bytes over de lijn als de image-tokens waarvoor je betaalt, zonder noemenswaardig verlies van leesbaarheid voor tekst op het scherm.
Hoe weet de app überhaupt of het model kan zien? De backend maakt het bekend. Het /api/config-antwoord kreeg een ai.vision-blok per plan, en de client cachet één enkele boolean — aiSupportsVision — bij elke config-fetch. Wanneer vision wordt ondersteund, optimaliseer je de afbeelding; wanneer niet, val je terug op OCR. Dus de capability is een backend-feit, client-side gecachet, en de beslissing per screenshot om OCR te draaien of over te slaan wordt op de client genomen op basis van die flag. De backend bepaalt nog steeds welk model daadwerkelijk antwoordt.
De optimalisatie die te slim was
Hier wordt het leerzaam. De eerste versie van het vision-pad draaide OCR en beeldoptimalisatie samen, parallel. Toen kneep een "perf"-commit het dicht tot een of-of: als het plan vision ondersteunt, optimaliseer de afbeelding en sla OCR volledig over; als dat niet zo is, draai OCR en sla het beeldwerk over. Op papier is dit de voor de hand liggende winst — waarom tekst extraheren die het vision-model niet nodig heeft? De changelog adverteert het zelfs: "OCR is skipped when the AI can read the image directly."
Het probleem zit in dat ene woord: fallback. De backend heeft niet één model; hij heeft een primair model en een fallback, en de fallback is misschien een niet-vision-model. Stel je de volgorde voor: het primaire model van jouw plan ondersteunt vision, dus de client slaat trots OCR over en stuurt alleen de afbeelding. De primaire provider hapert. De backend valt terug op een tekst-only-model — dat nu een afbeelding krijgt die het niet kan lezen en geen tekst, want de client heeft de tekst weggeoptimaliseerd. Het snelle pad had stilletjes het vangnet gewist. Je kunt een overbodig snel pad veilig laten vallen; je kunt niet het ding laten vallen waar het snelle pad een shortcut omheen was.
De fix: ga af op de fallback, niet op het primaire model
De fix is het deel dat het stelen waard is. Het was niet om terug te gaan naar altijd OCR draaien — dat zou de winst voor het gangbare geval weggooien. Het was om het overslaan afhankelijk te maken van een tweede capability-flag: ondersteunt de fallback vision? De config van de backend kreeg een fallbackVision-blok; de client cachet aiFallbackSupportsVision naast de eerste flag. Nu heeft de beslissing vier takken:
- Primair en fallback zien allebei → optimaliseer de afbeelding, sla OCR over. Het snelle, gangbare pad.
- Primair ziet maar fallback is blind → optimaliseer de afbeelding en draai OCR, als verzekering voor de fallback.
- Beeldoptimalisatie gooit een fout → OCR als harde fallback.
- Plan heeft helemaal geen vision → alleen OCR.
En de tweede tak — het verzekeringsgeval — is waar de laatste zet ertoe doet. OCR draaien na de beeldoptimalisatie zou de latency er weer bovenop leggen. Dus de twee draaien samen onder één Promise.all: beeldoptimalisatie en OCR overlappen, en de OCR die alleen bestaat voor het geval de backend hem nodig heeft, is klaar binnen de tijd waarin de afbeelding toch al werd geoptimaliseerd. Het vangnet is qua wall-clock-tijd gratis zolang het niet wordt gebruikt. Dat is de hele truc in één zin: behoud de goedkope fallback, laat hem afhangen van of je fallback-provider hem echt nodig heeft, en parallelliseer hem zodat hij niets kost tot de dag dat hij je redt.
Twee kleinere valstrikken die het overslaan liet dichtklappen
Agressief uitgesteld werk overslaan heeft de neiging om elke plek bloot te leggen die er stilzwijgend van uitging dat het werk zou gebeuren. Twee kwamen dezelfde week in de PR-review naar boven.
Een queue-deadlock. De screenshot-queue beschouwde een item als "klaar" wanneer het OCR-tekst had. Vision-screenshots kregen nu nooit OCR-tekst — met opzet — dus de volledigheidscontrole hasIncompleteOCR wachtte eeuwig en de queue liep vast. De fix herdefinieert "klaar" als heeft een geoptimaliseerde afbeelding of heeft OCR-tekst, wat de pipeline nu daadwerkelijk garandeert.
Een verouderde capability-flag. aiSupportsVision wordt gecachet, wat betekent dat hij precies de verkeerde kant op kan verouderen: een gebruiker logt uit, of een config-fetch faalt, en de client blijft geloven dat hij nog afbeeldingen mag sturen naar een model dat ze niet langer ondersteunt. De flag reset nu bij uitloggen en bij een gefaalde config-fetch — een gecachete capability moet een antwoord hebben op het moment dat datgene wat hij beschrijft verdwijnt.
Een woord over de getallen
Omdat eerlijkheid het punt van deze serie is: de commit-berichten dragen cijfers — beeldoptimalisatie "~30ms" tegenover OCR "~100–150ms," payloads "~200–500 KB vs ~3–8 MB PNG" — en ze zijn plausibel, maar het zijn de eigen inline-schattingen van de auteur, niet de uitkomst van een benchmark. Nergens in de geschiedenis staat een gemeten end-to-end-latency van voor/na. Behandel het snelheidsargument als gezonde ontwerpintentie, niet als een bewezen resultaat. De verkleining van de payload-grootte is het ene getal dat je kunt vertrouwen, want het volgt rechtstreeks uit verkleinen en hercomprimeren.
Drie dingen die deze release ons leerde
- De pixels versturen wint van de tekst lezen — als het model kan zien. OCR gooit alles weg wat een screenshot overbrengt behalve tekens. Een vision-model leest de lay-out, het diagram en de inspringing, en een geoptimaliseerde JPEG maakt dat betaalbaar. Maar het is een gok op capability, en daarom heeft het een fallback nodig.
- Je kunt een snel pad wegoptimaliseren, nooit het vangnet. OCR overslaan voor vision-gebruikers was juist voor het primaire model en fout voor de fallback. De bug was niet het overslaan; het was overslaan op basis van de capability van het primaire model terwijl die van de fallback de bepalende was.
- Een gratis vangnet is een geparallelliseerd vangnet. Laat het verzekeringswerk afhangen van of het echt nodig is, en draai het dan naast het snelle pad zodat het overlapt. Goed gedaan kost graceful degradation niets tot de dag dat het de enige reden is dat je feature nog werkt.
Voor het vorige hoofdstuk in het v1-verhaal, de twee faalmodi van een click-through-overlay (v1.8.5); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.