Steven
Steven6 분 소요

OCR에서 픽셀로, 폴백을 잃지 않으면서

GeekBye는 스크린샷을 OCR로 읽는 것을 멈추고 실제 이미지를 vision 모델로 보내기 시작했습니다 — 그건 쉬운 부분입니다. 배울 만한 부분은 vision 사용자에 대해 OCR을 통째로 건너뛴 최적화입니다. 그것은 vision 비지원 모델로의 폴백을 조용히 망가뜨렸고, 안전망이 병렬로 돌면서 아무 비용도 들지 않는 버전으로 되돌려야 했습니다.

엔지니어링
AI
데스크톱
GeekBye 릴리스
OCR에서 픽셀로, 폴백을 잃지 않으면서

스크린샷을 찍고 그것에 대해 AI에게 물어볼 수 있게 해 주는 어떤 앱에도, 진짜 갈림길이 하나 있습니다. OCR을 돌릴 수 있습니다 — 텍스트를 기기에서 추출하고, 아주 작은 문자열을 보내고, 그림은 버립니다. 아니면 픽셀을 보낼 수 있습니다 — 실제 이미지를 실어 보내고 vision 가능한 모델이 그것을 읽게 합니다. OCR은 싸고 작지만 텍스트가 아닌 모든 것에 눈이 멉니다: 레이아웃, 다이어그램, 코드의 들여쓰기, 에러 밑의 빨간 물결선. 픽셀은 그 모두를 보존하지만 더 많은 바이트가 들고 볼 수 있는 모델을 요구합니다. GeekBye는 그 갈림길의 OCR 쪽에서 시작해, v1.8.6과 v1.8.7에 걸쳐 픽셀 쪽으로 걸어 넘어왔습니다. 그 전환은 이 이야기의 쉬운 부분입니다. 흥미로운 부분은 그것과 함께 와서 폴백을 망가뜨렸고 되돌려야 했던 최적화입니다.

이전: 텍스트를 읽고, 그림을 버리다

이 갈래에 이르기까지, 스크린샷은 이렇게 답변이 되었습니다: 당신이 캡처 단축키를 누르면, 클라이언트가 OCR을 돌립니다 — macOS에서는 Swift VisionOCR 바이너리를 통한 Apple의 Vision 프레임워크, Windows에서는 PowerShell 스크립트, 둘 다 하나의 IOcrEngine 추상 뒤에 있습니다 — 그리고 추출된 텍스트가 문자열로 감싸여 screenshotText로 백엔드에 보내집니다. 이미지 자체는 기기를 떠난 적이 없었습니다. 그건 효율적이고, 평범한 텍스트의 벽이라면 그걸로 괜찮습니다. 하지만 스크린샷이 그저 텍스트인 경우는 드뭅니다. 두 단 레이아웃의 채용 공고, 다이어그램이 있는 코딩 문제, 들여쓰기가 곧 단서인 스택 트레이스가 달린 에러입니다. OCR은 그 모두를 한 줄 한 줄의 옮겨 적기로 납작하게 만들고, 모델은 당신이 물은 것보다 더 얄팍한 질문에 답합니다.

전환: 픽셀을 보내다 (v1.8.6)

v1.8.6은 다른 경로를 더했습니다. sharp 위에 지어져 그래서 크로스플랫폼인 새 imageOptimizer.ts는, 날것의 스크린샷에 정확히 세 가지를 합니다:

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

너비 최대 1920px로 리사이즈하고(더 작은 캡처를 결코 확대하지 않음), PNG를 JPEG로 변환, 품질 80. 출력은 미디어 타입을 별도 필드에 실은 날 base64이고, 그것은 옛것과 나란한 두 개의 새 요청 필드에 실려 백엔드로 갑니다: screenshotText와 독립적인 screenshotImagescreenshotImageMediaType입니다. 백엔드는 이제 이미지를, 텍스트를, 또는 둘 다를 받을 수 있습니다.

그 최적화 단계는 겉치레가 아닙니다. 날것의 스크린샷 PNG는 3–8 MB이고; 리사이즈된 JPEG는 대략 200–500 KB입니다. 그건 업로드에서 한 자릿수를 덜어 내고, 모델의 청구서도 줄입니다 — vision 모델은 이미지를 그 해상도로 타일 나누고 타일마다 과금하므로, 너비를 1920으로 상한 두고 다시 압축하는 것은 회선 위의 바이트와 당신이 값을 치르는 이미지 토큰을 둘 다 곧장 줄입니다. 화면 텍스트의 가독성에는 의미 있는 손실 없이.

앱은 모델이 볼 수 있는지 없는지를 애초에 어떻게 알까요? 백엔드가 그것을 알립니다. /api/config 응답에는 플랜을 키로 하는 ai.vision 블록이 생겼고, 클라이언트는 config를 가져올 때마다 하나의 불리언 — aiSupportsVision — 을 캐시합니다. vision이 지원되면 이미지를 최적화하고; 아니면 OCR로 폴백합니다. 그러니까 _능력_은 백엔드의 사실이고 클라이언트 쪽에 캐시되며, OCR을 돌릴지 건너뛸지의 _스크린샷별 결정_은 그 플래그로부터 클라이언트에서 내려집니다. 어느 모델이 실제로 답하는지는 여전히 백엔드가 쥐고 있습니다.

