Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 4 additions & 3 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 31 additions & 6 deletions src/features/recorder/use-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,47 @@ 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.
// 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<unknown>(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);
});
Comment thread
morepriyam marked this conversation as resolved.
Outdated
}, [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