Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 15 additions & 8 deletions src/app/recorder.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
69 changes: 63 additions & 6 deletions src/features/recorder/use-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,79 @@ 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 `<Camera outputs={[videoOutput]}>` 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,
enableAudio: micEnabled,
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<typeof videoOutput | null>(null);
useEffect(() => {
if (Platform.OS !== 'ios' || !cameraReady || isRecording) return;
const output = videoOutput;
if (h264OutputRef.current === output) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | 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,
Expand Down
8 changes: 4 additions & 4 deletions src/utils/import-normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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']],
Expand Down
16 changes: 13 additions & 3 deletions src/utils/import-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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']);

Expand Down Expand Up @@ -124,6 +131,9 @@ export function decideImport(probe: VideoProbeResult): ImportDecision {
}

const options: Partial<CompressOptions> = {
// 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,
};
Expand Down
7 changes: 4 additions & 3 deletions src/utils/import-pipeline.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,15 +290,15 @@ function durationSec(file: string): number {
const EXPECTED: Record<string, 'passthrough' | 'audio-only' | 're-encode'> = {
'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',
};

Expand Down Expand Up @@ -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);
Expand Down