Steven
Steven9 min de lecture

De l'OCR aux pixels sans perdre le fallback

GeekBye a cessé de lire vos captures d'écran avec l'OCR et s'est mis à envoyer l'image réelle à des modèles de vision — ce qui est la partie facile. La partie instructive, c'est l'optimisation qui a sauté l'OCR entièrement pour les utilisateurs vision, a cassé sans bruit le fallback vers les modèles sans vision, et a dû être défaite jusqu'à une version où le filet de sécurité tourne en parallèle et ne coûte rien.

Ingénierie
AI
Desktop
Versions de GeekBye
De l'OCR aux pixels sans perdre le fallback

Il y a une vraie bifurcation pour toute app qui vous laisse capturer quelque chose et poser une question à une IA dessus. Vous pouvez lancer l'OCR — extraire le texte sur l'appareil, envoyer une chaîne minuscule, et jeter l'image. Ou vous pouvez envoyer les pixels — expédier l'image réelle et laisser un modèle capable de vision la lire. L'OCR est bon marché et léger mais aveugle à tout ce qui n'est pas du texte : la mise en page, le diagramme, l'indentation du code, le soulignement rouge sous l'erreur. Les pixels préservent tout cela mais coûtent plus d'octets et exigent un modèle qui peut voir. GeekBye a commencé du côté OCR de cette bifurcation et, à travers la v1.8.6 et la v1.8.7, a marché jusqu'au côté pixels. Ce basculement est la partie facile de cette histoire. La partie intéressante, c'est l'optimisation qui l'a accompagné, a cassé le fallback, et a dû être défaite.

L'avant : lire le texte, jeter l'image

Jusqu'à cet arc, une capture devenait une réponse comme ceci : vous appuyez sur le raccourci de capture, le client lance l'OCR — le framework Vision d'Apple via un binaire Swift VisionOCR sur macOS, un script PowerShell sur Windows, tous deux derrière une seule abstraction IOcrEngine — et le texte extrait est enveloppé dans une chaîne et envoyé au backend en tant que screenshotText. L'image elle-même ne quittait jamais la machine. C'est efficace, et pour un mur de texte brut, c'est très bien. Mais une capture n'est presque jamais que du texte. C'est une offre d'emploi avec une mise en page à deux colonnes, un problème de code avec un diagramme, une erreur avec une stack trace dont l'indentation est l'indice. L'OCR aplatit tout cela en une transcription ligne par ligne et le modèle répond à une question plus maigre que celle que vous avez posée.

Le basculement : envoyer les pixels (v1.8.6)

La v1.8.6 a ajouté l'autre chemin. Un nouveau imageOptimizer.ts, bâti sur sharp et donc multiplateforme, fait exactement trois choses à une capture brute :

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

Redimensionner à au plus 1920px de large (ne jamais agrandir une capture plus petite), convertir PNG en JPEG, quality 80. La sortie est du base64 brut avec le media type porté dans un champ séparé, et elle voyage vers le backend dans deux nouveaux champs de requête à côté de l'ancien : screenshotImage et screenshotImageMediaType, indépendants de screenshotText. Le backend peut désormais recevoir une image, du texte, ou les deux.

Cette étape d'optimisation n'est pas cosmétique. Un PNG de capture brut fait 3–8 MB ; le JPEG redimensionné fait à peu près 200–500 KB. C'est un ordre de grandeur en moins sur l'upload, et ça réduit aussi la facture du modèle — les modèles de vision découpent une image en tuiles selon sa résolution et facturent à la tuile, donc plafonner la largeur à 1920 et recompresser réduit directement à la fois les octets sur le fil et les tokens d'image que vous payez, sans perte notable de lisibilité pour le texte à l'écran.

Comment l'app sait-elle si le modèle peut seulement voir ? Le backend l'annonce. La réponse de /api/config a gagné un bloc ai.vision indexé par plan, et le client met en cache un unique booléen — aiSupportsVision — à chaque fetch de config. Quand la vision est supportée, optimise l'image ; quand elle ne l'est pas, retombe sur l'OCR. Donc la capacité est un fait du backend, mis en cache côté client, et la décision par capture de lancer ou de sauter l'OCR est prise sur le client à partir de cette flag. Le backend reste maître de quel modèle répond réellement.

