~14 min · beyond-the-app, camera, uiimagepickercontroller, images, privacy
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"긴 변 2048픽셀로 줄이니 같은 턴이 75초에서 13.6초가 됐어."
파일 선택기가 아니라 담는 도구
네이티브 앱이 웹 페이지보다 나은 점 하나는 카메라를 담는 도구로 쓸 수 있다는 거야. 사진첩을 뒤지게 만드는 파일 선택기 대신, 누르고 찍으면 바로 사진이 붙어. 가족 앱 세 개가 카메라를 감쌌는데, 감싸는 방식은 셋 다 같았어. 카메라 소스로 설정한 UIImagePickerController를 일부러 쓰는 거야. 시스템 카메라엔 사람들이 이미 익숙한 플래시, 앞뒤 카메라 전환, 초점, 셔터가 있거든. 서로 달랐던 건 정책이었어. 그래서 공유 감싸개는 정책을 호출하는 쪽에 맡겨. 호출자가 사진만 받을지 사진과 영상을 받을지 고르고, 사진이나 영상 URL이나 취소면 nil을 받고, 자기 시트는 자기가 닫아. 그러면 시트 상태를 쥔 곳이 하나뿐이야. 시뮬레이터처럼 카메라가 없는 곳에선 죽지 않고 사진첩으로 넘어가. 카메라엔 NSCameraUsageDescription이 필요하고, 소리까지 녹화하려면 마이크 문구도 필요해.
원본 크기 사진의 값
폰 사진은 몇 메가바이트에 긴 변이 대략 4000픽셀이고, 받는 사람한테 그 대부분은 쓸모가 없어. 코딩 엔진이 자기 비전 턴에서 재봤어. 4.1 MB 사진이 비전 토큰 약 13,000개와 프롬프트 평가 50.7초를 먹었고, 긴 변 2048픽셀로 줄이니 같은 턴이 75초에서 13.6초가 됐어. 공유 축소 코드는 그 숫자를 쓰고, 목표 크기는 테스트가 붙은 순수 함수로 계산하고, 더 작은 이미지는 절대 키우지 않아.
다시 인코딩하면 위치가 빠져. 일부러 그러는 거야
이미지를 새 비트맵에 그리고 JPEG로 인코딩하면 원본의 EXIF 메타데이터가 빠져. 촬영 위치도 함께. 가족 코드에선 그게 부작용이 아니라 핵심이야. 병원에서 찍은 사진엔 병원 좌표가 붙어 있어. 그 사진을 엔진으로 보내는 앱이 그러기로 정하지도 않았는데 좌표까지 딸려 보내면 안 돼. 메타데이터가 정말 필요한 앱은 분명한 이유를 가지고 일부러 다른 곳에 보관해. 같은 본능이 공유 확장 안에선 반대 방향으로 작동해. 확장은 메모리 한도가 빠듯해서, 거기선 파일을 해독하지 않고 그대로 복사하고, 축소는 나중에 앱에서 해.
찍은 사진이 가는 곳
찍은 사진은 따로 된 메시지가 아니라 담기의 일부야. 앱은 줄인 JPEG를 담기 id로 된 그 담기 전용 폴더에 써. 그리고 항목의 올릴 미디어 목록에 넣으면서 소유한 미디어 목록에도 적어두고, 네트워크가 연결되면 아웃박스가 보내게 둬. 항목 처리가 끝나면 앱은 그 소유 목록을 보고 파일을 지워. 여행 일지는 사진이 폰에 영영 남는 대가를 치르고서야 이 교훈을 배웠어. 영상도 똑같이 다루는데, 다시 인코딩하지 않고 녹화된 그대로 복사하는 것만 달라.
Code
작은 SwiftUI 감싸개 뒤의 시스템 카메라, 정책은 호출자 몫·swift
import SwiftUI
import UIKit
import UniformTypeIdentifiers
enum CaptureResult {
case photo(UIImage)
case movie(URL)
}
/// The system camera: flash, lens switch, focus and shutter the person already knows.
/// Policy stays with the caller; this view never dismisses itself.
struct CameraCapture: UIViewControllerRepresentable {
var allowsVideo = false
let onCapture: (CaptureResult?) -> Void // nil means cancelled
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
// A simulator has no camera: fall back to the library instead of crashing.
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary
picker.mediaTypes = allowsVideo ? [UTType.image.identifier, UTType.movie.identifier] : [UTType.image.identifier]
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(onCapture: onCapture) }
@MainActor
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onCapture: (CaptureResult?) -> Void
init(onCapture: @escaping (CaptureResult?) -> Void) { self.onCapture = onCapture }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let url = info[.mediaURL] as? URL {
onCapture(.movie(url))
} else if let image = info[.originalImage] as? UIImage {
onCapture(.photo(image))
} else {
onCapture(nil)
}
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { onCapture(nil) }
}
}
extension ImageDownscale {
/// Re-encoding strips EXIF, including location. That is the point, not a side effect.
static func jpegData(_ image: UIImage, quality: CGFloat = 0.85) -> Data? {
let size = targetSize(for: image.size)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let resized = UIGraphicsImageRenderer(size: size, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
return resized.jpegData(compressionQuality: quality)
}
}
폰 밖으로 나가는 사진의 긴 변을 테스트된 순수 함수로 계산하기·swift
import CoreGraphics
enum ImageDownscale {
/// The long edge a photo leaves the phone with. Never enlarges.
static let longEdge: CGFloat = 2048
static func targetSize(for size: CGSize, longEdge: CGFloat = longEdge) -> CGSize {
let longest = max(size.width, size.height)
guard longest > longEdge, longest > 0 else { return size }
let scale = longEdge / longest
return CGSize(width: (size.width * scale).rounded(), height: (size.height * scale).rounded())
}
}
// targetSize(for: 4032 x 3024) -> 2048 x 1536
// targetSize(for: 3024 x 4032) -> 1536 x 2048
// targetSize(for: 1200 x 900) -> 1200 x 900 (never enlarged)
SparkMobile 작성 화면에 카메라 버튼을 두고, CameraCapture를 시트로 띄워서 어떤 결과가 나오든 시트를 스스로 닫게 해. 시뮬레이터에서 한 번, 기기에서 한 번 돌려서 각각 어떤 소스를 썼는지 적어. CameraCapture가 돌려주는 이미지엔 메타데이터가 아예 없어서, 메타데이터 제거는 파일로 들어오는 사진에서 의미가 있어. 위치를 켜고 카메라 앱으로 사진을 찍어서 파일 앱에 저장하고, Mac에서 위치가 붙어 있는지 먼저 확인해. 그 파일을 UIImage로 불러와 ImageDownscale.jpegData로 보내고, 결과를 옆에 저장해서 두 파일의 픽셀 크기와 메타데이터를 비교해.
Hint
Mac에선 mdls -name kMDItemLatitude -name kMDItemPixelWidth <파일>이 위치가 있는지와 폭이 얼마인지 보여줘. targetSize(for:) 테스트는 가로 사진, 세로 사진, 이미 작은 사진으로 써.
Progress
Progress is local-only — sign in to sync across devices.