Skip to content

Build Focelle iOS beta - #1

Draft
PNHD wants to merge 93 commits into
mainfrom
feat/focelle-beta
Draft

Build Focelle iOS beta#1
PNHD wants to merge 93 commits into
mainfrom
feat/focelle-beta

Conversation

@PNHD

@PNHD PNHD commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Implements the approved Focelle iOS beta scope in docs/focelle-spec.md and docs/focelle-plan.md.

Included

  • native SwiftUI/AVFoundation camera: front/rear capture, ratios, focus/exposure, zoom, flash, timer, grid, volume shutter, supported 24/48 MP selection, and recoverable permission/interruption states
  • responsive subject coaching: full Vision scene detection at an adaptive interval, lightweight VNTrackObjectRequest updates between detections, thermal throttling, group bounds, persistent long-press selection, and stale-result protection when switching cameras
  • target-based AI coaching: current-dot to colored-ring movement, target framing, Primary/Safe/Creative plans, optional voice and guarded Auto Capture
  • 12 commercially owned filter recipes, live adjustment, compare/reset, existing-photo editing, custom presets, local storage, and private CloudKit sync on signed devices
  • validated Gemini Worker path with cancellation/fallback, rate limits, credit reserve/refund, and privacy-safe analytics
  • free Beta Pro plus disabled-by-default quota, rewarded ads, StoreKit subscriptions/credits, Sign in with Apple, deletion retention, and referral flows
  • Vietnamese/English onboarding, privacy/accessibility settings, original icon, release drafts, CI, D1 migrations, and physical TestFlight checklist
  • Doka frame-by-frame review retained only as a parity baseline; Focelle differentiates with immediate local guidance, non-blocking cloud refinement, no default watermark, offline usefulness, custom-preset sync, and measurable guided-vs-unguided quality targets

Verified

  • Public CI run 30213543045 at head d7e8dc4: backend, strict Swift formatting, iPhone 17e simulator boot, iOS build, 29/29 tests, app launch, onboarding screenshot, xcresult, logs, and artifact upload all passed
  • the run 50 onboarding artifact was downloaded and visually inspected as the expected Focelle screen
  • backend: 29/29 tests and TypeScript typecheck also pass locally
  • Worker: Wrangler production dry-run bundles successfully
  • D1: migrations 0001-0007 are applied remotely; commerce/account/referral flags remain off
  • repository history and current tree were scanned; no provider keys, signing files, personal photos, or sensitive paths were found
  • unsigned simulators intentionally retain presets locally; private CloudKit sync remains enabled for signed device builds

Release gates

  • provide Gemini and app shared-token secrets, deploy and smoke-test the Worker
  • configure the Apple App ID/provisioning profile for Sign in with Apple and iCloud.com.pnhd.focelle
  • provide Apple Developer/App Store Connect signing and public support/privacy details
  • complete the physical iPhone 17e, second-device CloudKit, StoreKit sandbox, and AdMob test checklist
  • obtain explicit authorization before TestFlight upload or any public App Store action
  • keep the repository public for free beta CI, then switch PNHD/focelle-ios back to private after release

See docs/completion-audit.md, docs/ui/doka-flow-analysis.md, and docs/testflight-checklist.md for requirement-by-requirement evidence.

PNHD added 30 commits July 26, 2026 13:40
PNHD and others added 30 commits July 27, 2026 01:07
Sideloadly re-signs the unsigned CI IPA with a free personal team, which
rewrites the bundle id to com.pnhd.focelle.<TeamID> and drops the iCloud
entitlement. CKContainer(identifier:) then calls os_crash instead of
throwing, so PresetStore.sync()'s do/catch could not stop it and the app
died at launch with EXC_BREAKPOINT on the cooperative pool.

Device crash report 6EC151FD-D545-4975-BC30-24374A444967 confirms the
frames: CloudKit -> PresetSync.database() -> PresetSync.fetch() ->
PresetStore.sync().