너무 영리했던 최적화

여기서 배울 게 많아집니다. vision 경로의 첫 판본은 OCR과 이미지 최적화를 함께, 병렬로 돌렸습니다. 그러다 "perf" 커밋이 그것을 양자택일로 조여 놓았습니다: 플랜이 vision을 지원하면 이미지를 최적화하고 OCR을 통째로 건너뛰고; 아니면 OCR을 돌리고 이미지 작업을 건너뜁니다. 종이 위에서 이건 뻔한 승리입니다 — vision 모델이 필요로 하지 않는 텍스트를 왜 추출하나? changelog마저 그것을 광고합니다: "OCR is skipped when the AI can read the image directly."

문제는 폴백이라는 말입니다. 백엔드는 모델을 하나만 가진 게 아닙니다; 프라이머리와 폴백을 가지고, 그 폴백은 vision 비지원 모델일 수 있습니다. 이 순서를 그려 보세요: 당신 플랜의 프라이머리 모델은 vision을 지원하므로, 클라이언트는 자랑스럽게 OCR을 건너뛰고 이미지만 보냅니다. 프라이머리 제공자가 딸꾹질을 합니다. 백엔드는 텍스트 전용 모델로 폴백합니다 — 그 모델은 이제 읽을 수 없는 이미지와 _텍스트 없음_을 건네받습니다. 클라이언트가 텍스트를 최적화로 없애 버렸으니까요. 빠른 경로가 조용히 안전망을 지워 버렸던 겁니다. 남아도는 빠른 경로는 안전하게 버릴 수 있습니다; 하지만 그 빠른 경로가 돌아가기 위한 지름길이었던 바로 그것은 버릴 수 없습니다.

해결책: 프라이머리가 아니라 폴백에 걸어라

이 해결책은 훔쳐 갈 만한 부분입니다. 늘 OCR을 돌리던 방식으로 되돌아가는 게 아니었습니다 — 그건 흔한 경우의 승리를 내다 버립니다. 그것은 건너뛰기를 두 번째 capability 플래그에 조건 거는 것이었습니다: _폴백_이 vision을 지원하나? 백엔드의 config에 fallbackVision 블록이 생겼고; 클라이언트는 첫 플래그 옆에 aiFallbackSupportsVision을 캐시합니다. 이제 결정에는 네 갈래가 있습니다:

  • 프라이머리도 폴백도 본다 → 이미지를 최적화하고 OCR을 건너뛴다. 빠르고 흔한 경로.
  • 프라이머리는 보지만 폴백은 눈이 멀었다 → 이미지를 최적화하고 그리고 OCR을 돌린다. 폴백을 위한 보험으로.
  • 이미지 최적화가 예외를 던진다 → 최후의 폴백으로서의 OCR.
  • 플랜에 vision이 아예 없다 → OCR만.

그리고 두 번째 갈래 — 보험의 경우 — 가 마지막 수가 중요해지는 곳입니다. 이미지 최적화 뒤에 OCR을 돌리면 그 지연이 위에 그대로 더해집니다. 그래서 둘은 하나의 Promise.all 아래서 함께 돕니다: 이미지 최적화와 OCR이 겹치고, 백엔드가 필요로 할 경우에만 존재하는 그 OCR은 어차피 이미지가 최적화되고 있던 그 시간 안에 끝납니다. 안전망은 쓰이지 않는 한 벽시계로는 공짜입니다. 그 요령 전부를 한 문장으로: 싼 폴백을 지키고, 당신의 폴백 제공자가 그것을 실제로 필요로 하는지에 걸고, 병렬화해서 그것이 당신을 구해 주는 날까지는 아무 비용도 들지 않게 하라.

건너뛰기가 튕겨 낸 더 작은 두 함정

미뤄 둔 작업을 공격적으로 건너뛰면, 그 작업이 일어나리라고 조용히 가정했던 모든 곳을 표면으로 끌어내는 버릇이 있습니다. 같은 주 PR 리뷰에서 둘이 나왔습니다.

큐 데드락. 스크린샷 큐는 항목이 OCR 텍스트를 가졌을 때 그것을 "준비됨"으로 여겼습니다. vision 스크린샷은 이제 — 설계상 — OCR 텍스트를 결코 얻지 못하므로, 완전함 검사 hasIncompleteOCR은 영원히 기다렸고 큐는 멈췄습니다. 해결책은 "준비됨"을 _최적화된 이미지를 가짐 또는 OCR 텍스트를 가짐_으로 다시 정의합니다. 그것이 파이프라인이 이제 실제로 보장하는 것입니다.

