// // IndexPipeline.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Walks the system photo library newest → oldest (people search the last // two years), runs stage 1 on everything, stage 2 on candidates only, // and checkpoints so an interruption never restarts from zero. // import CoreGraphics import Foundation import Photos import SwiftData import UIKit struct IndexProgress: Sendable { var indexed: Int var total: Int /// Honest progress line for the UI (CLAUDE.md §5), in French. var statusLine: String { guard total > 0 else { return "Photothèque vide" } if indexed >= total { return "\(total) photos indexées" } return "\(indexed.formatted()) / \(total.formatted()) photos indexées — les plus récentes sont déjà cherchables" } } actor IndexPipeline { private let container: ModelContainer private let vision = VisionIndexer() private let semantic = SemanticIndexer() private var isSuspendedForCapture = false private var isBackfilling = false private var isForegroundIndexing = false init(container: ModelContainer) { self.container = container } // MARK: - Capture priority (CLAUDE.md §9: viewfinder first, always) func suspendForCapture() { isSuspendedForCapture = true } func resumeAfterCapture() { isSuspendedForCapture = false } // MARK: - Photos taken in Focale: already understood /// Zero-inference ingestion: the context alone makes the photo findable. /// Stage 1 + stage 2 follow immediately — a fresh Focale photo is the /// best candidate there is (context present, cost minimal). func ingestCapturedPhoto(localIdentifier: String, context: CaptureContext) async { let modelContext = ModelContext(container) let record = PhotoRecord(localIdentifier: localIdentifier, captureDate: .now) record.captureContext = context modelContext.insert(record) try? modelContext.save() await indexAsset(localIdentifier: localIdentifier, runSemantic: true) } func updateSubjectHint(localIdentifier: String, hint: String) { let modelContext = ModelContext(container) guard let record = fetchRecord(localIdentifier, in: modelContext) else { return } record.subjectHint = hint if var context = record.captureContext { context.subjectHint = hint record.captureContext = context } try? modelContext.save() } // MARK: - Progress (honest, always) func progress() -> IndexProgress { let total = PHAsset.fetchAssets(with: .image, options: nil).count let modelContext = ModelContext(container) let indexed = (try? modelContext.fetchCount( FetchDescriptor(predicate: #Predicate { $0.visionIndexedAt != nil }) )) ?? 0 return IndexProgress(indexed: indexed, total: total) } /// Opportunistic stage-1 pass over the most recent photos while the app /// is open — search must be useful immediately, not after the first /// night on the charger (CLAUDE.md §5). Yields to the viewfinder and /// backs off on heat; the deep backfill stays with BGProcessingTask. func indexRecentInForeground(limit: Int = 400) async { guard !isForegroundIndexing else { return } isForegroundIndexing = true defer { isForegroundIndexing = false } let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] options.fetchLimit = limit let assets = PHAsset.fetchAssets(with: .image, options: options) for index in 0.. Bool) async { guard !isBackfilling else { return } isBackfilling = true defer { isBackfilling = false } let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] if let checkpoint = loadCheckpointDate() { options.predicate = NSPredicate(format: "creationDate < %@", checkpoint as NSDate) } let assets = PHAsset.fetchAssets(with: .image, options: options) for index in 0.. PhotoRecord? { var descriptor = FetchDescriptor( predicate: #Predicate { $0.localIdentifier == localIdentifier } ) descriptor.fetchLimit = 1 return try? modelContext.fetch(descriptor).first } /// Bridges PHImageManager's may-fire-twice handler to a single resume. /// @unchecked: the handler is delivered serially on the main queue. private final class ResumeFlag: @unchecked Sendable { var resumed = false } /// Thumbnails come from PhotoKit, never stored twice (CLAUDE.md §9). /// The result handler is @Sendable so it carries no actor isolation — /// PhotoKit calls it on the main queue, not on this actor. private func requestThumbnail(for asset: PHAsset, size: CGFloat = 1024) async -> CGImage? { let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true // the user's own iCloud photo, inbound only options.isSynchronous = false let flag = ResumeFlag() return await withCheckedContinuation { continuation in let handler: @Sendable (UIImage?, [AnyHashable: Any]?) -> Void = { image, info in let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false guard !degraded, !flag.resumed else { return } flag.resumed = true continuation.resume(returning: image?.cgImage) } PHImageManager.default().requestImage( for: asset, targetSize: CGSize(width: size, height: size), contentMode: .aspectFit, options: options, resultHandler: handler ) } } private func loadCheckpointDate() -> Date? { let modelContext = ModelContext(container) return (try? modelContext.fetch(FetchDescriptor()))? .first?.oldestIndexedDate } private func saveCheckpointDate(_ date: Date?) { guard let date else { return } let modelContext = ModelContext(container) if let checkpoint = (try? modelContext.fetch(FetchDescriptor()))?.first { checkpoint.oldestIndexedDate = date checkpoint.updatedAt = .now } else { modelContext.insert(IndexCheckpoint(oldestIndexedDate: date)) } try? modelContext.save() } }