FocelleCloudKitContainer now comes from FOCELLE_CLOUDKIT_CONTAINER, which
only the signed TestFlight archive fills. Unsigned builds leave it empty,
take the existing iCloudNotConfigured path, and keep local presets. iCloud
support itself is unchanged for properly provisioned builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Print embedded frameworks, load commands, rpaths, entitlements and the
launch-critical Info.plist keys for each unsigned device build. Secret
values are reported as lengths only because this repository is public.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 54 was cancelled at the screenshot step after 10.6 minutes. Run 52
needed 6.8, so the old cap left no headroom for runner variance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 55 measurements: boot 119s, simulator build+test 393s, device IPA
132s, screenshot 34s. Two of those were pure waste.

The boot no longer blocks. simctl boot returns as soon as the boot starts,
so the Debug build now runs during it and bootstatus only has to confirm
what already finished. That reclaims most of the 119s.

The simulator test and the device package share no build products, so they
are now separate jobs on separate runners instead of running back to back.
Wall clock becomes the longer of the two rather than their sum, and total
billed minutes drop because no work was added.

Both jobs share one -clonedSourcePackagesDirPath so GoogleMobileAds is
resolved into a cacheable directory instead of being downloaded twice into
two DerivedData trees.

Artifacts are now focelle-ios-tests and focelle-ios-ipa; upload-artifact
rejects the same name from parallel jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 56 measured build-for-testing at 448s plus test-without-building at
69s, against 393s for the single combined invocation in run 55. The split
made xcodebuild plan the build twice for no benefit.

Reverting to one pass keeps the two changes that did work: the device
package now runs as its own parallel job off the critical path (116s), and
the simulator boot no longer blocks (119s down to 19s plus a 14s confirm).

The SwiftPM cache missed on run 56 because that run created it. Run 57 is
the first that can hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing the shutter or a volume button killed the app on device.

Three crash reports (CB556D85, 40C2ED7D, and the 20:19:22 one) all show
the same frames: capturePhotoWithSettings:delegate: raising an ObjC
exception from CameraSession.capture() on com.pnhd.focelle.camera, then
abort.

capture() set photoQualityPrioritization to .quality, but nothing ever
raised photoOutput.maxPhotoQualityPrioritization above its .balanced
default. AVCapturePhotoOutput rejects a settings value above that ceiling
with NSInvalidArgumentException, which Swift cannot catch.

configureCapabilities now raises the ceiling while the session is being
configured, and capture() reads the ceiling back instead of assuming one.
maxPhotoDimensions is bounded against the output for the same reason: it
is the other documented throw in that call, and a stale value after a
format change would crash the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Schema 1 required instructionVi, instructionEn, poseVi and poseEn on every
plan, so each added language meant three more strings per plan and nine
more per response. Chinese, Japanese and Korean would have taken it to
thirty strings a tap.

Schema 2 keeps one instruction and one pose, written in the language the
client asked for. The request now carries a BCP-47 tag instead of a vi/en
switch, so a new language is a translation job rather than a schema change.

The tag reaches the provider prompt, so the backend accepts only letters
plus one hyphen. Two tests cover that boundary and one covers the client
tag it is given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Following the guidance on a real device did not work: walking closer made
it switch to "move left", and standing near a threshold made it alternate
between "move closer" and "step back".

Three causes, all in the proposal step.

The target teleported. targetX was 0.5 above width 0.42 and a third below
it, so walking in moved the target a sixth of the frame in one step and
produced a fresh sideways instruction. It also flipped between the left and
right third the moment the subject crossed centre. The target now eases
toward centre across widths 0.30 to 0.50, and a chosen third is kept until
the subject is well past the middle.

Every threshold was single-valued, so a subject resting on one oscillated.
Each goal now has a wider entry threshold and a narrower exit threshold.

