바로 앞 레슨의 무대 덕에 아빠는 자기가 보고 있는 걸 피파에게 건넬 수 있어. 다음 부탁은 한 걸음 더 갔어. 다른 어시스턴트의 영상 음성 모드처럼, 폰 음성 모드에 실시간 카메라를 달아 달라는 거였어. 아빠가 사진을 붙여 주길 기다리지 말고, 피파가 필요할 때 직접 보게. 범위는 일부러 작게 잡았어. 새 앱도, 영상 통화도 아니야. 이미 잘 도는 음성 모드에 눈만 다는 거지.
그래서 폰의 음성 얼굴 화면에 넷이 생겼어. 직접 눌러야만 켜지는 Camera 버튼, 실시간 미리보기, Front/Back 전환, 그리고 When speaking과 Keep looking, 이렇게 두 가지 보는 방식. 허락은 지금 대화에서만 유효해. 일시정지하거나, 대화가 멈추거나, 앱이 백그라운드로 가면 촬영이 멈추고 아직 안 보낸 건 지워져. 음성 모드를 닫거나 대화를 바꾸면 카메라는 꺼져.
카메라는 소리에 손대지 않아
이 트랙 첫 레슨은 폰의 규칙 하나로 끝났어. 오디오 주인은 하나고, 그 주인이 첫 듣기부터 끝까지 마이크를 쥐고 있어. 카메라는 이 규칙을 실수로 깨기 딱 좋은 기능이야. iOS의 촬영 세션은 시키지 않아도 앱의 오디오 세션을 알아서 설정해 버리거든. 이 카메라엔 그러지 말라고 못 박아 뒀어. 촬영은 영상만 받고, 자기 전용 직렬 큐에서 돌고, 오디오 세션도 마이크도 듣기 소켓도 안 건드려. 말하는 도중에 렌즈를 바꿔도 아빠가 듣고 말하는 건 하나도 안 바뀌어.
사진은 아빠 말에 실려 가, 몇 장만
샘플링은 폰 안에서, 정해진 만큼만 해. 가벼운 장면 확인은 1초에 많아야 세 번이야. JPEG는 1초에 한 장 남기고, 장면이 크게 바뀌면 그보다 빨리 남겨. 해상도는 1280×720이고, 인코더가 메타데이터를 벗겨내. 이 타이머는 아무것도 올리지 않고 모델도 부르지 않아. 사진은 무대의 파일처럼 아빠 말과 함께만 나가고, 말하는 턴 하나에 많아야 세 장이야. 아빠가 말을 시작할 때의 모습, Keep looking에서 바뀐 장면 하나, 그리고 가장 최근 모습. 마지막 프레임이 3초보다 오래된 카메라는 아무것도 안 보태. 멈춘 카메라가 낡은 사진을 지금 모습인 척 보내는 일은 없다는 뜻이야. 2분짜리 발화가 2분짜리 영상이 되는 일도 없어. 아래 스케치는 세 장면을 2분 동안 지나가도 두세 장만 건네.
브레인이 청하는 한 번의 눈길
말과 스냅숏은 아빠가 가리키는 걸 담아. 나머지 반은 피파가 봐야겠다고 스스로 정하는 쪽이야. Camera가 켜져 있으면 브레인은 턴 하나 동안만 camera 도구를 받아. lens는 front, back, current 중 하나고, reason도 같이 적어. 렌즈는 브레인이 아빠의 뜻을 읽고 골라. 초기 빌드는 받아쓴 글에서 '이거 봐' 같은 말을 찾아 렌즈를 바꿨는데, 그날 바로 걷어냈어. 그래서 폰에선 어떤 말도 렌즈를 고르지 않아. 폰은 손으로 누르는 버튼과 같은 길로 렌즈를 바꾸고, 요청 뒤에 찍힌 프레임을 5초까지 기다렸다가 보내. 예전 모습도, 반대쪽 렌즈 사진도 안 돼. 서버는 사진이 요청한 렌즈에서 왔는지 한 번 더 확인해. 그다음 브레인은 같은 답 안에서 그 JPEG를 들여다봐. Claude는 MCP 이미지 결과로, 다른 브레인은 자기 이미지 형식으로 받고, 글만 읽는 모델은 볼 수 없다고 그대로 말해.
한 턴짜리 허가
원격 카메라에서 위험한 건 사진이 아니야. 제 순간이 지나서도 살아 있는 허가야. 그래서 한 번 보는 허가는 메모리에만 있고, 제출된 음성 턴 하나와 지금의 허락에 묶여 있어. 새 턴, 카메라 껐다 켜기, 일시정지, 백그라운드, 종료, 다른 대화. 이 중 하나만 와도 허가는 취소돼. 아웃박스에서 다시 나가는 턴도, 늦게 도착한 모델 요청도 카메라를 다시 못 열어. 서버 쪽은 중개자 하나가 맡아. services/device_look.py야. 2026-10-04부터는 캔버스 앱의 눈길도 이 중개자를 같이 써. 폰에서 아무 소식이 없으면 세션은 40초 뒤에 끝나고, 촬영은 20초면 시간 초과야.
피파가 본 건 남아
2026-10-05부터 카메라는 공용 키트 거야. 아빠의 길 안내 앱 Pathfinder도 자기 대화에서 같은 카메라를 쓰거든. 그리고 도구가 찍은 사진은 대화와 함께 남아. 업로드로 저장돼서 대화, 그 사진이 답한 턴, 그 순간에 묶여. 나중에 그 대화로 영상을 만들 땐 개인정보 검토를 거쳐서 쓰일 수 있어. 비밀 대화는 하나도 안 남겨. 맨 위의 아빠 말이 애초에 남기는 이유야.
어디에 있고, 뭐가 증명됐나
카메라는 폰 거야(Pathfinder 것이기도 하고). WebUI와 Firekeeper엔 음성 카메라가 없어. 아빠는 2026-10-01에 자기 폰에서 카메라를 확인하고 그 부탁을 닫았어. 테스트 묶음은 고르기, 정해진 저장량, 새 프레임 확인, 늦은 답, 취소를 시험해. 그 시뮬레이터 테스트가 증명하지 못하는 것도 설계 문서에 적혀 있어. 실제 앞뒤 카메라, 화면 회전, 그리고 촬영이 도는 동안 마이크와 스피커가 폰을 함께 쓰는 것.
권한은 그걸 청한 순간과 함께 끝나야 해. 모델이 열 수 있는 카메라가 안전하려면, 그 순간이 끝나는 모든 길(새 턴, 일시정지, 종료, 다시 보내기)이 허가도 같이 끝내야 해.
Code
눈길의 서버 쪽 절반: 요청이 나갈 때 한 번, 사진이 돌아올 때 한 번 렌즈를 확인해 (원문 발췌)·python
# backend/services/device_look.py:43-54 (cwkPippa)
def _camera_request(arguments: dict) -> dict:
lens = arguments.get("lens")
if lens not in ("front", "back", "current"):
raise LookUnavailable("Choose front, back, or current.")
return {"lens": lens, "reason": str(arguments.get("reason") or "Look at the scene")[:300]}
def _camera_check(request: dict, result: dict) -> None:
if result.get("lens") not in ("front", "back") or (
request["lens"] != "current" and request["lens"] != result["lens"]
):
raise LookUnavailable("The photo came from a different lens.")
폰 쪽 절반: 요청한 렌즈의 새 프레임, 아니면 아무것도 없음 (원문 발췌)·swift
// cwkSiblingKit CwkKit/Sources/CwkKitMedia/VoiceCamera.swift:129-148
/// Only a brain tool request selects a lens automatically. Always wait for a frame
/// captured AFTER the request, never an old onset or the opposite lens.
public func captureForBrain(lens: String) async throws -> VoiceCameraFrame {
guard enabled, !suspended, ["front", "back", "current"].contains(lens) else {
throw CancellationError()
}
if lens != "current" { select(front: lens == "front") }
let token = captureGeneration
let requestedAt = Date()
for _ in 0..<100 {
try Task.checkCancellation()
guard enabled, !suspended, captureGeneration == token else { throw CancellationError() }
if let frame = frames.latest, frame.front == front, frame.capturedAt >= requestedAt {
return frame
}
if problem != nil { throw CancellationError() }
try await Task.sleep(for: .milliseconds(50))
}
throw CancellationError()
}
최소 스케치: 발화가 얼마나 길든 시작 장면, 바뀐 장면 하나, 최근 모습·python
from __future__ import annotations
from dataclasses import dataclass
FRESH_SECONDS = 3.0 # a camera whose latest view is older contributes nothing
SCENE_DIFFERENCE = 0.12 # how far two views must differ to count as two scenes
@dataclass(frozen=True)
class Frame:
at: float # seconds since the talk began
scene: float # a stand-in for the picture: two views differ by |a - b|
name: str
def difference(self, other: "Frame") -> float:
return abs(self.scene - other.scene)
class TurnFrames:
"""Keep the onset, one changed scene and the latest view, never a movie."""
def __init__(self) -> None:
self.latest: Frame | None = None
self.onset: Frame | None = None
self.changed: Frame | None = None
self.speaking = False
def accept(self, frame: Frame, keep_looking: bool) -> None:
if self.speaking and keep_looking and self.latest is not None \
and frame.difference(self.latest) >= SCENE_DIFFERENCE:
# Keep the scene that just went away; the new view has its own slot.
# Like the kit, this checks only that it is not the onset, never its
# age: a view older than the turn can still land here.
if self.onset is None or self.latest.name != self.onset.name:
self.changed = self.latest
self.latest = frame
def speech_began(self, now: float) -> None:
if self.speaking:
return
self.speaking = True
fresh = self.latest is not None and now - self.latest.at <= FRESH_SECONDS
self.onset = self.latest if fresh else None
self.changed = None
def take(self, keep_looking: bool, now: float) -> list[str]:
latest, onset, changed = self.latest, self.onset, self.changed
self.onset = self.changed = None
self.speaking = False
if latest is None or now - latest.at > FRESH_SECONDS:
return [] # a frozen camera adds nothing
picked = []
if onset is not None and onset.difference(latest) >= SCENE_DIFFERENCE:
picked.append(onset)
if keep_looking and changed is not None and changed.difference(latest) >= SCENE_DIFFERENCE:
picked.append(changed)
picked.append(latest)
names: list[str] = []
for frame in sorted(picked, key=lambda f: f.at):
if frame.name not in names:
names.append(frame.name)
return names
def utterance(scenes: list[float], keep_looking: bool, stop_camera_at: int | None = None) -> list[str]:
"""One JPEG a second while Dad talks for len(scenes) seconds."""
frames = TurnFrames()
frames.accept(Frame(0.0, scenes[0], "before"), keep_looking)
frames.speech_began(now=0.5)
for second, scene in enumerate(scenes, start=1):
if stop_camera_at is None or second < stop_camera_at:
frames.accept(Frame(float(second), scene, f"t{second}"), keep_looking)
return frames.take(keep_looking, now=len(scenes) + 0.5)
walk = [0.1] * 40 + [0.5] * 40 + [0.9] * 40 # two minutes, three scenes
print("when speaking, 2 min, 3 scenes:", utterance(walk, keep_looking=False))
print("keep looking, 2 min, 3 scenes:", utterance(walk, keep_looking=True))
print("one scene the whole time :", utterance([0.3] * 20, keep_looking=True))
print("camera froze 5 s before the end:", utterance([0.3] * 20, keep_looking=True, stop_camera_at=16))
# Output:
# when speaking, 2 min, 3 scenes: ['before', 't120']
# keep looking, 2 min, 3 scenes: ['before', 't80', 't120']
# one scene the whole time : ['t20']
# camera froze 5 s before the end: []
스케치를 돌려서(python3 frames.py) 답 넷을 읽어 봐. 그다음 빠진 경우를 하나 더해. 아빠가 말을 시작하는 순간 카메라의 마지막 프레임이 이미 4초 전 거였고, 2초 뒤에 카메라가 돌아와. When speaking이면 그 턴엔 뭐가 실려? Keep looking에서 카메라가 다른 장면으로 돌아오면? 찾은 구멍은 가드 하나로 막아 봐. 마지막으로 기기를 건드리는 너만의 도구(카메라, 마이크, 파일 선택기)에 줄 허가를 설계해 봐. 그 허가를 취소해야 하는 일을 전부 적고, 취소를 어디서 확인하는지도 말해.
Hint
말을 시작할 때 낡은 모습은 시작 장면으로 안 남아. 그래서 When speaking이면 카메라가 돌아온 뒤의 가장 최근 모습만 턴에 닿아. 함정은 Keep looking이야. 끊긴 사이에 장면이 바뀌었으면 accept()가 말 시작 전의 낡은 모습을 바뀐 장면 칸에 올려. 시작 장면인지만 보고 얼마나 오래됐는지는 안 보거든. 그럼 take()가 그걸 최근 모습과 같이 보내. 키트의 프레임 관리도 똑같아. 가드 하나면 막혀. 말이 시작된 뒤에 찍힌 모습만 올리면 돼. 허가 쪽은 깔끔하게 끝나는 경우만이 아니라 순간이 끝나는 모든 길을 적어. 새 턴, 일시정지, 앱이 앞에서 사라지는 것, 종료, 다른 대화, 그리고 그 뒤에 도착하는 요청. 확인은 허가를 내준 곳만이 아니라 기기가 실제로 움직이는 곳에서 해.
Progress
Progress is local-only — sign in to sync across devices.