diff --git a/package-lock.json b/package-lock.json index 5eb85bf..35a5a3c 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#1728f23d5bd64a8fedc6cad0054c8ea5fbc961b4", "react-native-vision-camera": "^5.2.0", "react-native-vision-camera-worklets": "^5.2.0", "react-native-web": "~0.21.0", @@ -72,6 +73,7 @@ "eslint-config-expo": "~57.0.0", "eslint-config-prettier": "^10.1.8", "jest": "~29.7.0", + "patch-package": "^8.0.1", "prettier": "^3.8.3", "typescript": "~6.0.3" } @@ -2547,15 +2549,15 @@ } }, "node_modules/@expo/config-plugins/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@expo/config-plugins/node_modules/glob": { @@ -2606,15 +2608,15 @@ } }, "node_modules/@expo/config/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@expo/config/node_modules/glob": { @@ -2752,15 +2754,15 @@ } }, "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@expo/fingerprint/node_modules/glob": { @@ -2927,15 +2929,15 @@ } }, "node_modules/@expo/metro-config/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@expo/metro-config/node_modules/glob": { @@ -5030,16 +5032,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -5471,6 +5473,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -6213,9 +6222,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -8881,15 +8890,15 @@ } }, "node_modules/expo/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/expo/node_modules/glob": { @@ -9170,6 +9179,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -9228,6 +9247,21 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -11157,6 +11191,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11176,6 +11230,29 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -11202,6 +11279,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12873,6 +12960,46 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13080,9 +13207,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -13582,8 +13709,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#1728f23d5bd64a8fedc6cad0054c8ea5fbc961b4", + "integrity": "sha512-XClU2y+qpKGapNnQ6I6ruuBXY65wT0sZ6+pTxLlYpWmqbCdh5rKZJ0ZYaEbtGP1CScyzIumA+QfI5ruMOsPc6A==", "license": "MIT", "workspaces": [ "example" @@ -14969,6 +15096,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15790,6 +15927,16 @@ "integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 82770f6..205ee02 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#1728f23d5bd64a8fedc6cad0054c8ea5fbc961b4", "react-native-vision-camera": "^5.2.0", "react-native-vision-camera-worklets": "^5.2.0", "react-native-web": "~0.21.0", @@ -67,6 +67,7 @@ "eslint-config-expo": "~57.0.0", "eslint-config-prettier": "^10.1.8", "jest": "~29.7.0", + "patch-package": "^8.0.1", "prettier": "^3.8.3", "typescript": "~6.0.3" }, @@ -89,7 +90,8 @@ "lint": "expo lint", "test": "jest", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "postinstall": "patch-package" }, "private": true } diff --git a/patches/react-native-vision-camera+5.2.0.patch b/patches/react-native-vision-camera+5.2.0.patch new file mode 100644 index 0000000..a707972 --- /dev/null +++ b/patches/react-native-vision-camera+5.2.0.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-vision-camera/ios/Hybrid Objects/Recording/HybridFrameRecorder.swift b/node_modules/react-native-vision-camera/ios/Hybrid Objects/Recording/HybridFrameRecorder.swift +index 14eca90..637baae 100644 +--- a/node_modules/react-native-vision-camera/ios/Hybrid Objects/Recording/HybridFrameRecorder.swift ++++ b/node_modules/react-native-vision-camera/ios/Hybrid Objects/Recording/HybridFrameRecorder.swift +@@ -48,7 +48,7 @@ final class HybridFrameRecorder: HybridRecorderSpec { + self.masterClock = masterClock + self.fileURL = try URL.createTempURL(fileType: fileType.toUTType()) + self.assetWriter = try AVAssetWriter(outputURL: fileURL, fileType: fileType.toAVFileType()) +- self.assetWriter.shouldOptimizeForNetworkUse = false ++ self.assetWriter.shouldOptimizeForNetworkUse = true + self.queue = DispatchQueue( + label: "com.margelo.camera.recorder", + qos: .utility diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index 766b903..fab7ad9 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -86,8 +86,10 @@ 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. @@ -102,6 +104,24 @@ 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; setOutputSettings preserves the bitrate configured above (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. + 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);