The update step switched after two differing frames, roughly 1.4 seconds at
the current detection rate. A goal is now held until its exit threshold
clears it, the subject is lost, or four seconds pass. A goal that has become
wrong still yields at once, since a stale instruction is worse than a new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Foundation may report a likely script for a locale that never spelled one
out, so en_US could produce "en-Latn" and ja_JP "ja-Jpan". Only Chinese
needs its script to pick the right characters, so only Chinese carries one
now and everything else sends a bare language code.

The test no longer asserts an exact string for every locale either. It
checks each tag against the same pattern backend/src/analyze.ts enforces,
which is the property that actually matters, and pins exact values only
where the script is genuinely part of the answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strip showed names only, so choosing a look meant guessing. Doka solves
this with a bundled sample photo per filter, which is a large part of why
that app is 163 MB.

Each look is now rendered onto the current camera frame instead, at 96
points, refreshed every two seconds on the capture queue. The strip previews
the scene actually being shot rather than someone else's photo, and the app
still ships no image assets for it.

User presets get the same treatment, which also makes them visible: they
were already appended after the twelve originals but looked identical to
them as plain text.

The strip also gains horizontal padding. Chips previously sat flush against
the screen edge, so the last one read as clipped rather than scrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec asks for charcoal chrome, ivory text and a warm amber accent, and
rules out a generic purple-blue AI gradient. The guidance overlay drew its
target ring and target frame with an angular cyan to orange to pink sweep,
lit them with a cyan shadow, and used cyan for the aim dot and the ring
hint. Every one of those is now the amber accent.

The three sliders rendered in the iOS system blue for the same reason:
nothing tinted them.

The zoom row showed a static "1x" beside the live value, which read as the
same number printed twice whenever zoom sat at minimum. The left end of the
track already means 1x, so the static label is gone and the live value now
carries the accent once zoom leaves minimum.

The instruction bubble sat at a fixed 16 percent from the top and landed on
the subject whenever the subject reached into that band. It now drops below
the subject in that case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Missed in the previous pass; the target frame still cast a cyan glow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Give the analyzer an explicit resume path: any real session transition
from stopped to running (Settings dismissal, interruption recovery, or a
fresh launch) now clears analysisInFlight, bumps the analysis generation,
resets the tracker/stabilizer/guidance engine, and republishes nil
measurement/guidance so the next frame produces a clean detection.
switchCamera() now shares the same reset path instead of duplicating it.

The stale-generation guard in the video delegate is extracted into
CameraSession.shouldAcceptAnalysis so it's independently testable. Added
DEBUG-only os.Logger diagnostics at the lifecycle/analysis checkpoints
needed for a physical retest, and regression tests covering: guidance
preference defaults/persists independent of other settings, stale vs
current generation acceptance, and a resume clearing in-flight analysis,
bumping generation, and clearing published guidance.

Also records FCL-002 as physically completed and FCL-003 as implemented
and pending physical verification in docs/project-status.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Address independent review findings against 76b11e1:

- Wire the Settings sheet's real onDismiss into a new
  CameraSession.refreshLocalGuidanceAfterSettings(), instead of only
  logging. It works on the still-running session (no stop/restart, no
  scenePhase assumption), invalidates in-flight/stale analysis, resets the
  tracker/stabilizer/guidance engine, and clears stale measurement/guidance
  so the next frame republishes them. Camera settings (zoom, exposure,
  ratio, filter, flash, timer) and guidanceEnabled are untouched.
- Fix stale-completion ordering: acceptAnalysisCompletion() now validates
  the request generation before clearing analysisInFlight, so a stale
  completion (from before a reset) can no longer clear the flag a newer,
  still-in-flight request owns.
- Reconcile docs/project-status.md's Next Gate section, which still called
  FCL-002 inactive after FCL-002 had already been recorded complete;
  FCL-003 is now the one documented active gate, still not physically
  verified.
