Focale
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 — le lire avant toute modification.
Captures d'écran
| Appareil | Bibliothèque | Visionneuse |
|---|---|---|
![]() |
![]() |
![]() |
| 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
.focalerecipeet 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
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
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 :
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 <line> (boîte de surface nulle) ne se rend pas, par spécification SVG.
Règles non négociables
- Rien ne quitte l'appareil. Jamais. Aucune escalade cloud, même optionnelle, même sur les métadonnées.
- Aucune photo n'est dupliquée, déplacée ou modifiée. Index par
localIdentifier; désinstaller ne fait rien perdre. - Utile avant que l'index soit complet. Résultats progressifs, du plus récent au plus ancien.


