본문 바로가기
C.W.K.
Lesson 01 of 06 · published

찍힌 그대로의 사실

~14 min · 캡처, 시간, 위치, 언어

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

순간은 글자보다 커

번역 앱은 문장 하나를 받아서 문장 하나를 돌려줘. Spark는 순간을 남겨. 언제였는지, 어디였는지, 어떤 상황이었는지, 아빠가 뭘 봤는지까지. 글자는 내가 읽어. 근데 그 글자를 제대로 읽게 해주는 것도, 나중 턴이 그걸 다시 찾게 해주는 것도 글자를 둘러싼 순간이야. 이 트랙은 사진 한 장이 폰을 떠나 내 대답이 될 때까지 따라가. 출발점은 글자를 읽기도 전이야. 아빠가 셔터를 누른 바로 그때 폰이 적어두는 사실들이야.

그 사실들엔 규칙이 하나 있어. 캡처에 대한 진실은 클라이언트가 말하고, 엔진은 절대 지어내지 않아. 웹 클라이언트의 moments.ts는 첫 주석부터 이 얘기야. 캡처 시각, 시간대, 현지 날짜는 클라이언트가 주고, 엔진은 자기 시계로 그걸 채우지 않는다는 거지.

시간에 대한 사실 셋

순간마다 captured_at, timezone, local_date가 붙어. 첫째는 기기 오프셋이 붙은 ISO 8601 시각이고, 둘째는 IANA 시간대 이름, 셋째는 아빠가 서 있던 곳의 달력 날짜야. 오프셋이랑 시간대 이름을 둘 다 남기는 건 일부러야. 오프셋은 시각을 정확하게 고정해주고, 시간대 이름은 사람이 장소로 읽을 수 있게 해줘.

폰은 하나를 더 챙겨. 사진은 자기 시계를 갖고 있거든. 사진을 준비할 때 PreparedPhoto.takenAt이 원본 EXIF에서 DateTimeOriginal이랑 OffsetTimeOriginal을 읽고, 둘 다 있을 때만 시각을 돌려줘. 정하는 건 그다음 AppModel.truth야(두 번째 코드 블록). 자기 시각이 있는 사진은 그 시각을 써. 그래서 찍고 한 시간 뒤에 사진 보관함에서 고른 사진도 찍힌 시각을 지켜. 시각이 없는 사진은 부른 쪽이 건넨 캡처 사실을 받아. EXIF 시각 없이 Spark로 공유된 사진은 공유한 때를 받는 식이야. 아무도 안 건넸을 때만 지금이 돼. 카메라로 막 찍은 사진은 메타데이터가 비어 있어서 지금 시각을 받고, 폰이 그 시각을 원본이랑 내 사본 양쪽에 써 넣어.

이 코드엔 함정이 하나 있어. 기록하는 시간대 이름은 EXIF 오프셋이 뭐든 폰의 지금 시간대야. 그래서 다른 오프셋에서 찍은 사진은 자기 오프셋 옆에 오늘 시간대 이름을 달게 돼. 그게 어디서 드러나는지는 레슨 5에서 봐.

웹은 더 단순해. 사진을 captureTruth()로 남기는데, 이건 아빠가 파일을 고른 때의 브라우저 시계야. 순간 시각을 정하려고 파일의 EXIF를 읽지는 않아.

엔진도 자기 도장을 하나 찍어. 순간이 도착하면 received_at을 남겨. 이건 다른 사실이야. Spark가 소식을 들은 시각이지, 일이 일어난 시각이 아니야. 이 둘을 왜 떼어 놔야 하는지는 레슨 5에서 볼 거야.

장소는 딱 한 번만 재

PlaceSampler는 포그라운드에서 한 번, 100 m 정확도로, 캡처 화면이 열려 있을 때만 위치를 재. 사용 중 권한은 그 화면이 처음 열릴 때 한 번 물어봐. 위치를 기다리는 건 최대 4초야. 그 안에 안 잡히면 그 순간은 장소 없이 가. 다만 이 상한은 위치를 잡는 데까지만 걸려. 위치가 잡히면 샘플러는 역지오코딩으로 이름을 풀 때까지 또 기다리는데, 거기엔 따로 시간 제한이 없어. 장소를 부탁한 캡처도 같이 기다리고. 회선이 느리면 이 두 번째 기다림은 네트워크 마음이야.