- Tests: exercise refreshLocalGuidanceAfterSettings() itself (the same
  method Settings' onDismiss calls) for state invalidation and idempotency
  across repeated calls, and a new stale-vs-current completion ordering
  test proving a stale result can't clear a newer request's in-flight flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FCL-004. Physical evidence on iPhone 17e showed the toolbar claiming "24"
while the saved photo was ~12 MP, and selecting "48 MP" changed nothing
observable — the UI was showing a hardcoded label disconnected from what
AVFoundation could actually deliver, and CameraSession.resolution could be
set directly from the toolbar without updating the persisted
AppSettings.maximumResolution, so the two could drift apart independently.

Single source of truth: AppSettings.requestedResolution (a CameraResolution,
replacing the old maximumResolution Bool) is now the only persisted
selection. CameraSession.resolution is private(set); the toolbar and
Settings both write through settings.requestedResolution, and CameraView
mirrors it into the camera via the one entry point, setRequestedResolution(_:).

Truthful three-way split: ResolvedResolution captures what was requested,
what the active device/format actually resolves to (PhotoDimensions), and
whether/why that's a downgrade (unsupportedByActiveFormat vs
outputLimited) — computed by the pure, testable resolve() rule whenever
capabilities or the requested mode change. The toolbar label, the two menu
choices, and a non-modal inline "lower than requested" indicator are all
driven by this instead of a fixed "24"/"48". CaptureResolutionRecord adds a
privacy-safe (dimensions and enum labels only) record of requested/resolved/
saved dimensions after every capture, decoded from the saved bytes via
pixelDimensions(of:) without touching pixel content.

Confirmed the live filter/aspect-ratio pipeline does not silently reduce
output size beyond a deliberate ratio crop (FilterRenderer.renderedData
only crops and re-encodes; it never scales), so it's not a downgrade
source and was left untouched.

No zoom/exposure change: setRequestedResolution and
refreshLocalGuidanceAfterSettings never touch either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses four independent-review findings against 2b3af0c, without
amending it or the two cherry-picked FCL-003 commits.

1. Compile blocker: FocelleTests/SmokeTests.swift still set the deleted
   AppSettings.maximumResolution; switched to requestedResolution.

2. Legacy migration: AppSettings now reads an existing requestedResolution
   key first, otherwise maps a legacy maximumResolution Bool (true ->
   .maximum, false -> .standard) via the pure, testable
   migratedResolution(newRawValue:legacyValue:), persists the mapped value
   immediately, and drops the legacy key once it's been carried across. A
   new-format value already present is never overwritten, and repeated
   instantiation is idempotent.

3. Requested tier survives capability changes: configureCapabilities() no
   longer forces `resolution` to .standard when the active camera lacks a
   distinct maximum tier — that line was mutating the requested tier from
   inside CameraSession itself, bypassing setRequestedResolution(_:) and
   contradicting AppSettings.requestedResolution as sole intent. Only
   resolvedResolution's dimensions/downgradeReason reflect the camera's
   current limits; switching back to a capable camera resolves to maximum
   again with no further Settings/toolbar action.

4. One immutable capture snapshot: capture() now takes a single
   CaptureResolutionSnapshot (CameraSession.currentCaptureSnapshot(for:))
   on `queue` at the moment of capture, computed from queue-owned
   standardDimensions/maximumDimensions/outputLimitDimensions (now
   PhotoDimensions, replacing the old CMVideoDimensions/PhotoDimensions
   dual state) plus a capabilityGeneration bumped on every
   configureCapabilities() call. That one snapshot configures
   AVCapturePhotoSettings.maxPhotoDimensions and later becomes
   CaptureResolutionRecord — they can no longer be independently
   re-derived and disagree, and a stale generation can't leak into a
   later capture.

Also corrects docs/project-status.md, which still described FCL-004 as
unreviewed and its "changeable only through setRequestedResolution(_:)"
claim as unconditionally true — neither was accurate until this batch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant