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..552741b 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,19 @@ 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). + // 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); @@ -536,6 +550,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(),