Steven
Steven7 мин. чтения

От OCR к пикселям, не теряя запасной путь

GeekBye перестал читать твои скриншоты через OCR и начал отправлять само изображение зрячим моделям — и это лёгкая часть. Поучительная часть — оптимизация, которая пропускала OCR целиком для пользователей со зрячей моделью, тихо ломала запасной путь к моделям без зрения и её пришлось откатить до версии, где страховочная сетка бежит параллельно и не стоит ничего.

Инженерия
AI
Десктоп
Релизы GeekBye
От 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» — и они правдоподобны, но это собственные встроенные оценки автора, а не вывод бенчмарка. Нигде в истории нет измеренной сквозной задержки до и после. Считай аргумент о скорости здравым проектным замыслом, а не доказанным результатом. Сокращение размера нагрузки — единственное число, которому можно доверять, потому что оно прямо выпадает из уменьшения и пережатия.

Три вещи, которым нас научил этот релиз

  1. Отправка пикселей бьёт чтение текста — когда модель умеет видеть. OCR отбрасывает всё, что скриншот сообщает помимо символов. Зрячая модель читает вёрстку, диаграмму и отступы, а оптимизированный JPEG делает это доступным по цене. Но это ставка на способность, и потому ей нужен запасной путь.
  2. Быстрый путь можно оптимизировать до исчезновения, страховочную сетку — никогда. Пропуск OCR для пользователей со зрячей моделью был верен для основной модели и неверен для запасной. Баг был не в пропуске; он был в том, что пропуск завязали на способность основной модели, когда значение имела способность запасной.
  3. Бесплатная страховочная сетка — это распараллеленная сетка. Завяжи страховочную работу на то, нужна ли она на самом деле, а потом запусти её рядом с быстрым путём, чтобы они перекрывались. Сделанная правильно, плавная деградация не стоит ничего до того дня, когда она — единственная причина, по которой твоя фича всё ещё работает.

Про предыдущую главу истории v1 — два режима отказа накладки, пропускающей клики (v1.8.5); а про всю дугу — анатомию доведения софта до совершенства.

Похожие статьи

Два режима отказа накладки, пропускающей клики
Steven
Steven8 мин. чтения

Два режима отказа накладки, пропускающей клики

Окно GeekBye плавает поверх всего и пропускает твои клики насквозь — кроме тех мест, где у него есть кнопки. Это двусторонний контракт, а v1.8.5 и v1.8.14 показывают, как выглядит поломка каждой из сторон: один релиз, где накладка проглотила системный диалог, и другой, где она украла твои нажатия клавиш. Победившим лекарством для второго стало удаление кода.

Инженерия
Electron
Десктоп
Релизы в CI, дважды
Steven
Steven7 мин. чтения

Релизы в CI, дважды

GeekBye v1.8.4 перенёс сборки релизов в CI для macOS и Windows. Чего changelog не говорит — что мы уже пробовали это однажды, удалили месяц спустя из-за стоимости раннеров и заставили прижиться только со второго раза, потому что к тому моменту скрипт релиза уже работал вручную.

Инженерия
CI/CD
Десктоп
Выпуск тридцати языков без страховочной сетки
Steven
Steven7 мин. чтения

Выпуск тридцати языков без страховочной сетки

GeekBye v1.8.3 подключил react-i18next и перевёл всё приложение на 30 языков. Часть с библиотекой была рутиной. Интересно то, чего не хватало: ни проверки паритета ключей, ни экстрактора строк — так что зашитый в код английский просочился, в продакшн уехала датская опечатка, а changelog не мог договориться, на скольких языках говорит приложение — 28, 29 или 30.

Инженерия
i18n
Десктоп