Skip to content
C.W.K.
Stream
Lesson 06 of 06 · published

The Camera as a Capture Surface, and What a Photo Costs

~14 min · beyond-the-app, camera, uiimagepickercontroller, images, privacy

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"2048 pixels on the long edge took the same turn from 75 seconds to 13.6."

A Capture Surface, Not a File Picker

One of the things a native app buys over a web page is the camera as a capture surface: tap, shoot, and the photo is attached, instead of a file picker that sends the person hunting through their library. Three family apps wrapped the camera and agreed on the wrapper: UIImagePickerController with the camera source, on purpose, because the system camera already has the flash, the lens switch, focus and the shutter the person knows. What they disagreed on was policy, so the shared wrapper keeps policy with the caller. The caller chooses stills or stills and video, receives a photo, a movie URL or nil for cancel, and dismisses its own sheet, so the sheet state has one source of truth. Where there is no camera, as in a simulator, it falls back to the photo library instead of crashing. The camera needs NSCameraUsageDescription, and recording video with sound needs the microphone string as well.

What a Full-Size Photo Costs

A phone photo is several megabytes and roughly 4000 pixels on its long edge, and most of that never helps the person who receives it. The coding engine measured it on its own vision turns: a 4.1 MB photo cost about 13,000 vision tokens and 50.7 seconds of prompt evaluation, and downscaling to 2048 pixels on the long edge took the same turn from 75 seconds to 13.6. The shared downscale uses that number, computes the target size as a pure function the suite tests, and never enlarges a smaller image.

Re-Encoding Removes the Location, On Purpose

Drawing the image into a new bitmap and encoding a JPEG strips the original's EXIF metadata, including where it was taken. In the family's code that is the point, not a side effect: a photo taken at a clinic carries the clinic's coordinates, and an app that sends it to an engine should not send those along without deciding to. An app that genuinely needs the metadata keeps it deliberately, somewhere else, with its own reason. The same instinct applies in the other direction inside a share extension, which works under a tight memory budget: there, files are copied as they are and never decoded, and any downscaling happens later in the app.

Where the Photo Goes Next

A captured photo is part of a capture, not a message of its own. The app writes the downscaled JPEG into the capture's own folder under the capture's id, records it in the entry's list of owned media as well as its list of media still to upload, and lets the outbox send it when the network allows. The owned list is what the app deletes from when the entry is finished, which is the lesson the travel journal paid for when its photos stayed on the phone forever. A movie is treated the same way, except that it is copied as recorded rather than re-encoded.

Code

The system camera behind a small SwiftUI wrapper, with policy left to the caller·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)
    }
}
The long edge that leaves the phone, as a pure, tested size·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)

External links

Exercise

Add CameraCapture to SparkMobile's composer behind a camera button that presents it in a sheet and closes the sheet itself on every result. Run it once in a simulator and once on a device, and record which source each used. The image CameraCapture hands back carries no metadata at all, so stripping matters for photos that arrive as files. Take a photo in the Camera app with location on, save it to Files, and confirm on your Mac that it carries a location. Load that file into a UIImage, send it through ImageDownscale.jpegData, save the result beside it, and compare the two files' pixel size and metadata.
Hint
On the Mac, mdls -name kMDItemLatitude -name kMDItemPixelWidth <file> shows whether a location and what width a file carries. Write tests for targetSize(for:) with a landscape, a portrait and an already-small size.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.