From c09186fec108564a0ebbb0f25bd6393ee888473c Mon Sep 17 00:00:00 2001 From: morepriyam Date: Sun, 9 Aug 2026 04:47:12 +0530 Subject: [PATCH 1/2] fix(recorder): scope the flip cameraReady reset to Android; re-pin H.264 per connection epoch Two regressions from one root: #150 removed the accidental session restart a camera flip used to trigger (the flip's cameraReady reset flipped micWanted, rebuilding the video output and restarting the session), and two behaviors were riding it. 1. On iOS, flipCamera()'s setCameraReady(false) never re-armed: onStarted maps to AVCaptureSession.didStartRunningNotification, and an iOS device swap runs inside beginConfiguration/commitConfiguration on a RUNNING session - the notification never fires (start() even early-returns on isRunning). Record gestures (enabled: cameraReady && ...) died permanently; flipping back couldn't recover. The reset exists solely for CameraX (#133 torch-mid-rebind crash) and CameraX re-fires onStarted per bind, so it is now Android-only - mirroring the existing platform scoping of the zoom/torch prop gates (ba7647b). 2. The H.264 pin is applied per-connection natively (output.setOutputSettings(settings, for: connection)), and a flip forms a new connection - the pin died with the old one and the effect, keyed on output identity, never re-applied: post-flip clips silently recorded HEVC. The pin now re-keys on a connection epoch bumped from (fires after connections are formed on every reconfigure: cold open, enableAudio rebuild, flip), so it re-lands on the new connection with the existing bounded retry riding any race. Repro'd on TestFlight 2.0.0 (31) = main @ 82e3b95, the first build containing #150. Fixes #153 --- src/app/recorder.tsx | 2 + src/features/recorder/use-recorder.ts | 53 ++++++++++++++++++--------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/app/recorder.tsx b/src/app/recorder.tsx index 8b800a4..2c12856 100644 --- a/src/app/recorder.tsx +++ b/src/app/recorder.tsx @@ -72,6 +72,7 @@ export default function RecorderScreen() { appActive, reportMicPriorityError, onCameraReady, + onSessionConfigured, toggleRecording, finalizeRecording, importClip, @@ -419,6 +420,7 @@ export default function RecorderScreen() { // setting for video, so refocus pulls look cinematic instead of "hunting". Gated on // device support so it never throws; tap-to-focus stays snappy via `responsiveness`. enableSmoothAutoFocus={device?.supportsSmoothAutoFocus ?? false} + onConfigured={onSessionConfigured} onStarted={onCameraReady} onError={onCameraError} /> diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index 699634a..6f4f644 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -112,20 +112,23 @@ export function useRecorder(initialDraftId?: string) { }); // Force H.264 (iOS only — Android's CameraX camcorder profiles are already AVC, and its - // setOutputSettings is a native no-op). Applied once per output *instance*: the enableAudio - // flip above rebuilds the output, silently reverting the codec to the HEVC default, so this - // re-applies whenever the identity changes. Gated on cameraReady && !isRecording because + // setOutputSettings is a native no-op). Natively the codec is applied PER-CONNECTION + // (output.setOutputSettings(settings, for: connection)), and a connection is torn down and + // re-formed whenever the session reconfigures — an enableAudio output rebuild, or a camera + // flip swapping the device input. So the pin is re-applied once per *connection epoch*: + // `onConfigured` (VisionCamera's "connections are formed" hook, wired in recorder.tsx) bumps + // the epoch AFTER the new connection exists, which is the only ordering that can't lose the + // pin to a reconfigure that lands later. Gated on cameraReady && !isRecording because // mutating the settings of a session that is actively capturing is what crashed the recorder - // historically; running post-ready also means the connection exists, unlike the configure-time - // bitrate path above. setOutputSettings preserves whatever compression settings are present + // historically. setOutputSettings preserves whatever compression settings are present // (it only swaps the codec key). Failure is non-fatal — worst case that clip records HEVC, // exactly today's behavior, and the merge engine still handles it. // The ref is committed only when the native call RESOLVES: setOutputSettings runs on the - // output's own queue and throws while the rebuilt output is not yet connected — the session - // reconfigure that attaches it runs on a different queue, so on every enableAudio rebuild the - // first attempt can race it and reject. Committing eagerly would let that rejection - // permanently pin the instance to HEVC; instead a short bounded retry rides out the - // reconfigure window, and the effect cleanup cancels retries if a recording starts. + // output's own queue and throws while a rebuilt output is not yet connected — the session + // reconfigure that attaches it runs on a different queue, so the first attempt can race it + // and reject. Committing eagerly would let that rejection permanently pin the epoch to HEVC; + // instead a short bounded retry rides out the reconfigure window, and the effect cleanup + // cancels retries if a recording starts or another reconfigure supersedes this epoch. // A pin that is still in flight when recording starts cannot corrupt the capture: // setOutputSettings and createRecorder both run on the output's own serial queue // (Promise.parallel(queue) in HybridCameraVideoOutput), so the mutation and the recorder @@ -134,17 +137,19 @@ export function useRecorder(initialDraftId?: string) { // NOTE: raw per-clip files are still written moov-at-end — AVCaptureMovieFileOutput (what // createRecorder actually wraps) has no faststart API, so faststart for uploads is owned by // the merge/export layer (fork's +faststart), the upload gate (#142), and the server backstop. - const h264OutputRef = useRef(null); + const [connectionEpoch, setConnectionEpoch] = useState(0); + const h264PinnedRef = useRef<{ output: typeof videoOutput; epoch: number } | null>(null); useEffect(() => { if (Platform.OS !== 'ios' || !cameraReady || isRecording) return; const output = videoOutput; - if (h264OutputRef.current === output) return; + const pinned = h264PinnedRef.current; + if (pinned && pinned.output === output && pinned.epoch === connectionEpoch) return; let cancelled = false; let timer: ReturnType | null = null; const attempt = (retriesLeft: number) => { output.setOutputSettings({ codec: 'h264' }).then( () => { - if (!cancelled) h264OutputRef.current = output; + if (!cancelled) h264PinnedRef.current = { output, epoch: connectionEpoch }; }, (e: unknown) => { if (cancelled) return; @@ -163,7 +168,7 @@ export function useRecorder(initialDraftId?: string) { cancelled = true; if (timer) clearTimeout(timer); }; - }, [videoOutput, cameraReady, isRecording]); + }, [videoOutput, cameraReady, isRecording, connectionEpoch]); const { data: segments } = useLiveQuery(segmentsForDraft(draftId ?? ''), [draftId]); @@ -500,10 +505,17 @@ export function useRecorder(initialDraftId?: string) { } function flipCamera() { - // Re-gate zoom/torch until the flipped session has started (`onStarted` refires per device - // bind). Without this, torchMode lands on the outgoing camera mid-rebind — on Android that - // throws IllegalStateException("No flash unit") when the front camera is still bound. - setCameraReady(false); + // Re-gate zoom/torch until the flipped session has started — ANDROID ONLY. The reset exists + // for CameraX: torchMode landing on the outgoing camera mid-rebind throws + // IllegalStateException("No flash unit") when the torch-less front camera is still bound, + // and CameraX re-fires `onStarted` per device bind so the gate re-arms (verified in #133). + // On iOS the flip is an input swap inside beginConfiguration/commitConfiguration on a + // RUNNING session — didStartRunningNotification never fires, so `onStarted` never re-fires + // and a reset here would stick cameraReady=false forever, permanently disabling the record + // gestures. (Before the mic un-gating in #150 this was masked: the flip rebuilt the video + // output via micWanted, which restarted the session and re-armed the gate by accident.) + // iOS also doesn't need the gate: its zoom/torch props bind ungated (see recorder.tsx). + if (Platform.OS !== 'ios') setCameraReady(false); setFacing((prev) => { const next = prev === 'back' ? 'front' : 'back'; if (next === 'front') setTorch(false); @@ -536,6 +548,11 @@ export function useRecorder(initialDraftId?: string) { appActive, reportMicPriorityError, onCameraReady: () => setCameraReady(true), + // Wire to : fires whenever the session's connections are (re)formed — + // cold open, enableAudio output rebuild, camera flip. Bumping the epoch re-arms the H.264 + // pin for the NEW video connection (the codec is applied per-connection natively, so it + // dies with the old one on every reconfigure). + onSessionConfigured: () => setConnectionEpoch((prev) => prev + 1), toggleRecording, finalizeRecording, importClip: () => void importClip(), From 085f56d4978244c0a742aa16cde2b07148db05ad Mon Sep 17 00:00:00 2001 From: morepriyam Date: Sun, 9 Aug 2026 04:55:12 +0530 Subject: [PATCH 2/2] chore: match the flip reset to 'android' explicitly, not "everything but iOS" The guard exists for CameraX; any other platform would inherit iOS's stuck-cameraReady failure mode from a reset that never re-arms. Addresses review on #154. --- src/features/recorder/use-recorder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index 6f4f644..552741b 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -515,7 +515,9 @@ export function useRecorder(initialDraftId?: string) { // gestures. (Before the mic un-gating in #150 this was masked: the flip rebuilt the video // output via micWanted, which restarted the session and re-armed the gate by accident.) // iOS also doesn't need the gate: its zoom/torch props bind ungated (see recorder.tsx). - if (Platform.OS !== 'ios') setCameraReady(false); + // Matched to 'android' explicitly (not "everything but iOS") — the guard exists for + // CameraX, and any other platform would inherit iOS's stuck-gate failure mode instead. + if (Platform.OS === 'android') setCameraReady(false); setFacing((prev) => { const next = prev === 'back' ? 'front' : 'back'; if (next === 'front') setTorch(false);