
Від OCR до пікселів, не втрачаючи запасний шлях
GeekBye перестав читати твої скриншоти через OCR і почав надсилати саме зображення зрячим моделям — і це легка частина. Повчальна частина — оптимізація, яка пропускала OCR цілком для користувачів зі зрячою моделлю, тихо ламала запасний шлях до моделей без зору й її довелося відкотити до версії, де страхувальна сітка біжить паралельно і не коштує нічого.
Перед будь-яким застосунком, що дозволяє зробити скриншот і спитати про нього ШІ, стоїть справжня розвилка. Ти можеш запустити OCR — витягнути текст на пристрої, надіслати крихітний рядок і викинути картинку. Або ти можеш надіслати пікселі — переслати саме зображення і дати зрячій моделі його прочитати. OCR дешевий і малий, але сліпий до всього, що не текст: до верстки, до діаграми, до відступів коду, до червоної хвилястої лінії під помилкою. Пікселі зберігають усе це, але коштують більше байтів і потребують моделі, що вміє бачити. GeekBye починав зі сторони OCR цієї розвилки і, за v1.8.6 і v1.8.7, перейшов на сторону пікселів. Це перемикання — легка частина цієї історії. Цікава частина — оптимізація, що прийшла разом із ним, зламала запасний шлях і її довелося відкотити.
До цього: прочитай текст, кинь картинку
До цього розділу скриншот ставав відповіддю так: ти тиснеш гарячу клавішу захоплення, клієнт запускає OCR — фреймворк Vision від Apple через свіфтовий бінарник VisionOCR на macOS, скрипт PowerShell на Windows, обидва за однією абстракцією IOcrEngine — а витягнутий текст загортається в рядок і надсилається на backend як screenshotText. Саме зображення ніколи не покидало машину. Це ефективно, і для стіни простого тексту це нормально. Але скриншот рідко буває просто текстом. Це вакансія з двоколонковою версткою, задача з програмування з діаграмою, помилка зі стек-трейсом, чиї відступи і є підказкою. OCR сплющує все це в порядкову транскрипцію, і модель відповідає на бідніше питання, ніж те, що ти поставив.
Перемикання: надішли пікселі (v1.8.6)
v1.8.6 додав інший шлях. Новий imageOptimizer.ts, побудований на sharp і тому кросплатформний, робить із сирим скриншотом рівно три речі:
sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })
Зменш до щонайбільше 1920px завширшки (ніколи не збільшуй менше захоплення), перетвори PNG на JPEG, якість 80. Вивід — сирий base64 з типом медіа, несеним в окремому полі, і він їде на backend у двох нових полях запиту поряд зі старим: screenshotImage і screenshotImageMediaType, незалежно від screenshotText. Backend тепер може отримати зображення, текст або обидва одразу.
Цей крок оптимізації не косметичний. Сирий PNG скриншота — 3–8 MB; зменшений JPEG — приблизно 200–500 KB. Це на порядок менше при завантаженні, і це до того ж зрізає рахунок за модель — зрячі моделі б'ють зображення на тайли за його роздільною здатністю і беруть плату за тайл, тож обмеження ширини до 1920 і перестиск напряму знижують і байти на дроті, і токени зображення, за які ти платиш, без скільки-небудь помітної втрати читабельності екранного тексту.
Звідки застосунок узагалі знає, чи вміє модель бачити? Про це оголошує backend. Відповідь /api/config отримала блок ai.vision з ключем за тарифом, і клієнт при кожному завантаженні конфігурації кешує єдине булеве значення — aiSupportsVision. Коли зір підтримується, оптимізуй зображення; коли ні — відкочуйся на OCR. Тож здатність — це факт на стороні backend, кешований на стороні клієнта, а рішення для кожного скриншота, запускати OCR чи пропустити, ухвалюється на клієнті за цим прапорцем. Backend і далі володіє тим, яка модель насправді відповідає.
Оптимізація, що виявилася надто розумною
Ось тут стає повчально. Перша версія зрячого шляху запускала OCR і оптимізацію зображення разом, паралельно. Потім коміт «perf» стиснув це в «або-або»: якщо тариф підтримує зір, оптимізуй зображення і пропусти OCR цілком; якщо ні — запусти OCR і пропусти роботу над зображенням. На папері це очевидний виграш — навіщо витягувати текст, який зрячій моделі не потрібен? Changelog навіть це рекламує: «OCR is skipped when the AI can read the image directly.»
Проблема у слові fallback. У backend не одна модель; у нього є основна й запасна, і запасна може бути без зору. Уяви послідовність: основна модель твого тарифу вміє бачити, тож клієнт гордо пропускає OCR і надсилає лише зображення. Основний провайдер гикає. Backend відкочується на модель, що працює лише з текстом, — якій тепер вручають зображення, що вона прочитати не може, і жодного тексту, бо клієнт оптимізував текст так, що він зник. Швидкий шлях тихо видалив страхувальну сітку. Надлишковий швидкий шлях можна безпечно відкинути; не можна відкинути те, в обхід чого швидкий шлях і був зрізанням.
Лік: покладайся на запасну модель, а не на основну
Лік — та частина, що варта поцупити. Він був не в тому, щоб повернутися до завжди запусканого OCR — це викинуло б виграш для частого випадку. Він був у тому, щоб зробити пропуск залежним від другого прапорця здатності: чи вміє бачити запасна модель? Конфігурація backend отримала блок fallbackVision; клієнт кешує aiFallbackSupportsVision поряд із першим прапорцем. Тепер у рішення чотири гілки:
- Основна й запасна обидві бачать → оптимізуй зображення, пропусти OCR. Швидкий, частий шлях.
- Основна бачить, але запасна сліпа → оптимізуй зображення і запусти OCR, як страховку для запасної.
- Оптимізація зображення кидає виняток → OCR як жорсткий запасний шлях.
- У тарифу немає зору зовсім → тільки OCR.
А друга гілка — випадок зі страховкою — це місце, де важить останній хід. Запуск OCR після оптимізації зображення додав би його затримку згори. Тож обидва біжать разом під одним Promise.all: оптимізація зображення і OCR перекриваються, а OCR, що існує лише на випадок, якщо backend його потребує, завершується всередині того часу, що зображення й так оптимізувалося. Страхувальна сітка безкоштовна в термінах настінного годинника щоразу, коли не використовується. Увесь трюк в одному реченні: збережи дешевий запасний шлях, зроби його залежним від того, чи потрібен він твоєму запасному провайдеру насправді, і розпаралель його так, щоб він не коштував нічого, поки тебе не врятує.
Дві менші пастки, що їх наставив пропуск
У агресивного пропуску відкладеної роботи є звичка витягувати на поверхню кожне місце, що тихо припускало: робота станеться. Дві сплили в огляді PR того ж тижня.
Взаємне блокування черги. Черга скриншотів вважала елемент «готовим», коли той мав текст OCR. Зрячі скриншоти тепер ніколи не отримували текст OCR — за задумом — тож перевірка повноти hasIncompleteOCR чекала вічно, і черга зависала. Лік перевизначає «готовий» як є оптимізоване зображення або є текст OCR, що конвеєр тепер і справді гарантує.
Застарілий прапорець здатності. aiSupportsVision кешується, а отже, може застаріти рівно в неправильний бік: користувач виходить з акаунта, або завантаження конфігурації провалюється, і клієнт лишається в переконанні, що все ще може надсилати зображення моделі, яка їх більше не підтримує. Прапорець тепер скидається при виході з акаунта і при невдалому завантаженні конфігурації — у кешованої здатності мусить бути сценарій на випадок, коли те, що вона описує, зникає.
Слово про числа
Оскільки сенс цієї серії — чесність: повідомлення комітів несуть цифри — оптимізація зображення «~30ms» проти OCR «~100–150ms», навантаження «~200–500 KB vs ~3–8 MB PNG» — і вони правдоподібні, але це власні вбудовані оцінки автора, а не вивід бенчмарка. Ніде в історії немає виміряної наскрізної затримки до і після. Сприймай аргумент про швидкість як здоровий проєктний задум, а не доведений результат. Скорочення розміру навантаження — єдине число, якому можна довіряти, бо воно прямо випадає зі зменшення і перестиску.
Три речі, яких нас навчив цей реліз
- Надсилання пікселів б'є читання тексту — коли модель уміє бачити. OCR відкидає все, що скриншот повідомляє окрім символів. Зряча модель читає верстку, діаграму й відступи, а оптимізований JPEG робить це доступним за ціною. Але це ставка на здатність, і тому їй потрібен запасний шлях.
- Швидкий шлях можна оптимізувати до зникнення, страхувальну сітку — ніколи. Пропуск OCR для користувачів зі зрячою моделлю був вірний для основної моделі й невірний для запасної. Баг був не в пропуску; він був у тому, що пропуск поставили в залежність від здатності основної моделі, коли значення мала здатність запасної.
- Безкоштовна страхувальна сітка — це розпаралелена сітка. Зроби страхувальну роботу залежною від того, чи вона справді потрібна, а потім запусти її поряд зі швидким шляхом, щоб вони перекривалися. Зроблена правильно, плавна деградація не коштує нічого до того дня, коли вона — єдина причина, з якої твоя фіча все ще працює.
Про попередній розділ історії v1 — два режими відмови накладки, що пропускає кліки (v1.8.5); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.