From 80515735d761dabcc42a3aa3fc480d28c3b69c6d Mon Sep 17 00:00:00 2001 From: morepriyam Date: Wed, 5 Aug 2026 02:20:13 +0530 Subject: [PATCH 1/6] feat: standardize pipeline on H.264 for web playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recorder: force codec h264 via setOutputSettings once per video-output instance (re-applied after the enableAudio rebuild reverts it), gated on cameraReady && !isRecording so a live session is never mutated. iOS-only; Android CameraX is already AVC. HEVC recordings never decoded in Firefox and often not in Chrome. - Import normalization: drop hevc from NATIVE_VIDEO_CODECS and pin codec:'h264' on the re-encode, so HEVC imports (all iPhone Photos videos) are conformed once at import time instead of leaking into merged output. Recordings and normalized imports now share one signature, widening the merge engine's zero-re-encode fast path. - Bump react-native-video-trim to the fork commit adding +faststart to all merge/trim/compress outputs, and the pulsevault submodule to the web-ready backstop. Known limits, documented in-code (verified against VisionCamera source, measurements from #142): AVCaptureMovieFileOutput has no faststart API, so raw per-clip files stay moov-at-end — faststart is owned by the merge layer, the upload contract gate (#142), and the server backstop. The 5 Mbps targetBitRate is applied inside the session-configuration batch and can silently fail to land (~8 Mbps measured), so nothing downstream assumes it. Fixes #140 --- package-lock.json | 7 ++--- package.json | 2 +- pulsevault-mieweb | 2 +- src/features/recorder/use-recorder.ts | 37 +++++++++++++++++++++----- src/utils/import-normalization.test.ts | 8 +++--- src/utils/import-normalization.ts | 16 ++++++++--- src/utils/import-pipeline.e2e.test.ts | 7 ++--- 7 files changed, 58 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5eb85bf..98a7277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "pulse", "version": "2.0.0", + "hasInstallScript": true, "dependencies": { "@expo/ui": "~57.0.7", "@react-native-async-storage/async-storage": "2.2.0", @@ -55,7 +56,7 @@ "react-native-screens": "4.25.2", "react-native-sortables": "^1.10.0", "react-native-svg": "15.15.4", - "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#2a06098e160b4bb822bcfcecf867cffb0cf158ed", + "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", "react-native-vision-camera": "^5.2.0", "react-native-vision-camera-worklets": "^5.2.0", "react-native-web": "~0.21.0", @@ -13582,8 +13583,8 @@ }, "node_modules/react-native-video-trim": { "version": "8.3.0", - "resolved": "git+ssh://git@github.com/morepriyam/react-native-video-trim.git#2a06098e160b4bb822bcfcecf867cffb0cf158ed", - "integrity": "sha512-eHXMwyvMmcoTBcNpoiGapwOJcbVMY7b/N7PY1EjnBC30zbbyzTunTqXFyl/xM1/EOk0ahK9iFnUnD44lUzvQDw==", + "resolved": "git+ssh://git@github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", + "integrity": "sha512-XClU2y+qpKGapNnQ6I6ruuBXY65wT0sZ6+pTxLlYpWmqbCdh5rKZJ0ZYaEbtGP1CScyzIumA+QfI5ruMOsPc6A==", "license": "MIT", "workspaces": [ "example" diff --git a/package.json b/package.json index 82770f6..8281c9e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "react-native-screens": "4.25.2", "react-native-sortables": "^1.10.0", "react-native-svg": "15.15.4", - "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#2a06098e160b4bb822bcfcecf867cffb0cf158ed", + "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", "react-native-vision-camera": "^5.2.0", "react-native-vision-camera-worklets": "^5.2.0", "react-native-web": "~0.21.0", diff --git a/pulsevault-mieweb b/pulsevault-mieweb index e2dfdbd..ab27829 160000 --- a/pulsevault-mieweb +++ b/pulsevault-mieweb @@ -1 +1 @@ -Subproject commit e2dfdbd8f7ad758bf8c048a657f8ea23bd043263 +Subproject commit ab27829973132a5c74eb1141e15d52fb49931347 diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index 766b903..b2e6969 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -86,15 +86,18 @@ export function useRecorder(initialDraftId?: string) { // VisionCamera records to a file via a per-recording `Recorder` created from this output. The // output is also handed to `` in recorder.tsx. Pinned to 1080p; - // the codec stays on VisionCamera's default (HEVC on modern devices) so every clip is - // format-uniform and exports on the merge engine's zero-re-encode fast path. `fileType: 'mp4'` + // the codec is forced to H.264 below (see the setOutputSettings effect) so every clip is + // format-uniform, exports on the merge engine's zero-re-encode fast path, AND plays in every + // browser — VisionCamera's device default is HEVC on modern iPhones, which Firefox never + // decodes and Chrome usually can't without hardware support. `fileType: 'mp4'` // makes iOS write a true MP4 container (Android always does) — segments are persisted and // uploaded as `{segmentId}.mp4`, so the bytes now match the extension end to end instead of // QuickTime bytes under an .mp4 name. - // targetBitRate ~5 Mbps: the mobile-feed sweet spot for 1080p (uploads shrink 2-5× vs the - // encoder's default, playback starts faster, rebuffers less) — and since export is - // passthrough, record-time bitrate IS upload bitrate. Set here at output creation, which is - // safe — unlike mutating a running session via setOutputSettings, which crashed the recorder. + // targetBitRate ~5 Mbps: the mobile-feed sweet spot for 1080p. CAVEAT (measured on-device, + // see PR #142): VisionCamera applies this inside the session-configuration batch, where it + // can silently fail to land — real 1080p clips have probed at ~8 Mbps (the encoder default + // scaled to the pixel count). The pin stays as intent, but nothing downstream may ASSUME it: + // the upload contract gate (#142) and the pulsevault web-ready backstop own the guarantee. const videoOutput = useVideoOutput({ targetResolution: CommonResolutions.FHD_16_9, targetBitRate: 5_000_000, @@ -102,6 +105,28 @@ export function useRecorder(initialDraftId?: string) { fileType: 'mp4', }); + // 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 + // 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 + // (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. + // 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); + useEffect(() => { + if (Platform.OS !== 'ios' || !cameraReady || isRecording) return; + if (h264OutputRef.current === videoOutput) return; + h264OutputRef.current = videoOutput; + videoOutput.setOutputSettings({ codec: 'h264' }).catch((e: unknown) => { + console.warn('Failed to force H.264 on the video output; clip may record as HEVC', e); + }); + }, [videoOutput, cameraReady, isRecording]); + const { data: segments } = useLiveQuery(segmentsForDraft(draftId ?? ''), [draftId]); // Library access for the + import — granular (photo+video) like the camera/mic gate, diff --git a/src/utils/import-normalization.test.ts b/src/utils/import-normalization.test.ts index c70658c..cf4b349 100644 --- a/src/utils/import-normalization.test.ts +++ b/src/utils/import-normalization.test.ts @@ -114,9 +114,7 @@ describe('decideImport against the wild-import fixture corpus', () => { it.each([ 'mono44k-portrait-1080p-30-h264', 'ntsc-landscape-1080p-2997-h264', - 'rot270-portrait-1080p-30-hevc', 'square-720x720-30-h264', - 'timelapse-landscape-1080p-30-hevc-noaudio', 'whatsapp-848x464-30-h264-baseline', ])('%s passes through untouched', (name) => { expect(decideImport(FIXTURES[name])).toEqual({ action: 'passthrough' }); @@ -132,8 +130,10 @@ describe('decideImport against the wild-import fixture corpus', () => { }); it.each([ - ['hdr-hlg-portrait-1080p-30-hevc10', ['10-bit', 'HDR transfer arib-std-b67']], - ['hdr-pq-landscape-4k-30-hevc10', ['10-bit', 'HDR transfer smpte2084']], + ['hdr-hlg-portrait-1080p-30-hevc10', ['video codec hevc', '10-bit', 'HDR transfer arib-std-b67']], + ['hdr-pq-landscape-4k-30-hevc10', ['video codec hevc', '10-bit', 'HDR transfer smpte2084']], + ['rot270-portrait-1080p-30-hevc', ['video codec hevc']], + ['timelapse-landscape-1080p-30-hevc-noaudio', ['video codec hevc']], ['screenrec-portrait-886x1920-60-h264', ['60 fps']], ['slomo-portrait-1080p-120-h264', ['120 fps']], ['vfr-portrait-1080p-h264', ['40 fps']], diff --git a/src/utils/import-normalization.ts b/src/utils/import-normalization.ts index bd37635..ac75a42 100644 --- a/src/utils/import-normalization.ts +++ b/src/utils/import-normalization.ts @@ -8,7 +8,12 @@ import type { CompressOptions, VideoProbeResult } from 'react-native-video-trim' * every import (slow, lossy, usually pointless), only inputs that are *hostile* to the * FFmpeg merge/upload pipeline are normalized: * - * - exotic video codecs (not H.264/HEVC) — no hardware decode guarantee, merge fallback only + * - non-H.264 video codecs — HEVC included: iPhone Photos imports are HEVC, which Firefox + * never decodes and Chrome usually can't, and letting them pass through means an + * HEVC-dominated draft merges back to HEVC on iOS. The one-time re-encode here (inside + * the existing import progress UI) is what guarantees every uploaded artifact is H.264. + * It also makes imports signature-match the H.264 recorder clips, so mixed drafts hit + * the merge engine's zero-re-encode fast path instead of a selective conform. * - 10-bit / HDR (HLG, PQ) — hardware H.264 encoders reject 10-bit input; SDR displays * need the tone cast anyway once clips are mixed with SDR recordings * - display long edge > 1920 — 4K imports inflate every downstream artifact (merge output, @@ -35,8 +40,10 @@ export const NORMALIZE_TARGET_BITRATE = 5_000_000; /** Sources above this keep their size advantage from a re-encode; ~1.6x recorder rate. */ export const NORMALIZE_MAX_BITRATE = 8_000_000; -/** Video codecs the merge pipeline handles natively (hardware decode on both platforms). */ -const NATIVE_VIDEO_CODECS = new Set(['h264', 'hevc']); +/** Video codecs allowed through untouched. H.264 only: the whole pipeline (recorder, merge + * output, uploads) is standardized on H.264 for universal browser playback — HEVC imports + * are re-encoded once at import time rather than leaking into merged artifacts. */ +const NATIVE_VIDEO_CODECS = new Set(['h264']); /** HDR transfer functions: HLG (iPhone camera default) and PQ (HDR10 / Dolby Vision 8.x). */ const HDR_TRANSFERS = new Set(['arib-std-b67', 'smpte2084']); @@ -124,6 +131,9 @@ export function decideImport(probe: VideoProbeResult): ImportDecision { } const options: Partial = { + // Explicit h264: never rely on the native default staying H.264 — this is the + // pipeline-wide codec guarantee for everything that gets re-encoded. + codec: 'h264', bitrate: NORMALIZE_TARGET_BITRATE, frameRate: NORMALIZE_TARGET_FPS, }; diff --git a/src/utils/import-pipeline.e2e.test.ts b/src/utils/import-pipeline.e2e.test.ts index c5f0153..c0af5fb 100644 --- a/src/utils/import-pipeline.e2e.test.ts +++ b/src/utils/import-pipeline.e2e.test.ts @@ -290,15 +290,15 @@ function durationSec(file: string): number { const EXPECTED: Record = { 'hdr-hlg-portrait-1080p-30-hevc10.mp4': 're-encode', 'hdr-pq-landscape-4k-30-hevc10.mp4': 're-encode', + 'rot270-portrait-1080p-30-hevc.mp4': 're-encode', + 'timelapse-landscape-1080p-30-hevc-noaudio.mp4': 're-encode', 'slomo-portrait-1080p-120-h264.mp4': 're-encode', 'screenrec-portrait-886x1920-60-h264.mp4': 're-encode', 'vfr-portrait-1080p-h264.mp4': 're-encode', 'opus-landscape-1080p-30-h264.mp4': 'audio-only', 'whatsapp-848x464-30-h264-baseline.mp4': 'passthrough', 'ntsc-landscape-1080p-2997-h264.mp4': 'passthrough', - 'rot270-portrait-1080p-30-hevc.mp4': 'passthrough', 'square-720x720-30-h264.mp4': 'passthrough', - 'timelapse-landscape-1080p-30-hevc-noaudio.mp4': 'passthrough', 'mono44k-portrait-1080p-30-h264.mp4': 'passthrough', }; @@ -337,8 +337,9 @@ e2e('import pipeline e2e (probe → decide → normalize)', () => { normalizedOutputs.set(name, output); // Output invariants: what the merge/upload pipeline is promised downstream. + // Audio-less sources stay audio-less — `-c:a aac` is a no-op with no input stream. const out = probeLikeNative(output); - expect(out.audioCodec).toBe('aac'); + expect(out.audioCodec).toBe(probe.hasAudio ? 'aac' : ''); if (expected === 'audio-only') { // Video track stream-copied byte-for-byte: same codec, geometry, timing. expect(out.videoCodec).toBe(probe.videoCodec); From 591787e03ec7ec26c6c213d95bfc5e3190c56dc8 Mon Sep 17 00:00:00 2001 From: morepriyam Date: Wed, 5 Aug 2026 02:41:44 +0530 Subject: [PATCH 2/6] fix: commit the H.264 pin only when setOutputSettings resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the effect recorded the output instance in the ref before the native call resolved and never cleared it on rejection. Native-side, setOutputSettings runs on the output's own queue and throws while the output is not yet connected; the session reconfigure that attaches a rebuilt output (every enableAudio flip) runs on a different queue, so the first attempt can race it and reject — permanently pinning that instance to HEVC with only a console.warn. Now the ref is committed on resolve, rejections retry on a short bounded backoff to ride out the reconfigure window, and cleanup cancels retries if a recording starts. Also bumps the pulsevault submodule to the review-fix commit (legacy sidecar status semantics, tmp-name collision, checksum caveat). --- pulsevault-mieweb | 2 +- src/features/recorder/use-recorder.ts | 37 +++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pulsevault-mieweb b/pulsevault-mieweb index ab27829..1cf6387 160000 --- a/pulsevault-mieweb +++ b/pulsevault-mieweb @@ -1 +1 @@ -Subproject commit ab27829973132a5c74eb1141e15d52fb49931347 +Subproject commit 1cf6387ac35ce7f450d76d2d1ea3f6e1282f282d diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index b2e6969..f219489 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -114,17 +114,44 @@ export function useRecorder(initialDraftId?: string) { // bitrate path above. 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. // 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); useEffect(() => { if (Platform.OS !== 'ios' || !cameraReady || isRecording) return; - if (h264OutputRef.current === videoOutput) return; - h264OutputRef.current = videoOutput; - videoOutput.setOutputSettings({ codec: 'h264' }).catch((e: unknown) => { - console.warn('Failed to force H.264 on the video output; clip may record as HEVC', e); - }); + const output = videoOutput; + if (h264OutputRef.current === output) return; + let cancelled = false; + let timer: ReturnType | null = null; + const attempt = (retriesLeft: number) => { + output.setOutputSettings({ codec: 'h264' }).then( + () => { + if (!cancelled) h264OutputRef.current = output; + }, + (e: unknown) => { + if (cancelled) return; + if (retriesLeft > 0) { + timer = setTimeout(() => { + if (!cancelled) attempt(retriesLeft - 1); + }, 250); + } else { + console.warn('Failed to force H.264 on the video output; clip may record as HEVC', e); + } + }, + ); + }; + attempt(4); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; }, [videoOutput, cameraReady, isRecording]); const { data: segments } = useLiveQuery(segmentsForDraft(draftId ?? ''), [draftId]); From 2cb5d4767338d252119e44ff0db375005168af33 Mon Sep 17 00:00:00 2001 From: morepriyam Date: Wed, 5 Aug 2026 02:47:45 +0530 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20type=20the=20H.264=20ref,=20document=20native=20ser?= =?UTF-8?q?ialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - h264OutputRef typed to the video output instance instead of unknown. - Document why an in-flight codec pin cannot overlap an active capture: setOutputSettings and createRecorder share the output's serial native queue, so the mutation and recorder creation are ordered — worst case is a fail-open HEVC clip, never a mid-recording mutation. --- src/features/recorder/use-recorder.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index f219489..bcb3293 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -120,10 +120,15 @@ export function useRecorder(initialDraftId?: string) { // 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. + // 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 + // creation are serialized natively — the codec lands either before or after the recorder + // exists, never mid-setup. Worst case remains a fail-open HEVC clip, never a crash. // 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 h264OutputRef = useRef(null); useEffect(() => { if (Platform.OS !== 'ios' || !cameraReady || isRecording) return; const output = videoOutput; From a8c701c084d9065a5e85929cd47e8581ba252a90 Mon Sep 17 00:00:00 2001 From: morepriyam Date: Thu, 6 Aug 2026 02:40:51 +0530 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20bump=20vision-camera=20to=205.2.2?= =?UTF-8?q?=20=E2=80=94=205.2.0's=20setOutputSettings=20is=20process-fatal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-native-vision-camera 5.2.0 round-trips the fully-resolved output settings dict (including AVVideoWidthKey/AVVideoHeightKey, which AVFoundation refuses to set back), so the H.264 pin's setOutputSettings({ codec: 'h264' }) aborted the whole app with an uncatchable NSInvalidArgumentException the moment the recorder opened (upstream mrousavy/react-native-vision-camera#4037, fixed by #4081 in v5.2.1). 5.2.1 is therefore a hard floor for this branch — the 'fail-open' behavior of the pin effect only holds from there. Bumps worklets in lockstep and react-native-nitro-modules to 0.36.5 (the nitrogen version VC 5.2.2 is generated with). Verified on-device: recorder opens without crashing, clips probe h264 @ ~5.5 Mbps faststart — the 5 Mbps targetBitRate pin now actually lands, since #4081 writes codec+bitrate in one minimal dict. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98a7277..08ec41c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,15 +50,15 @@ "react-native-background-actions": "^4.1.0", "react-native-gesture-handler": "~2.32.0", "react-native-nitro-image": "^0.15.1", - "react-native-nitro-modules": "^0.35.10", + "react-native-nitro-modules": "^0.36.5", "react-native-reanimated": "4.5.0", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-sortables": "^1.10.0", "react-native-svg": "15.15.4", "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", - "react-native-vision-camera": "^5.2.0", - "react-native-vision-camera-worklets": "^5.2.0", + "react-native-vision-camera": "^5.2.2", + "react-native-vision-camera-worklets": "^5.2.2", "react-native-web": "~0.21.0", "react-native-worklets": "0.10.0", "whisper.rn": "0.6.0" @@ -13503,9 +13503,9 @@ } }, "node_modules/react-native-nitro-modules": { - "version": "0.35.10", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.10.tgz", - "integrity": "sha512-KsySOAIkbSTjNiX2GvXiNT9P5DMeZd99yvtE/+Lw7RXV7Ztxh9Z+YyAbYkBqadhN5J/KXXuPjhRYgyLOIZgsbQ==", + "version": "0.36.5", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.5.tgz", + "integrity": "sha512-SJby84+hovD70JmQ1fq+56tw3oapJeChehUqXA4Gqjm+ktJhMAcj8LGwoAjStSOUwRaK7czbzBPB0Widj+EDoQ==", "license": "MIT", "peerDependencies": { "react": "*", @@ -13595,9 +13595,9 @@ } }, "node_modules/react-native-vision-camera": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.2.0.tgz", - "integrity": "sha512-0qvR62KLj15YO0L0MeZFqiepGunDD3Ld1i6tWNtFFxZgU5jIyuTFHBI2hsFM6v1N+GaVez/qe1Jkta9rSe7BFg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.2.2.tgz", + "integrity": "sha512-oPSWDsKqZ6nmwulV0MqD0CeqDePvvq0yvC1rWUiintcBjWzqL4YXFmX4wTKwgFZ8LvwWkEMqcnOTennKQmvvdg==", "license": "MIT", "peerDependencies": { "react": "*", @@ -13607,9 +13607,9 @@ } }, "node_modules/react-native-vision-camera-worklets": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/react-native-vision-camera-worklets/-/react-native-vision-camera-worklets-5.2.0.tgz", - "integrity": "sha512-LqaHDYt+ahzDahLIlgfwpb3lhI6Y/WSVKj+BNjJOiigDmCMuM9UrCESxYIWW5gPMkye14wdDhbCqQRYVoV31OA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/react-native-vision-camera-worklets/-/react-native-vision-camera-worklets-5.2.2.tgz", + "integrity": "sha512-/Z8dxwJTOA7edPKgEANuqBigYETPqRTkG30KDOisp95e9TYvM3UIdXJHADsl3CjNdexSovMqzUo6PRVp5Oe6mQ==", "license": "MIT", "peerDependencies": { "react": "*", diff --git a/package.json b/package.json index 8281c9e..0f62f9e 100644 --- a/package.json +++ b/package.json @@ -44,15 +44,15 @@ "react-native-background-actions": "^4.1.0", "react-native-gesture-handler": "~2.32.0", "react-native-nitro-image": "^0.15.1", - "react-native-nitro-modules": "^0.35.10", + "react-native-nitro-modules": "^0.36.5", "react-native-reanimated": "4.5.0", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-sortables": "^1.10.0", "react-native-svg": "15.15.4", "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", - "react-native-vision-camera": "^5.2.0", - "react-native-vision-camera-worklets": "^5.2.0", + "react-native-vision-camera": "^5.2.2", + "react-native-vision-camera-worklets": "^5.2.2", "react-native-web": "~0.21.0", "react-native-worklets": "0.10.0", "whisper.rn": "0.6.0" From ba7647b779dc7381961d87a0bf25bad2d8fe4816 Mon Sep 17 00:00:00 2001 From: morepriyam Date: Thu, 6 Aug 2026 02:41:02 +0530 Subject: [PATCH 5/6] =?UTF-8?q?fix(recorder):=20bind=20zoom/torch=20immedi?= =?UTF-8?q?ately=20on=20iOS=20=E2=80=94=20cold=20open=20landed=20on=20the?= =?UTF-8?q?=20ultra-wide=20lens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CameraX guard from the Android parity work gated the zoom/torchMode props on cameraReady, which only flips at onStarted. On iOS that left the session with no zoom bound at startup, so fused multi-cam iPhones opened on the native default lens (ultra-wide — dark preview) and visibly snapped to 1x once the gate opened (TestFlight builds 27/28; build 26 bound zoom from the first frame and was fine). The guard exists purely for CameraX (OperationCanceledException on control calls before the session starts — iOS quietly tolerates them), so scope it to Android and restore the immediate binding on iOS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/recorder.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app/recorder.tsx b/src/app/recorder.tsx index be54715..8b800a4 100644 --- a/src/app/recorder.tsx +++ b/src/app/recorder.tsx @@ -1,6 +1,6 @@ import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, StyleSheet, Text, View } from 'react-native'; +import { Alert, Platform, StyleSheet, Text, View } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -395,17 +395,24 @@ export default function RecorderScreen() { device={device} isActive={cameraActive} outputs={outputs} - // Zoom/torch are gated until the session has started: CameraX rejects control calls on - // an inactive camera (OperationCanceledException) where iOS quietly tolerates them, so - // applying these props on mount — before `onStarted` — throws unhandled rejections on - // Android. `undefined` makes VisionCamera's updater hooks skip the native call; the - // shared-value binding still applies the current zoom the moment it attaches. - zoom={cameraReady ? zoomSv : undefined} + // Zoom/torch are gated until the session has started — but only on Android: CameraX + // rejects control calls on an inactive camera (OperationCanceledException), so applying + // these props on mount — before `onStarted` — throws unhandled rejections there. + // `undefined` makes VisionCamera's updater hooks skip the native call; the shared-value + // binding still applies the current zoom the moment it attaches. iOS quietly tolerates + // early calls and NEEDS the immediate binding: without it the session opens on the + // native default lens (ultra-wide on fused multi-cam iPhones — dark preview) and then + // visibly snaps to 1x when the gate opens at `onStarted`. + zoom={Platform.OS === 'ios' || cameraReady ? zoomSv : undefined} // ...and torch is additionally gated on hardware: writing any torchMode (even 'off') to // a torch-less camera (typically the front one) throws IllegalStateException("No flash // unit") on Android, while iOS silently ignores it. torchMode={ - cameraReady && device.hasTorch ? (torch && !previewing ? 'on' : 'off') : undefined + (Platform.OS === 'ios' || cameraReady) && device.hasTorch + ? torch && !previewing + ? 'on' + : 'off' + : undefined } constraints={constraints} // Smooth (rather than snapping) continuous-AF transitions — VisionCamera's recommended From 29edbef9345d1426ddf67850baffebb0879e16b6 Mon Sep 17 00:00:00 2001 From: morepriyam Date: Thu, 6 Aug 2026 20:03:58 +0530 Subject: [PATCH 6/6] chore: re-pin video-trim and pulsevault to their merge commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit morepriyam/react-native-video-trim#4 (faststart on all MP4/MOV outputs) and mieweb/pulsevault#58 (server-side web-ready backstop) have landed; move the git dep and the submodule from the PR-head SHAs to the merge commits on their default branches, as this PR's description planned. Content is unchanged — both merges were fast-forwards of the tested heads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 5 ++--- package.json | 2 +- pulsevault-mieweb | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 08ec41c..f6d1cc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "pulse", "version": "2.0.0", - "hasInstallScript": true, "dependencies": { "@expo/ui": "~57.0.7", "@react-native-async-storage/async-storage": "2.2.0", @@ -56,7 +55,7 @@ "react-native-screens": "4.25.2", "react-native-sortables": "^1.10.0", "react-native-svg": "15.15.4", - "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", + "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#0abd478417ff583989e9afa78a284d2cc71b5a3b", "react-native-vision-camera": "^5.2.2", "react-native-vision-camera-worklets": "^5.2.2", "react-native-web": "~0.21.0", @@ -13583,7 +13582,7 @@ }, "node_modules/react-native-video-trim": { "version": "8.3.0", - "resolved": "git+ssh://git@github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", + "resolved": "git+ssh://git@github.com/morepriyam/react-native-video-trim.git#0abd478417ff583989e9afa78a284d2cc71b5a3b", "integrity": "sha512-XClU2y+qpKGapNnQ6I6ruuBXY65wT0sZ6+pTxLlYpWmqbCdh5rKZJ0ZYaEbtGP1CScyzIumA+QfI5ruMOsPc6A==", "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 0f62f9e..fe77a26 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "react-native-screens": "4.25.2", "react-native-sortables": "^1.10.0", "react-native-svg": "15.15.4", - "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#9cca4c537acf137115743db8d1e37b1aab248490", + "react-native-video-trim": "git+https://github.com/morepriyam/react-native-video-trim.git#0abd478417ff583989e9afa78a284d2cc71b5a3b", "react-native-vision-camera": "^5.2.2", "react-native-vision-camera-worklets": "^5.2.2", "react-native-web": "~0.21.0", diff --git a/pulsevault-mieweb b/pulsevault-mieweb index 1cf6387..8ee8939 160000 --- a/pulsevault-mieweb +++ b/pulsevault-mieweb @@ -1 +1 @@ -Subproject commit 1cf6387ac35ce7f450d76d2d1ea3f6e1282f282d +Subproject commit 8ee893931b27a8cac56d871b59befe36c4abebf8