There is a folder on every photographer's Mac that nobody opens. It has six versions of the same group photo, one where you blinked, one where your friend blinked, one where a stranger walked through. Everyone meant to sort it. Nobody did.

Blink is a small macOS app that does that pass for you, entirely on device. It groups bursts, picks the frame where nobody blinked, files screenshots and receipts into a junk drawer, and ranks your best photos of the year. No upload, no account, no API key.

This is the build log: the model, the architecture, the four things that broke, and the numbers.

The model does the interesting part

Desert Ant Labs ships a set of on-device vision models with a source-available Swift SDK. The one that mattered here is Eye, which is the most mature in the set: calibrated tags, per-face attributes, embeddings, duplicate grouping.

What Eye returns for one photo is the whole reason this app is possible in a day:

  • Face.confidence(.eyesClosed), .lookingAtCamera, .smiling, .quality per detected face
  • frame level everyoneLooking, everyoneSmiling
  • scores: keeper, aesthetic, technical, cover, peakMoment
  • a tag set with confidences, including screenshot, receipt, document, whiteboard, photoOfScreen
  • Eye.duplicates(in:dates:) and Eye.best(of:), which are the burst clustering and the keeper pick, already written by people who tuned them

That list is why the app is three screens and not a machine learning project. My job was to use the signals honestly, not to train anything.

Architecture: five targets, one pipeline

The shape of the package is the design decision I would defend hardest.

graph TD A[Blink<br/>AppKit shell] --> B[BlinkUI<br/>SwiftUI screens] B --> C[BlinkCore<br/>scan pipeline] D[blink-cli] --> C E[blink-snap] --> B E --> C C --> F[Eye SDK<br/>local package] B --> F

Three executables and two libraries:

  • BlinkCore imports Eye and Foundation and nothing else. Enumerate, analyze, group, rank.
  • BlinkUI holds SwiftUI screens plus the observable store.
  • Blink is a 100 line AppKit shell around NSHostingView(rootView: ContentView()).
  • blink-cli gives the same result as JSON, for testing speed and diffing behavior.
  • blink-snap renders the real interface to a PNG, in a mode you choose.

That last one exists for a boring reason: this machine has no screen recording permission available to the terminal, so screenshots came back as wallpaper. Rather than declare the UI "probably fine", I made the UI renderable offscreen and inspected actual pixels.

The pipeline

