# Focale

Icône Focale — iris d'obturateur à six lamelles

iOS 26.0+ Swift 6 SwiftUI Strict concurrency Build passing UI tests passing TestFlight 0.1.0 (4) 100% on-device

**Appareil photo manuel + photothèque interrogeable.** SwiftUI, AVFoundation, PhotoKit, Vision, Foundation Models. Deux produits qui se sauvent mutuellement : une photo prise dans Focale arrive déjà comprise, et la vieille bibliothèque se rattrape en arrière-plan pendant que le produit est déjà utile. **Rien ne quitte l'appareil. Jamais.** Aucune photo dupliquée, déplacée ou modifiée. Utile avant que l'index soit complet. **Author :** Simon-Pierre Boucher — contact@spboucher.ai > La thèse produit, les contraintes API et les règles d'architecture vivent dans [CLAUDE.md](CLAUDE.md) — le lire avant toute modification. --- ## Captures d'écran | Appareil | Bibliothèque | Visionneuse | |:---:|:---:|:---:| | ![Écran de capture](docs/screenshots/capture.png) | ![Bibliothèque](docs/screenshots/bibliotheque.png) | ![Visionneuse](docs/screenshots/visionneuse.png) | | Recettes, projets, retardateur, grille, dials manuels | Albums vivants + progression d'indexation honnête | Zoom, favoris, « photos semblables », panneau d'info | *(Simulateur iPhone 17 Pro — l'aperçu caméra est noir sans matériel photo.)* --- ## Métriques | Métrique | Valeur | |---|---| | Fichiers Swift | 37 | | Lignes de code Swift | 4 554 | | Erreurs de build | 0 | | Concurrence | Swift 6, `SWIFT_STRICT_CONCURRENCY: complete` | | Cible de déploiement | iOS 26.0 (iPhone) | | Tests UI | 1 suite (régression crash favoris) — ✅ 22,7 s | | Builds TestFlight | 4 (0.1.0 (1) → (4)) | | Étapes CLAUDE.md couvertes | 1–2 complètes, fondations 3–5 | | Module | LOC | Rôle | |---|---|---| | `Capture/` | 1 999 | session AVFoundation, contrôles manuels, ProRAW, flash, retardateur, recettes, dispositions, gestes | | `Index/` | 676 | étage 1 Vision (tout) + étage 2 Foundation Models (candidats seulement) | | `Library/` | 661 | timeline, visionneuse plein écran, albums vivants | | `Search/` | 508 | requête naturelle → filtre structuré → classement local | | `Intent/` | 341 | `CaptureContext` — le contexte capturé au déclenchement, cœur du projet | | `App/` | 246 | entrée, onboarding accès photos (le risque produit n° 1) | | `FocaleUITests/` | 74 | test de régression du flux favoris | | `Design/` | 49 | tokens, badge « Générée » | --- ## Fonctionnalités ### 📷 Appareil photo (étape 1 — vendable seul) - **Contrôles manuels complets** : ISO, vitesse d'obturation, mise au point, balance des blancs, compensation d'exposition, zoom — chaque contrôle est **vérifié contre le matériel** avant d'être affiché (aucun bouton mort). - **ProRAW** (iPhone 12 Pro+), HEIC, choix d'objectif (ultra grand-angle / grand-angle / télé). - **Flash** auto / activé / désactivé (affiché seulement si l'appareil a un flash). - **Retardateur** 3 s / 10 s avec compte à rebours annulable, **grille des tiers**, **retour au déclenchement** (clignotement d'écran + haptique immédiate). - **Le déclencheur est sacré** : aucune inférence, aucun disque, aucune allocation dans le chemin du déclenchement — réponse < 50 ms visée. - **Recettes** : presets complets nommés, rappelables en un geste, **exportables en fichier `.focalerecipe`** et importables — elles se partagent. - **Dispositions** : trois profils (Simple / Photographe / Expert), gestes entièrement réassignables (glissement gauche = ISO, pincement = zoom…), haptique paramétrable par contrôle. - Proposition de recette sur signal de scène (basse lumière) — **une proposition, jamais un basculement silencieux**. ### 🎯 Contexte de capture (étape 2 — la pièce qui différencie tout) - Au déclenchement, Focale enregistre **sans coût d'inférence** : recette active, projet déclaré, réglages manuels, signal de scène (luminosité EV), lieu grossier (jamais de coordonnées brutes). - **Écrit dans les métadonnées EXIF de la photo** (sans ré-encodage) en plus de la base locale : si l'utilisateur désinstalle, l'information reste dans sa photo. - **Mode Projet** : déclare « chantier cuisine » une fois — tout ce qui suit est marqué. Zéro friction. - **Note de sujet après la prise** (« reçu du garage ») — jamais de saisie obligatoire avant une photo. ### 🔍 Indexation & recherche (étapes 3–5) - **Étage 1 — Vision, sur tout** : OCR (le gagnant silencieux : reçus, tableaux blancs, numéros de série), empreintes visuelles (similarité sans LLM), classification, visages (comptage local seulement, jamais d'identification). - **Étage 2 — Foundation Models, sur candidats seulement** : le calcul à ne jamais oublier — 40 000 photos × 1-3 s = 11-33 h en série. L'étage 2 ne touche que les photos qui le méritent (prises dans Focale, favorites, demandées). - **Du plus récent au plus ancien, toujours.** Photos récentes indexées au premier plan dès l'ouverture ; rattrapage profond la nuit, en charge (`BGProcessingTask`), reprise incrémentale. - **Recherche naturelle** : « le reçu du garage l'automne passé » → filtre structuré exécuté sur la base locale (instantané). Le modèle analyse la requête, **jamais les photos**. Repli déterministe complet sur appareils sans A17 Pro. - **Albums vivants** : définis par une requête, ils se remplissent tout seuls. Créables depuis n'importe quelle recherche. - **« Photos semblables »** : distance d'empreinte visuelle, zéro LLM. - **Honnêteté** : contenu généré toujours badgé « Générée », l'OCR prime sur le modèle pour tout chiffre, progression d'indexation transparente. --- ## Architecture ``` Focale/ ├─ App/ entrée, onboarding accès photos, racine ├─ Capture/ │ ├─ Session/ AVCaptureSession (file série dédiée), capacités matérielles │ ├─ Controls/ modèle ISO/vitesse/focus/BB, dials, mapping de gestes │ ├─ Output/ délégué de capture, écriture PhotoKit + EXIF │ ├─ Recipes/ presets, gestionnaire, export/import │ └─ Layout/ profils d'interface Simple/Photographe/Expert ├─ Intent/ CaptureContext, projets, signal de scène, lieu ├─ Index/ │ ├─ Vision/ étage 1 — OCR, feature prints, classification │ ├─ Semantic/ étage 2 — Foundation Models (@Generable) │ ├─ Store/ SwiftData (localIdentifier, jamais de doublon) │ └─ Scheduler/ pipeline + BGProcessingTask (en charge seulement) ├─ Search/ analyse de requête (FM ou déterministe) + classement ├─ Library/ timeline, visionneuse, albums vivants └─ Design/ tokens, badge « Générée » FocaleUITests/ tests de régression UI Design/AppIcon.svg icône source (rendue en PNG dans Assets.xcassets) ``` Le projet Xcode est **généré par XcodeGen** : `Focale.xcodeproj` est un artefact, la source de vérité est `project.yml`. --- ## Build & tests ```bash xcodegen generate # regénère Focale.xcodeproj depuis project.yml open Focale.xcodeproj # build & run (la caméra exige un iPhone physique) # Tests UI (simulateur) xcodebuild test -project Focale.xcodeproj -scheme Focale \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' ``` - Xcode 26+, Swift 6 (concurrence stricte), cible iOS 26.0. - Foundation Models exige un appareil A17 Pro+ ; partout ailleurs le chemin Vision-only prend le relais automatiquement. ### TestFlight ```bash xcodebuild -project Focale.xcodeproj -scheme Focale -destination 'generic/platform=iOS' \ -archivePath build/Focale-N.xcarchive archive -allowProvisioningUpdates open -a Xcode build/Focale-N.xcarchive # Organizer → Distribute App ``` | Build | Contenu | |---|---| | 0.1.0 (1) | fondation complète : caméra manuelle, contexte, index, recherche | | 0.1.0 (2) | permission caméra, visionneuse, orientation portrait, indexation au premier plan | | 0.1.0 (3) | projets/recettes dans l'UI, export `.focalerecipe`, note après prise, pincement, favoris, « photos semblables », albums vivants depuis recherche | | 0.1.0 (4) | **crashs d'isolation Swift 6 corrigés** (favoris, enregistrement de capture, vignettes), Vision résilient, flash, retardateur, grille, retour au déclenchement, test UI de régression | --- ## Leçons Swift 6 (à lire avant de toucher à PhotoKit) Les closures passées à `PHPhotoLibrary.performChanges` ou aux handlers de `PHImageManager` **héritent de l'isolation** (MainActor ou acteur) du contexte où elles sont créées. PhotoKit les exécute sur sa propre file → `dispatch_assert_queue_fail`, crash immédiat. Règle du projet : **tout bloc destiné à une file de framework est déclaré `@Sendable`**, récupère ses `PHAsset` à l'intérieur, et sort ses résultats via une boîte `@unchecked Sendable`. Voir `PhotoLibraryWriter.swift` pour le patron. --- ## Icône Source : `Design/AppIcon.svg` — iris d'obturateur à six lamelles ambre sur objectif charbon. Pour régénérer le PNG : ```bash mkdir -p /tmp/focale-icon qlmanage -t -s 1024 -o /tmp/focale-icon Design/AppIcon.svg cp /tmp/focale-icon/AppIcon.svg.png Focale/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png ``` ⚠️ Le dégradé des lamelles doit rester `gradientUnits="userSpaceOnUse"` : un dégradé `objectBoundingBox` sur une `` (boîte de surface nulle) ne se rend pas, par spécification SVG. --- ## Règles non négociables 1. **Rien ne quitte l'appareil. Jamais.** Aucune escalade cloud, même optionnelle, même sur les métadonnées. 2. **Aucune photo n'est dupliquée, déplacée ou modifiée.** Index par `localIdentifier` ; désinstaller ne fait rien perdre. 3. **Utile avant que l'index soit complet.** Résultats progressifs, du plus récent au plus ancien.