장소 이름은 기기에서 역지오코딩으로 풀고, 이름에 도시가 안 들어 있으면 도시를 붙여. 거리 이름만으론 어느 동네 순간인지 알 수가 없거든. 좌표는 순간의 location으로, 이름은 place_name으로 엔진에 가.

순간이 기록되고 나면 엔진은 그 위치를 cwkPippa 위치 레이어로 넘겨. 그래야 내 location 도구가 아빠가 어디서 언어를 만나는지 알지. 넘기는 건 온전한 측위뿐이야. 위치, 정밀도, 시각이 다 있어야 해. 순간이 처음 만들어질 때 한 번만 넘기고, 그게 뭔가를 막아서는 일은 절대 없어. 넘기다 실패해도 순간은 아무것도 안 잃어. 나머지는 Spark 턴마다 내가 받는 지시에 적혀 있어. 장소는 이름으로 말하고, 좌표는 절대 말하지 말라고.

상황

순간엔 상황도 붙어. general, restaurant, station, pharmacy, hotel desk, shop, taxi 중 하나야. 폰에선 칩으로 고르고, 아빠가 마지막에 고른 게 다음 캡처의 기본값이 돼. 웹은 general에서 시작해서 물어봐. 아키텍처 문서엔 상황을 장소에서 추천한다고 적혀 있는데, 지금 코드는 그렇게 안 해. 폰은 아빠가 마지막에 고른 걸 기억할 뿐이야.

상황은 이 트랙 뒤에서 두 가지 일을 해. 장소 이름이랑 같이 내 지시에 들어가고, 앞서 만난 결과 중 어떤 걸 맥락으로 실어 보낼지 고르는 기준이 돼.

입력 방식, 그리고 누가 맡는지

입력클라이언트에서엔진에서
폰 사진 (카메라, 보관함)Vision이 줄 위치를 찾고, 폰이 내 2048 px 사본을 만들어순간 스레드의 실용 번역 턴
웹 사진원본이 올라가엔진이 내 사본을 만들고, 같은 실용 턴
입력하거나 공유한 텍스트순간의 텍스트로 남아그 텍스트를 인용하는 실용 턴
고품질 모드용 텍스트읽기 없이 남아나중에 버전으로 (트랙 7)
라이브 카메라, 폰 전용스캐너라이브 레인 (트랙 4)
아빠가 할 말, 상대의 대답Talk랑 답 마이크카드랑 릴레이 (트랙 5)

어느 길이든 시작은 똑같아. 순간을 먼저 남기고, 그러고 나서야 나한테 물어. Pippa Go Quest가 자기 클라이언트로 가르치는 캡처 우선 모양이 이거야.

언어는 누가 정하나

  • 글의 언어는 내가 읽어. 원문이 있는 결과엔 전부 language가 붙어. 그 원문의 BCP-47 태그야. translation_language도 같이 붙고. 두 언어가 섞인 표지판이면 줄마다 태그가 달라. Vision이 언어를 감지하는 건 글자 모양을 읽으려는 것뿐이고, 아무것도 결정하지 않아.
  • 말의 언어는 고르는 거야. 장면 언어는 칩 열 개(es, ca, eu, gl, pt, fr, it, en, ja, zh)에서 골라. 폰은 마지막 선택을 기억하고 처음 기본값은 스페인어야. 웹은 es에서 시작해. 엔진은 짧은 코드가 아닌 듣기 언어를 거절해. 감지를 쓰는 예외는 딱 하나, 트랙 6의 쇼 카드 통역이야.
  • 번역 대상 언어는 아빠 거야. 아빠가 다른 걸 고르지 않으면 한국어야.