public static func scan( folder: URL, bestCount: Int = 30, progress: @escaping @Sendable (String) -> Void = { _ in } ) async throws -> ScanResult { let urls = Library.enumerate(folder) var items = urls.map { PhotoItem(url: $0, date: Library.captureDate($0)) } for i in items.indices { items[i].analysis = AnalyzeCache.load(items[i].url) } let missing = items.indices.filter { items[$0].analysis == nil } // ... analyze the missing ones three at a time, caching each result ... let grouped = assemble(photos: items, bestCount: bestCount) return ScanResult(folder: folder, photos: items, moments: grouped.moments, junk: grouped.junk, best: grouped.best, ...) } 

Two properties make this bearable:

The cache is per photo, not per scan. Every analysis is written to ~/Library/Application Support/Blink/analyses/<hash>.json as it finishes. Kill the app halfway through a 900 photo folder and the next run picks up at photo 451. It is the poor man's resume, and it is the difference between a two hour scan being obnoxious once and being obnoxious every time.

The second half is pure. assemble takes analyzed photos and returns moments, junk and best. No model call, no file IO. Changing the top N from 30 to 50 re-sorts in milliseconds. Re-picking a keeper in the UI does not touch Eye. And because the CLI and the app call the same function, they cannot disagree.

let groups = Eye.duplicates(in: analyses, dates: dates) for g in groups where g.count >= 2 { let items = g.map { analyzed[$0] } let pick = Eye.best(of: g.map { analyses[$0] }) ?? 0 moments.append(Moment(id: items[0].id, items: items, pickIndex: pick)) } 

That is the whole Moments feature. Six lines. Everything else is presentation.

Thing one that broke: the junk classifier lied

My first classifier took every junk-ish tag and used one threshold. It looked clean. Then I ran it on a folder with three group photos from a gala, and it filed all three as "Identity paper 0.70".

The photos had name cards on the tables. Eye was right that there was identity-like paper in frame. My classifier was wrong to treat "there is some paper in this photograph" as "this is not a photograph".

The fix was to split the tags by what they mean, not by how confident Eye is:

/// Tags that are near-certain when Eye is confident at all. public static let hardTags: Set<Tag> = [ .screenshot, .photoOfScreen, .receipt, .document, .idCard, .whiteboard, .chartOrSlide, .menu, .map, .handwriting, .letterMessage, .testOrDuplicateShot, ] /// Tags that fire on real photos too, so they need a much higher bar. public static let softTags: Set<Tag> = [ .identityDocument, .lowContent, .forgettable, .technicalFlaw, .unusable, .badFraming, .blurry, ] public static func isJunk(_ a: Analysis?) -> Bool { guard let a else { return false } if a.kind == .screenshot || a.kind == .document || a.kind == .receipt { return true } if let hard = hardTags.compactMap({ a.confidence($0) }).max(), hard >= 0.55 { return true } return false } 

The second half of the fix matters more than the threshold. Soft tags do not create junk at all. They only remove a photo from Best Of. A slightly soft portrait stays in the album and quietly loses the ranking, which is what a human would do. Filing it next to a receipt is what an algorithm does.

After the change, a 13 photo folder returned exactly the three clutter items: screenshot 1.00, whiteboard 0.92, receipt 0.71, and zero false positives on the ten photographs.

Thing two that broke: the window did not exist

The app launched. The process was alive. pgrep found it. And there was no window.

I could not use screen capture to check, so I put a diagnostic in the app that dumped NSApp.windows to a file and quit:

windows=0 policy=0 running=true 

SwiftUI's WindowGroup scene never instantiated a window in this environment. The app was running a scene graph with nothing attached. I spent a while assuming my Info.plist was wrong, then stopped debugging the wrong layer and deleted the abstraction.

@main enum BlinkMain { @MainActor static func main() { let app = NSApplication.shared app.delegate = AppDelegate() app.setActivationPolicy(.regular) app.mainMenu = MainMenu.build() // Cmd O, Cmd Q app.run() } } 

Inside the delegate, a real NSWindow with NSHostingView(rootView: ContentView()), .fullSizeContentView, a transparent title bar, and a dark background. Twenty extra lines, and the next diagnostic said:

windows=1 Blink frame={{296, 168}, {1280, 830}} visible=true key=true 

The lesson is not "SwiftUI bad". It is that when a framework's lifecycle silently does nothing, a deterministic 20 line shell is cheaper than a day of guessing, and it stays cheaper forever.

Thing three that broke: the renderer rendered nothing

ImageRenderer needs the real view tree. My grid is a LazyVGrid inside a ScrollView, which is correct for scrolling and useless for offscreen rendering: with no viewport, laziness has nothing to lay out, and the PNG came back as an empty frame.

Two attempts failed. Faking a grid type, then a "host" wrapper that swapped implementations, both produced either a crash or the same empty image. What worked was dumber: when a render flag is set, the root container skips the ScrollView entirely.

RenderFlag.offscreen = true let store = Store() store.open(folder) while store.scanning || store.photos.isEmpty { try? await Task.sleep(nanoseconds: 300_000_000) } store.mode = mode let renderer = ImageRenderer(content: ContentView(store: store).frame(width: 1440, height: 900)) renderer.scale = 2 

blink-snap runs as an .accessory app, so no dock icon, no window, no run loop dependency. Same Store, same views, real pixels at 2880x1800.

Thing four that broke: I trusted a display string

blink-snap folder out.png junk printed the Moments screen. The mode parser used BlinkMode(rawValue:), and my enum's raw values are the labels the sidebar shows: "Moments", "Junk Drawer", "Best Of". So "junk" matched nothing and fell through to the default. Case-insensitive prefix matching fixed it, and it is a good reminder that the string you show the user and the token you accept from a script are two different contracts.

Numbers, honestly

On an M1 Pro:

  • 2 photos, cold: 69.7s, almost entirely model load
  • 9 photos, cold: 139.1s, so about 14s per photo after load
  • 13 photos with clutter: 3 junk items caught, 3 correct, 0 false positives
  • warm rerun on cached analyses: seconds

Fourteen seconds per photo means a 500 photo library is a two hour first pass. Cached, it is instant. That trade is acceptable for a utility you run once per folder, but it is not acceptable as a silent UI, so a real ETA and a resume point are the top of the next list.

What the codebase looks like

Sources/BlinkCore/Model.swift 313 lines items, Eye facts, junk classifier, cache, engine Sources/BlinkCore/Scanner.swift 122 lines scan pipeline, pure grouping and ranking Sources/BlinkUI/Views.swift 968 lines theme, thumbnail cache, three screens Sources/BlinkUI/Store.swift 143 lines observable state, folder picker Sources/Blink/BlinkApp.swift 100 lines AppKit shell, menu, diagnostics Sources/BlinkCLI/main.swift 91 lines folder in, JSON out Sources/BlinkSnap/main.swift 35 lines folder in, PNG out 

1,812 lines total. One model. No tests yet, which is the largest hole in it: the classifier thresholds were tuned on 22 fixture images and that is not a dataset.

What I would build next

Ordered by what would actually change the product:

  1. Resumable scans with a real ETA. The pipeline already writes per photo; the UI just needs to read the cache count before starting and keep it after a crash.
  2. Keyboard triage. Left and right through a burst, space to keep. The keeper pick is right most of the time, and a person should be able to overrule it in one keystroke.
  3. Export the keepers to Photos instead of a folder, because the destination people actually use is the photo library.
  4. An album browser over Photos Library.photoslibrary. Originals there have no extensions, so this needs its own enumerator rather than a file filter.
  5. iOS. Same engine, same three screens, Photos framework instead of a folder picker. The pipeline is already framework-free for exactly this reason.

The part worth keeping

Two things generalise beyond this app.

Local inference is now good enough that a 49 MB model can make a judgment call about your photos, offline, in a day of work. The interesting engineering is not the model. It is deciding what a false positive costs. A blurry portrait in the junk drawer destroys trust in the whole tool. The same portrait quietly missing from a "best of" list costs nothing. Same signal, two different consequences, and getting that distinction right is the actual product.

The second one is about verification. I could not see the screen, so I made the app draw itself and checked the pixels. I could not trust the window existed, so I made it print its own window list. Both of those cost an hour and paid for themselves twice.

Junk Demo

Code & more: https://www.dailybuild.xyz/project/297-blink