L'optimisation trop maligne

C'est ici que ça devient instructif. La première mouture du chemin vision lançait l'OCR et l'optimisation d'image ensemble, en parallèle. Puis un commit « perf » l'a resserrée en un ou-l'un-ou-l'autre : si le plan supporte la vision, optimise l'image et saute l'OCR entièrement ; sinon, lance l'OCR et saute le travail d'image. Sur le papier c'est le gain évident — pourquoi extraire du texte dont le modèle de vision n'a pas besoin ? Le changelog l'annonce même : « OCR is skipped when the AI can read the image directly ».

Le problème, c'est le mot fallback. Le backend n'a pas un seul modèle ; il a un primaire et un fallback, et le fallback pourrait être un modèle sans vision. Imaginez la séquence : le modèle primaire de votre plan supporte la vision, donc le client saute fièrement l'OCR et n'envoie que l'image. Le fournisseur primaire a un hoquet. Le backend se rabat sur un modèle texte seul — auquel on tend maintenant une image qu'il ne peut pas lire et aucun texte, parce que le client a optimisé le texte jusqu'à le faire disparaître. Le chemin rapide avait discrètement supprimé le filet de sécurité. On peut abandonner un chemin rapide redondant sans danger ; on ne peut pas abandonner la chose que le chemin rapide contournait en raccourci.

Le correctif : conditionner sur le fallback, pas sur le primaire

Le correctif est la partie qui vaut d'être volée. Ce n'était pas de revenir à toujours lancer l'OCR — ça jetterait le gain pour le cas courant. C'était de rendre le saut conditionnel à une deuxième flag de capacité : le fallback supporte-t-il la vision ? La config du backend a gagné un bloc fallbackVision ; le client met en cache aiFallbackSupportsVision à côté de la première flag. Désormais la décision a quatre branches :

  • Primaire et fallback voient tous deux → optimise l'image, saute l'OCR. Le chemin rapide et courant.
  • Le primaire voit mais le fallback est aveugle → optimise l'image et lance l'OCR, comme assurance pour le fallback.
  • L'optimisation d'image lève une exception → OCR comme fallback dur.
  • Le plan n'a aucune vision → OCR seul.

Et la deuxième branche — le cas d'assurance — est là où le dernier mouvement compte. Lancer l'OCR après l'optimisation d'image rajouterait sa latence par-dessus. Alors les deux tournent ensemble sous un seul Promise.all : l'optimisation d'image et l'OCR se recouvrent, et l'OCR qui n'existe qu'au cas où le backend en aurait besoin finit dans le temps où l'image était de toute façon optimisée. Le filet de sécurité est gratuit en temps réel chaque fois qu'il n'est pas utilisé. C'est toute l'astuce en une phrase : gardez le fallback bon marché, conditionnez-le à si votre fournisseur de fallback en a réellement besoin, et parallélisez-le pour qu'il ne coûte rien jusqu'au jour où il vous sauve.

Deux pièges plus petits déclenchés par le saut

Sauter agressivement du travail différé a le don de faire remonter chaque endroit qui supposait en silence que le travail aurait lieu. Deux sont sortis en revue de PR la même semaine.

Un interblocage de file. La file de captures considérait un élément « prêt » quand il avait du texte OCR. Les captures vision n'obtenaient désormais jamais de texte OCR — par conception — donc le contrôle de complétude hasIncompleteOCR attendait pour toujours et la file se bloquait. Le correctif redéfinit « prêt » comme a une image optimisée ou a du texte OCR, ce que le pipeline garantit désormais réellement.

