Skip to content
C.W.K.
Lesson 04 of 04 · published

The Shutter Turns a Live Session Into a Moment

~12 min · swiftui, visionkit, capture, handoff

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A session is a moment from the first second

Before the shutter, look at what opening the live camera already did. The view's .task calls lane.start, and that creates a moment of kind live on the engine: the phone samples the place with a two-second limit, takes the current time as the capture truth, mints the moment's ULID itself, and sends it with a client request id prefixed live-. Only when the engine has that moment does the status change to Point at text. Texts the scanner reported in the meantime were waiting, because flush refuses to run without a moment id, and the first flush sends them.

This moment is created straight against the engine, not through the phone's outbox, and it has to be. Every live answer comes from the engine's corpus or a brain behind it, so with no engine there is nothing for the lane to do. The status says so in words: Live needs the engine, followed by the reason. A session that closes before any text was seen is still kept, and the day's list on both the phone and the web marks it No text seen. It asked me nothing, and the record says so.

The shutter

At the bottom of the live camera sits a white 68-point circle with an ember ring. Its accessibility label is the whole design in six words: Keep this as a photo moment. Pressing it asks the scanner for a photo of what it sees.

There is a small ownership problem in that sentence. The button is SwiftUI. The scanner is a UIKit DataScannerViewController, created inside the representable's makeUIViewController, and SwiftUI never holds it. Spark bridges the two with a tiny class, ScannerHandle: the representable stores the scanner in the handle's weak property when it creates it, and the button calls shutter.capture(), which awaits the scanner's own capturePhoto(). If the scanner is gone or the capture throws, the handle returns nil and nothing happens. A weak reference means the handle can never keep a dismissed camera alive.

One line, shared with the camera

The photo goes back to RootView through the onPhoto closure. Read the third code block: the live camera's cover and the ordinary camera's cover end in the same line, keep { await PreparedPhoto.prepare(camera: image) }. The live camera closes, and from that line on, the frame is exactly what a camera still would be.

  • The original is the image as JPEG at full resolution, and a 2048-pixel copy is made for me.
  • Vision locates the lines on that copy, numbered in reading order.
  • The place is sampled at the same time, and the situation chip Dad had chosen comes along. So does a pending crumb link, if one was waiting.
  • The moment goes into the outbox on the phone before anything crosses the network, and it opens at once.

A still from the scanner carries no metadata of its own, the same as a camera still. AppModel.capture fills the moment's capture time and place into both the original and my copy, so the photo keeps its truth even if it waits on the phone for hours before it is sent (Track 9).

Two moments, on purpose

After the shutter there are two moments, not one. The live moment holds every text the camera saw that got an answer, each a short live-line. The new photo moment holds the frame, and its practical turn is my full reading of the picture: every line in context, explanations when Dad taps one, roots when they are real (Track 3). No field links the two. Each carries its own place sample and time, so they sit side by side in the day's list.

The handoff exists because the lanes see different things. The live lane gets words the scanner transcribed and answers in seconds with labels as short as the signs. The photo path gives me the image itself, so I can correct what Vision misread and explain what the sign means here. In the architecture's words, the frame becomes a photo moment and continues as practical photo translation. The shutter is how Dad says this one is worth the longer look.

The two paths also fail differently. The live session needs the engine. The photo does not: it is safe on the phone the moment the shutter's work returns, and it reaches the engine whenever the link allows.

Principle: When a fast mode finds something worth keeping, hand it to the slow, careful path you already trust, through the same line every other capture uses. A second keep path is a second set of bugs.

Track 3 follows that photo from here: the copy I see, the numbered lines, and the spark block I answer in. Track 9 covers the outbox that keeps it safe until it is sent. If the general iOS mechanics interest you, Swift Quest has camera capture and UIKit-in-SwiftUI; its app is also named Spark, but it is an invented app.

Code

cwkSpark, clients/apple/SparkMobile/Sources/Views/LiveCameraView.swift: the live moment is created on the engine when the camera opens (verbatim, lines 77-96)·swift
    /// Create the live moment this session's texts are kept on.
    func start(model: AppModel, situation: Situation) async {
        client = model.client
        let place = await model.places.sample(timeout: .seconds(2))
        let truth = CaptureTruth.now()
        let id = ULID.mint()
        do {
            _ = try await model.client.createMoment(EngineClient.NewMoment(
                id: id, kind: "live", capturedAt: truth.capturedAt, timezone: truth.timezone,
                localDate: truth.capturedLocalDate, situation: situation.rawValue,
                placeName: place?.name,
                location: place.map { .init(lat: $0.lat, lon: $0.lon, accuracyM: $0.accuracyM, sampledAt: $0.sampledAt) },
                clientRequestId: ClientRequestId.mint(prefix: "live-")))
            momentId = id
            status = "Point at text"
            await flush()
        } catch {
            status = "Live needs the engine: \(error.localizedDescription)"
        }
    }
cwkSpark, clients/apple/SparkMobile/Sources/Views/LiveCameraView.swift: the shutter, and the task that starts the session (verbatim, lines 43-56)·swift
                Button {
                    Task {
                        if let image = await shutter.capture() { onPhoto(image) }
                    }
                } label: {
                    Circle().fill(.white).frame(width: 68, height: 68)
                        .overlay(Circle().stroke(Palette.ember, lineWidth: 4).padding(-6))
                }
                .accessibilityLabel("Keep this as a photo moment")
                .padding(.bottom, 30)
            }
        }
        .background(.black)
        .task { await lane.start(model: model, situation: situation) }
cwkSpark, clients/apple/SparkMobile/Sources/Views/RootView.swift: the camera and the live camera end in the same keep line (verbatim, lines 77-91)·swift
        .fullScreenCover(isPresented: $showCamera) {
            CameraCapture { media in
                showCamera = false
                if case .photo(let image)? = media {
                    Task { await keep { await PreparedPhoto.prepare(camera: image) } }
                }
            }
            .ignoresSafeArea()
        }
        .fullScreenCover(isPresented: $showLive) {
            LiveCameraView(situation: situation) { image in
                showLive = false
                Task { await keep { await PreparedPhoto.prepare(camera: image) } }
            }
        }

External links

Exercise

In an app with two capture modes, one fast and preview-only and one slow and complete, add a button to the fast mode that hands the current frame to the slow mode. Route it into the exact function your ordinary capture already uses, instead of writing a second save path. If the fast mode's view is a UIKit controller inside SwiftUI (or any framework view your UI layer does not own), reach it through a small handle object with a weak reference. Then turn the network off and try both: the fast mode should say it needs the network, and the handed-off frame should still be kept.
Hint
Search your code for every place a capture is saved. If the handoff adds a new one, fold it back into the shared function and pass the image in. For the handle, store the controller when the representable creates it, and let the capture method return nil when the controller is gone, so a late tap after dismissal does nothing.

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 in — Please sign in to comment.

No comments yet — be the first.