낡은 capability 플래그. aiSupportsVision은 캐시되는데, 그것은 정확히 잘못된 방향으로 낡을 수 있다는 뜻입니다: 사용자가 로그아웃하거나 config 가져오기가 실패하면, 더 이상 이미지를 지원하지 않는 모델에 여전히 이미지를 보내도 된다고 믿은 채로 클라이언트가 남겨집니다. 플래그는 이제 로그아웃 때와 config 가져오기 실패 때 리셋됩니다 — 캐시된 능력은, 그것이 기술하는 대상이 사라졌을 때를 위한 사연을 가지고 있어야 합니다.

숫자에 관해 한마디

정직함이 이 시리즈의 핵심이니 말하자면: 커밋 메시지에는 수치가 실려 있습니다 — 이미지 최적화 "~30ms" 대 OCR "~100–150ms", 페이로드 "~200–500 KB vs ~3–8 MB PNG" — 그리고 그럴듯하긴 하지만, 벤치마크의 산출물이 아니라 작성자 자신의 인라인 추정치입니다. 측정된 끝에서 끝까지의 전후 지연은 히스토리 어디에도 없습니다. 속도 주장은 증명된 결과가 아니라 건전한 설계 의도로 받아들이세요. 페이로드 크기 감소는 당신이 믿을 수 있는 하나의 숫자입니다. 그건 리사이즈와 재압축에서 곧장 떨어지기 때문입니다.

이 릴리스가 우리에게 가르쳐 준 세 가지

  1. 픽셀을 보내는 것이 텍스트를 읽는 것을 이긴다 — 모델이 볼 수 있을 때. OCR은 스크린샷이 문자 말고 전하는 모든 것을 버린다. vision 모델은 레이아웃을, 다이어그램을, 들여쓰기를 읽고, 최적화된 JPEG가 그걸 감당할 만하게 만든다. 하지만 그건 능력에 건 내기이고, 그래서 폴백이 필요하다.
  2. 빠른 경로는 최적화로 없앨 수 있어도, 안전망은 결코 안 된다. vision 사용자에 대해 OCR을 건너뛴 것은 프라이머리에는 옳고 폴백에는 틀렸다. 버그는 건너뛰기가 아니었다; 정작 중요한 게 폴백의 능력일 때 프라이머리의 능력에 근거해 건너뛴 것이었다.
  3. 공짜 안전망은 병렬화된 안전망이다. 보험 작업을 그것이 실제로 필요한지에 걸고, 그다음 빠른 경로와 나란히 돌려서 겹치게 하라. 제대로 하면, graceful degradation은 그것이 당신 기능이 아직 작동하는 유일한 이유가 되는 날까지 아무 비용도 들지 않는다.

v1 이야기의 이전 장은 클릭 통과 오버레이의 두 가지 실패 모드(v1.8.5)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.

관련 글

클릭 통과 오버레이의 두 가지 실패 모드
Steven
Steven7 분 소요

클릭 통과 오버레이의 두 가지 실패 모드

GeekBye의 창은 모든 것 위에 떠 있으면서, 버튼이 있는 곳을 빼고는 당신의 클릭이 그것을 그대로 통과하게 둡니다. 그것은 양면 계약이고, v1.8.5와 v1.8.14는 각 면이 깨질 때 어떤 모습인지를 보여 줍니다: 한 릴리스에서는 오버레이가 시스템 대화상자를 삼켰고, 다른 하나에서는 당신의 키 입력을 훔쳤습니다. 두 번째를 이긴 해결책은 코드를 지우는 것이었습니다.

엔지니어링
Electron
데스크톱
릴리스를 CI에 넣다, 두 번
Steven
Steven6 분 소요

릴리스를 CI에 넣다, 두 번

GeekBye v1.8.4는 릴리스 빌드를 macOS와 Windows 모두 CI로 옮겼습니다. 변경 로그가 말하지 않는 것은, 이 일을 이전에 한 번 시도했다가 러너 비용 때문에 한 달 뒤 지워 버렸고, 두 번째에서야 자리 잡게 했다는 사실입니다 — 그때쯤엔 릴리스 스크립트가 이미 손으로 잘 돌아가고 있었기 때문입니다.

엔지니어링
CI/CD
데스크톱
안전망 없이 서른 개 언어를 출시하다
Steven
Steven6 분 소요

안전망 없이 서른 개 언어를 출시하다

GeekBye v1.8.3은 react-i18next를 배선하고 앱 전체를 30개 언어로 번역했습니다. 라이브러리를 붙이는 일은 판에 박힌 작업이었죠. 흥미로운 부분은 빠져 있던 것입니다: 키 정합성 검사도, 문자열 추출기도 없었기에 — 하드코딩된 영어가 새어 나갔고, 덴마크어 오타가 출시됐으며, 변경 로그는 이 앱이 28개인지 29개인지 30개 언어를 말하는지 자기들끼리도 합의하지 못했습니다.

엔지니어링
i18n
데스크톱