Une flag de capacité périmée. aiSupportsVision est mise en cache, ce qui veut dire qu'elle peut se périmer exactement dans le mauvais sens : un utilisateur se déconnecte, ou un fetch de config échoue, et le client reste à croire qu'il peut encore envoyer des images à un modèle qui ne les supporte plus. La flag se réinitialise désormais à la déconnexion et à l'échec du fetch de config — une capacité mise en cache doit avoir un scénario pour quand la chose qu'elle décrit disparaît.

Un mot sur les chiffres

Parce que l'honnêteté est le propos de cette série : les messages de commit portent des chiffres — optimisation d'image « ~30ms » contre OCR « ~100–150ms », payloads « ~200–500 KB vs ~3–8 MB PNG » — et ils sont plausibles, mais ce sont les propres estimations en ligne de l'auteur, pas la sortie d'un benchmark. Il n'y a nulle part dans l'historique de latence end-to-end mesurée avant/après. Traitez l'argument de vitesse comme une intention de conception saine, pas un résultat prouvé. La réduction de taille de payload est le seul chiffre auquel vous pouvez vous fier, parce qu'il découle directement du redimensionnement et de la recompression.

Trois choses que ce release nous a apprises

  1. Envoyer les pixels bat lire le texte — quand le modèle peut voir. L'OCR jette tout ce qu'une capture communique au-delà des caractères. Un modèle de vision lit la mise en page, le diagramme et l'indentation, et un JPEG optimisé rend cela abordable. Mais c'est un pari sur la capacité, et c'est pourquoi il lui faut un fallback.
  2. On peut optimiser en supprimant un chemin rapide, jamais le filet de sécurité. Sauter l'OCR pour les utilisateurs vision était correct pour le primaire et faux pour le fallback. Le bug n'était pas le saut ; c'était de sauter en se fondant sur la capacité du primaire quand c'était celle du fallback qui comptait.
  3. Un filet de sécurité gratuit est un filet parallélisé. Conditionnez le travail d'assurance à si on en a réellement besoin, puis lancez-le à côté du chemin rapide pour qu'il se recouvre. Bien fait, la dégradation gracieuse ne coûte rien jusqu'au jour où c'est la seule raison pour laquelle votre fonctionnalité marche encore.

Pour le chapitre précédent de l'histoire v1, les deux modes de défaillance d'un overlay click-through (v1.8.5) ; et pour tout l'arc, l'anatomie de livrer un logiciel jusqu'à la perfection.

Articles Connexes

Les deux modes de défaillance d'un overlay click-through
Steven
Steven10 min de lecture

Les deux modes de défaillance d'un overlay click-through

La fenêtre de GeekBye flotte au-dessus de tout et laisse vos clics la traverser — sauf là où elle a des boutons. C'est un contrat à deux faces, et la v1.8.5 et la v1.8.14 sont ce que ça donne quand chaque face casse : une release où l'overlay a avalé une boîte de dialogue système, une autre où il a volé vos frappes clavier. Le correctif gagnant pour la seconde a été de supprimer du code.

Ingénierie
Electron
Desktop
Mettre la release dans la CI, deux fois
Steven
Steven9 min de lecture

Mettre la release dans la CI, deux fois

GeekBye v1.8.4 a déplacé les builds de release vers la CI pour macOS et Windows. Ce que le changelog ne dit pas, c'est qu'on avait déjà tenté le coup une fois, qu'on l'a supprimé un mois plus tard à cause du coût des runners, et qu'on ne l'a fait tenir qu'à la deuxième fois — parce qu'à ce moment-là le script de release marchait déjà à la main.

Ingénierie
CI/CD
Desktop
Livrer trente langues sans filet de sécurité
Steven
Steven9 min de lecture

Livrer trente langues sans filet de sécurité

GeekBye v1.8.3 a câblé react-i18next et traduit toute l'app en 30 langues. La partie librairie était de la routine. L'intéressant, c'est ce qui manquait : pas de contrôle de parité des clés ni d'extracteur de chaînes — si bien que de l'anglais codé en dur a fui, une coquille danoise est passée en prod, et le changelog n'arrivait pas à se mettre d'accord sur si l'app parlait 28, 29 ou 30 langues.

Ingénierie
i18n
Desktop