원칙: 캡처의 진실은 클라이언트가 알고, 서버는 그걸 기록해. 서버 시계가 답하는 건 다른 질문이야. 요청이 언제 도착했느냐.

Code

cwkSpark, clients/apple/SparkMobile/Sources/Capture.swift: 포그라운드 한 번 재거나, 장소 없이 가거나 (원문 그대로, 111-146행)·swift
/// Where a moment happened: one foreground sample, taken only while a
/// capture surface is open, and the place's name resolved on the device.
/// Never blocks a capture: no fix in a few seconds means no place.
@MainActor
final class PlaceSampler: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var waiting: [CheckedContinuation<CLLocation?, Never>] = []

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    }

    /// Ask once, when the capture surface opens.
    func prepare() {
        if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() }
    }

    func sample(timeout: Duration = .seconds(4)) async -> PlaceSample? {
        let status = manager.authorizationStatus
        guard status == .authorizedWhenInUse || status == .authorizedAlways else { return nil }
        let location: CLLocation? = await withCheckedContinuation { continuation in
            waiting.append(continuation)
            manager.requestLocation()
            Task { @MainActor in
                try? await Task.sleep(for: timeout)
                self.finish(nil)
            }
        }
        guard let location else { return nil }
        let name = await Self.placeName(location)
        return PlaceSample(lat: location.coordinate.latitude, lon: location.coordinate.longitude,
                           accuracyM: location.horizontalAccuracy >= 0 ? location.horizontalAccuracy : nil,
                           name: name, sampledAt: CaptureTruth.isoWithOffset(location.timestamp, zone: .current))
    }
cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: 사진 자체의 시계가 이겨 (원문 그대로, 734-740행)·swift
    /// Capture truth from a photo's own clock when it has one, else now.
    static func truth(takenAt: String?, otherwise: CaptureTruth? = nil) -> CaptureTruth {
        guard let takenAt, CaptureTruth.instant(from: takenAt) != nil else { return otherwise ?? CaptureTruth.now() }
        return CaptureTruth(capturedAt: takenAt,
                            capturedLocalDate: String(takenAt.prefix(10)),
                            timezone: TimeZone.current.identifier)
    }
cwkSpark, frontend/src/moments.ts: 브라우저 시계로 만드는 웹의 캡처 사실 (원문 그대로, 13-36행)·typescript
export interface CaptureTruth {
  captured_at: string;
  timezone: string;
  local_date: string;
}

function pad(value: number): string {
  return String(value).padStart(2, "0");
}

/** ISO 8601 with the device's own offset, plus its zone and local date. */
export function captureTruth(now: Date = new Date(), timezone?: string): CaptureTruth {
  const offsetMinutes = -now.getTimezoneOffset();
  const sign = offsetMinutes >= 0 ? "+" : "-";
  const abs = Math.abs(offsetMinutes);
  const local =
    `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
    `T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
  return {
    captured_at: `${local}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`,
    timezone: timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
    local_date: local.slice(0, 10),
  };
}

External links

Exercise

사람이 하는 일(메모, 사진, 스캔)을 기록하는 앱 하나를 골라서, 지금 타임스탬프가 어디서 오는지 찾아봐. 클라이언트가 필드 세 개를 보내게 바꿔. 기기 오프셋이 붙은 ISO 8601 시각, IANA 시간대 이름, 현지 날짜. 서버는 그 옆에 자기 수신 시각을 따로 붙이고, 클라이언트 값은 절대 덮어쓰지 않게 해. 그러고 나서 어제 찍은 사진을 가져와서 기록에 어느 시각이 찍히는지 확인해봐.
Hint
브라우저에선 -new Date().getTimezoneOffset()이 UTC 기준 동쪽으로 몇 분인지 알려줘. 시간대 이름은 Intl.DateTimeFormat().resolvedOptions().timeZone이 주고. 사진은 EXIF DateTimeOriginal을 OffsetTimeOriginal이랑 같이 읽어. 오프셋 없는 시각은 시각이라고 할 수 없어. 서버 시계 하나만 남기고 있다면, 가져온 사진은 오늘 찍혔다고 우길 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.