diff --git a/package-lock.json b/package-lock.json index 5eb85bf..f6d1cc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,15 +49,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#2a06098e160b4bb822bcfcecf867cffb0cf158ed", - "react-native-vision-camera": "^5.2.0", - "react-native-vision-camera-worklets": "^5.2.0", + "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", "react-native-worklets": "0.10.0", "whisper.rn": "0.6.0" @@ -13502,9 +13502,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": "*", @@ -13582,8 +13582,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#0abd478417ff583989e9afa78a284d2cc71b5a3b", + "integrity": "sha512-XClU2y+qpKGapNnQ6I6ruuBXY65wT0sZ6+pTxLlYpWmqbCdh5rKZJ0ZYaEbtGP1CScyzIumA+QfI5ruMOsPc6A==", "license": "MIT", "workspaces": [ "example" @@ -13594,9 +13594,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": "*", @@ -13606,9 +13606,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 82770f6..fe77a26 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#2a06098e160b4bb822bcfcecf867cffb0cf158ed", - "react-native-vision-camera": "^5.2.0", - "react-native-vision-camera-worklets": "^5.2.0", + "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", "react-native-worklets": "0.10.0", "whisper.rn": "0.6.0" diff --git a/pulsevault-mieweb b/pulsevault-mieweb index e2dfdbd..8ee8939 160000 --- a/pulsevault-mieweb +++ b/pulsevault-mieweb @@ -1 +1 @@ -Subproject commit e2dfdbd8f7ad758bf8c048a657f8ea23bd043263 +Subproject commit 8ee893931b27a8cac56d871b59befe36c4abebf8 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 diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index 766b903..bcb3293 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,60 @@ 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. + // 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. + // 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); + useEffect(() => { + if (Platform.OS !== 'ios' || !cameraReady || isRecording) return; + 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]); // 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);