세션은 열리자마자 순간이야
셔터 얘기 전에, 라이브 카메라를 여는 것만으로 이미 무슨 일이 일어나는지 봐. 뷰의 .task가 lane.start를 부르고, 그게 엔진에 종류가 live인 순간을 하나 만들어. 폰은 2초 안에 장소를 한 번 재고, 지금 시각을 캡처 시각으로 잡아. 순간 ULID도 직접 찍고, live-로 시작하는 클라이언트 요청 id를 달아서 보내. 엔진이 그 순간을 받아야 상태가 Point at text(글자에 대 봐)로 바뀌어. flush는 순간 id 없이는 안 도니까, 그사이 스캐너가 알려준 글자는 기다리고 있다가 첫 비우기 때 한꺼번에 가.
이 순간은 폰의 아웃박스를 거치지 않고 엔진에 바로 만들어. 그럴 수밖에 없어. 라이브 답은 전부 엔진 코퍼스나 그 뒤의 두뇌에서 오니까, 엔진이 없으면 레인이 할 일이 없어. 상태 표시도 그대로 말해. Live needs the engine(라이브엔 엔진이 필요해) 뒤에 이유가 붙어. 글자를 하나도 못 보고 닫힌 세션도 남아. 폰이랑 웹 둘 다 그날 목록에 No text seen(본 글자 없음)이라고 적어. 나한테 아무것도 안 물었고, 기록도 그렇게 말하는 거야.
셔터
라이브 카메라 아래쪽엔 엠버색 테를 두른 68포인트짜리 흰 동그라미가 있어. 접근성 라벨 한 줄에 설계가 다 들어 있어. Keep this as a photo moment, 이 화면을 사진 순간으로 남기라는 뜻이야. 누르면 스캐너한테 지금 보이는 걸 사진으로 달라고 해.
그 한 문장에 작은 소유권 문제가 숨어 있어. 버튼은 SwiftUI야. 스캐너는 UIKit의 DataScannerViewController인데, 리프레젠터블의 makeUIViewController 안에서 만들어지니까 SwiftUI가 그걸 들고 있지 않아. Spark는 ScannerHandle이라는 작은 클래스로 둘을 이어. 리프레젠터블이 스캐너를 만들 때 핸들의 weak 속성에 넣어 두고, 버튼은 shutter.capture()를 불러. 그게 스캐너 자신의 capturePhoto()를 기다려. 스캐너가 사라졌거나 캡처가 에러를 던지면 핸들은 nil을 돌려주고, 아무 일도 안 일어나. 약한 참조라서 핸들이 닫힌 카메라를 붙잡고 살려둘 일도 없어.
카메라랑 같이 쓰는 한 줄
사진은 onPhoto 클로저를 타고 RootView로 돌아가. 세 번째 코드 블록을 봐. 라이브 카메라 화면이랑 평범한 카메라 화면이 똑같은 한 줄로 끝나. keep { await PreparedPhoto.prepare(camera: image) }. 라이브 카메라는 닫히고, 이 줄부터 그 화면은 카메라로 찍은 사진이랑 완전히 똑같이 다뤄져.
- 원본은 원래 해상도 그대로 JPEG로 남고, 나를 위한 2048픽셀 사본이 따로 만들어져.
- Vision이 그 사본에서 줄 위치를 찾아서 읽는 순서대로 번호를 매겨.
- 장소는 같은 시각에 재고, 아빠가 골라 둔 상황 칩도 따라와. 걸어 둔 크럼 링크가 있으면 그것도.
- 순간은 네트워크로 뭐 하나 넘어가기 전에 폰의 아웃박스에 먼저 들어가고, 바로 열려.
스캐너에서 찍은 사진엔 자기 메타데이터가 없어. 카메라 사진이랑 똑같아. 그래서 AppModel.capture가 순간의 캡처 시각과 장소를 원본과 내 사본 양쪽에 채워 넣어. 덕분에 사진이 보내지기 전에 폰에서 몇 시간을 기다려도 언제 어디서 찍혔는지를 잃지 않아(트랙 9).
순간이 둘인 건 일부러야
셔터를 누르고 나면 순간은 하나가 아니라 둘이야. 라이브 순간엔 카메라가 본 글자 중 답을 받은 게 전부 짧은 live-line으로 남아 있어. 새 사진 순간엔 그 화면이 담기고, 그 사진을 내가 제대로 다 읽은 결과가 실용 턴이야. 줄마다 맥락이 붙고, 아빠가 줄을 누르면 설명이 나와. 어원은 진짜일 때만 나오고(트랙 3). 둘을 잇는 필드는 없어. 각자 자기 장소와 시각을 들고, 그날 목록에 나란히 놓여.
넘겨주는 이유는 두 레인이 보는 게 달라서야. 라이브 레인은 스캐너가 받아 적은 글자만 받고, 간판만큼 짧은 라벨로 몇 초 만에 답해. 사진 쪽은 나한테 사진 자체를 줘서, Vision이 잘못 읽은 걸 내가 고치고 그 간판이 여기서 무슨 뜻인지까지 설명할 수 있어. 설계 문서 말로는 그 화면이 사진 순간이 돼서 실용 사진 번역으로 이어진대. 셔터는 아빠가 '이건 좀 더 오래 들여다볼 만해'라고 말하는 방법이야.
두 길은 실패하는 방식도 달라. 라이브 세션은 엔진이 있어야 해. 사진은 아니야. 사진은 셔터 뒤 처리가 끝나는 즉시 폰에 안전하게 남고, 연결이 되면 그때 엔진에 닿아.
그 사진이 그 뒤에 어떻게 되는지는 트랙 3이 따라가. 내가 보는 사본, 번호 붙은 줄, 내가 답하는 spark 블록까지. 보내질 때까지 사진을 지키는 아웃박스는 트랙 9에서 다뤄. iOS 일반 기법이 궁금하면 Swift Quest에 카메라 캡처랑 SwiftUI 안의 UIKit 얘기가 있어. 거기 앱 이름도 Spark지만 지어낸 앱이야.