react-native-ensys-camera-x 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (506) hide show
  1. package/CustomCamera.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +833 -0
  4. package/android/build.gradle +135 -0
  5. package/android/consumer-rules.pro +47 -0
  6. package/android/src/main/AndroidManifest.xml +72 -0
  7. package/android/src/main/java/android/print/PdfPrint.java +51 -0
  8. package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
  9. package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
  10. package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
  11. package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
  12. package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
  13. package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
  14. package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
  15. package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
  16. package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
  17. package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
  18. package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
  19. package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
  20. package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
  21. package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
  22. package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
  23. package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
  24. package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
  25. package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
  26. package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
  27. package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
  28. package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
  29. package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
  30. package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
  31. package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
  32. package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
  33. package/android/src/main/java/com/customcamera/Localization.kt +206 -0
  34. package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
  35. package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
  36. package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
  37. package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
  38. package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
  39. package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
  40. package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
  41. package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
  42. package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
  43. package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
  44. package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
  45. package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
  46. package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
  47. package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
  48. package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
  49. package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
  50. package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
  51. package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
  52. package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
  53. package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
  54. package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
  55. package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
  56. package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
  57. package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
  58. package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
  59. package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
  60. package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
  61. package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
  62. package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
  63. package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
  64. package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
  65. package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
  66. package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
  67. package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
  68. package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
  69. package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
  70. package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
  71. package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
  72. package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
  73. package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
  74. package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
  75. package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
  76. package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
  77. package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
  78. package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
  79. package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
  80. package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
  81. package/android/src/main/res/drawable/bg_pill.xml +6 -0
  82. package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
  83. package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
  84. package/android/src/main/res/drawable/bg_send_button.xml +5 -0
  85. package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
  86. package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
  87. package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
  88. package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
  89. package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
  90. package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
  91. package/android/src/main/res/drawable/ic_audio.xml +10 -0
  92. package/android/src/main/res/drawable/ic_back.xml +10 -0
  93. package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
  94. package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
  95. package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
  96. package/android/src/main/res/drawable/ic_check.xml +17 -0
  97. package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
  98. package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
  99. package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
  100. package/android/src/main/res/drawable/ic_close.xml +10 -0
  101. package/android/src/main/res/drawable/ic_cover.xml +9 -0
  102. package/android/src/main/res/drawable/ic_crop.xml +10 -0
  103. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  104. package/android/src/main/res/drawable/ic_document.xml +10 -0
  105. package/android/src/main/res/drawable/ic_done.xml +10 -0
  106. package/android/src/main/res/drawable/ic_download.xml +10 -0
  107. package/android/src/main/res/drawable/ic_emoji.xml +10 -0
  108. package/android/src/main/res/drawable/ic_filter.xml +10 -0
  109. package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
  110. package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
  111. package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
  112. package/android/src/main/res/drawable/ic_flip.xml +15 -0
  113. package/android/src/main/res/drawable/ic_gallery.xml +10 -0
  114. package/android/src/main/res/drawable/ic_gif.xml +24 -0
  115. package/android/src/main/res/drawable/ic_lock.xml +19 -0
  116. package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
  117. package/android/src/main/res/drawable/ic_marker.xml +10 -0
  118. package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
  119. package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
  120. package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
  121. package/android/src/main/res/drawable/ic_pause.xml +10 -0
  122. package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
  123. package/android/src/main/res/drawable/ic_play.xml +10 -0
  124. package/android/src/main/res/drawable/ic_redo.xml +10 -0
  125. package/android/src/main/res/drawable/ic_rotate.xml +9 -0
  126. package/android/src/main/res/drawable/ic_send.xml +10 -0
  127. package/android/src/main/res/drawable/ic_share.xml +10 -0
  128. package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
  129. package/android/src/main/res/drawable/ic_sticker.xml +24 -0
  130. package/android/src/main/res/drawable/ic_text.xml +10 -0
  131. package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
  132. package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
  133. package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
  134. package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
  135. package/android/src/main/res/drawable/ic_trim.xml +23 -0
  136. package/android/src/main/res/drawable/ic_undo.xml +10 -0
  137. package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
  138. package/android/src/main/res/drawable/ic_video_note.xml +13 -0
  139. package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
  140. package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
  141. package/android/src/main/res/drawable/sticker_heart.xml +9 -0
  142. package/android/src/main/res/drawable/sticker_smile.xml +9 -0
  143. package/android/src/main/res/drawable/sticker_star.xml +9 -0
  144. package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
  145. package/android/src/main/res/values/colors.xml +34 -0
  146. package/android/src/main/res/values/ids.xml +15 -0
  147. package/android/src/main/res/values/strings.xml +15 -0
  148. package/android/src/main/res/values/styles.xml +20 -0
  149. package/android/src/main/res/xml/file_paths.xml +34 -0
  150. package/ios/CustomCamera.h +5 -0
  151. package/ios/CustomCamera.mm +214 -0
  152. package/lib/module/AudioPlayerContext.js +233 -0
  153. package/lib/module/AudioPlayerContext.js.map +1 -0
  154. package/lib/module/CameraPreview.js +304 -0
  155. package/lib/module/CameraPreview.js.map +1 -0
  156. package/lib/module/NativeAnimatedImageView.js +41 -0
  157. package/lib/module/NativeAnimatedImageView.js.map +1 -0
  158. package/lib/module/NativeAudioPlayerView.js +33 -0
  159. package/lib/module/NativeAudioPlayerView.js.map +1 -0
  160. package/lib/module/NativeBlurView.js +35 -0
  161. package/lib/module/NativeBlurView.js.map +1 -0
  162. package/lib/module/NativeCustomCamera.js +128 -0
  163. package/lib/module/NativeCustomCamera.js.map +1 -0
  164. package/lib/module/NativeCustomCamera.web.js +143 -0
  165. package/lib/module/NativeCustomCamera.web.js.map +1 -0
  166. package/lib/module/NativeCustomVideoView.js +5 -0
  167. package/lib/module/NativeCustomVideoView.js.map +1 -0
  168. package/lib/module/NativeDocumentPreviewView.js +156 -0
  169. package/lib/module/NativeDocumentPreviewView.js.map +1 -0
  170. package/lib/module/NativeMediaThumbnailView.js +110 -0
  171. package/lib/module/NativeMediaThumbnailView.js.map +1 -0
  172. package/lib/module/NativeSvgIcon.js +32 -0
  173. package/lib/module/NativeSvgIcon.js.map +1 -0
  174. package/lib/module/ResponsivePreviewContainer.js +171 -0
  175. package/lib/module/ResponsivePreviewContainer.js.map +1 -0
  176. package/lib/module/accessibility.js +61 -0
  177. package/lib/module/accessibility.js.map +1 -0
  178. package/lib/module/config.js +352 -0
  179. package/lib/module/config.js.map +1 -0
  180. package/lib/module/cpuOptimization.js +296 -0
  181. package/lib/module/cpuOptimization.js.map +1 -0
  182. package/lib/module/db/DatabaseManager.js +191 -0
  183. package/lib/module/db/DatabaseManager.js.map +1 -0
  184. package/lib/module/db/MediaMapper.js +284 -0
  185. package/lib/module/db/MediaMapper.js.map +1 -0
  186. package/lib/module/db/MediaRepository.js +242 -0
  187. package/lib/module/db/MediaRepository.js.map +1 -0
  188. package/lib/module/db/index.js +41 -0
  189. package/lib/module/db/index.js.map +1 -0
  190. package/lib/module/db/migrations.js +180 -0
  191. package/lib/module/db/migrations.js.map +1 -0
  192. package/lib/module/db/react-native-sqlcipher.d.js +2 -0
  193. package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
  194. package/lib/module/db/secureKey.js +87 -0
  195. package/lib/module/db/secureKey.js.map +1 -0
  196. package/lib/module/db/types.js +57 -0
  197. package/lib/module/db/types.js.map +1 -0
  198. package/lib/module/db/useMediaDatabase.js +123 -0
  199. package/lib/module/db/useMediaDatabase.js.map +1 -0
  200. package/lib/module/defaultIcons.js +2259 -0
  201. package/lib/module/defaultIcons.js.map +1 -0
  202. package/lib/module/deviceCapabilities.js +408 -0
  203. package/lib/module/deviceCapabilities.js.map +1 -0
  204. package/lib/module/errors.js +267 -0
  205. package/lib/module/errors.js.map +1 -0
  206. package/lib/module/iconConfig.js +796 -0
  207. package/lib/module/iconConfig.js.map +1 -0
  208. package/lib/module/icons.js +514 -0
  209. package/lib/module/icons.js.map +1 -0
  210. package/lib/module/index.js +251 -0
  211. package/lib/module/index.js.map +1 -0
  212. package/lib/module/jest-globals.d.js +2 -0
  213. package/lib/module/jest-globals.d.js.map +1 -0
  214. package/lib/module/library/MediaLibrary.js +107 -0
  215. package/lib/module/library/MediaLibrary.js.map +1 -0
  216. package/lib/module/library/MediaLibraryScreen.js +2597 -0
  217. package/lib/module/library/MediaLibraryScreen.js.map +1 -0
  218. package/lib/module/library/components/AudioPreview.js +74 -0
  219. package/lib/module/library/components/AudioPreview.js.map +1 -0
  220. package/lib/module/library/components/DocumentPreview.js +331 -0
  221. package/lib/module/library/components/DocumentPreview.js.map +1 -0
  222. package/lib/module/library/components/ExpandableFab.js +343 -0
  223. package/lib/module/library/components/ExpandableFab.js.map +1 -0
  224. package/lib/module/library/components/FileTiles.js +251 -0
  225. package/lib/module/library/components/FileTiles.js.map +1 -0
  226. package/lib/module/library/components/ImagePreview.js +70 -0
  227. package/lib/module/library/components/ImagePreview.js.map +1 -0
  228. package/lib/module/library/components/MediaMetadataHeader.js +175 -0
  229. package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
  230. package/lib/module/library/components/RecordAudioScreen.js +816 -0
  231. package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
  232. package/lib/module/library/components/VideoPreview.js +453 -0
  233. package/lib/module/library/components/VideoPreview.js.map +1 -0
  234. package/lib/module/library/components/ViewerCounter.js +55 -0
  235. package/lib/module/library/components/ViewerCounter.js.map +1 -0
  236. package/lib/module/library/compressionEstimate.js +507 -0
  237. package/lib/module/library/compressionEstimate.js.map +1 -0
  238. package/lib/module/library/fileMetadata.js +196 -0
  239. package/lib/module/library/fileMetadata.js.map +1 -0
  240. package/lib/module/library/index.js +42 -0
  241. package/lib/module/library/index.js.map +1 -0
  242. package/lib/module/library/libraryIconRegistry.js +69 -0
  243. package/lib/module/library/libraryIconRegistry.js.map +1 -0
  244. package/lib/module/library/libraryIcons.js +858 -0
  245. package/lib/module/library/libraryIcons.js.map +1 -0
  246. package/lib/module/library/localizedFormat.js +181 -0
  247. package/lib/module/library/localizedFormat.js.map +1 -0
  248. package/lib/module/library/palette.js +225 -0
  249. package/lib/module/library/palette.js.map +1 -0
  250. package/lib/module/library/responsive.js +53 -0
  251. package/lib/module/library/responsive.js.map +1 -0
  252. package/lib/module/locales.js +416 -0
  253. package/lib/module/locales.js.map +1 -0
  254. package/lib/module/localization.js +1064 -0
  255. package/lib/module/localization.js.map +1 -0
  256. package/lib/module/logger.js +20 -0
  257. package/lib/module/logger.js.map +1 -0
  258. package/lib/module/lowMemoryCache.js +206 -0
  259. package/lib/module/lowMemoryCache.js.map +1 -0
  260. package/lib/module/mediaHistory.js +33 -0
  261. package/lib/module/mediaHistory.js.map +1 -0
  262. package/lib/module/mediaStorageService.js +402 -0
  263. package/lib/module/mediaStorageService.js.map +1 -0
  264. package/lib/module/memory.js +79 -0
  265. package/lib/module/memory.js.map +1 -0
  266. package/lib/module/nativeActions.js +307 -0
  267. package/lib/module/nativeActions.js.map +1 -0
  268. package/lib/module/nativeComponentSupport.js +61 -0
  269. package/lib/module/nativeComponentSupport.js.map +1 -0
  270. package/lib/module/networkPerformance.js +24 -0
  271. package/lib/module/networkPerformance.js.map +1 -0
  272. package/lib/module/package.json +1 -0
  273. package/lib/module/performance.js +104 -0
  274. package/lib/module/performance.js.map +1 -0
  275. package/lib/module/performanceMonitor.js +155 -0
  276. package/lib/module/performanceMonitor.js.map +1 -0
  277. package/lib/module/permissions.js +370 -0
  278. package/lib/module/permissions.js.map +1 -0
  279. package/lib/module/rendering.js +40 -0
  280. package/lib/module/rendering.js.map +1 -0
  281. package/lib/module/security.js +18 -0
  282. package/lib/module/security.js.map +1 -0
  283. package/lib/module/state.js +266 -0
  284. package/lib/module/state.js.map +1 -0
  285. package/lib/module/storage.js +400 -0
  286. package/lib/module/storage.js.map +1 -0
  287. package/lib/module/theme.js +390 -0
  288. package/lib/module/theme.js.map +1 -0
  289. package/lib/module/types.js +4 -0
  290. package/lib/module/types.js.map +1 -0
  291. package/lib/module/utils.js +115 -0
  292. package/lib/module/utils.js.map +1 -0
  293. package/lib/module/validation.js +288 -0
  294. package/lib/module/validation.js.map +1 -0
  295. package/lib/typescript/package.json +1 -0
  296. package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
  297. package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
  298. package/lib/typescript/src/CameraPreview.d.ts +13 -0
  299. package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
  300. package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
  301. package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
  302. package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
  303. package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
  304. package/lib/typescript/src/NativeBlurView.d.ts +12 -0
  305. package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
  306. package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
  307. package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
  308. package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
  309. package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
  310. package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
  311. package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
  312. package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
  313. package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
  314. package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
  315. package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
  316. package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
  317. package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
  318. package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
  319. package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
  320. package/lib/typescript/src/accessibility.d.ts +20 -0
  321. package/lib/typescript/src/accessibility.d.ts.map +1 -0
  322. package/lib/typescript/src/config.d.ts +145 -0
  323. package/lib/typescript/src/config.d.ts.map +1 -0
  324. package/lib/typescript/src/cpuOptimization.d.ts +63 -0
  325. package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
  326. package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
  327. package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
  328. package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
  329. package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
  330. package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
  331. package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
  332. package/lib/typescript/src/db/index.d.ts +23 -0
  333. package/lib/typescript/src/db/index.d.ts.map +1 -0
  334. package/lib/typescript/src/db/migrations.d.ts +31 -0
  335. package/lib/typescript/src/db/migrations.d.ts.map +1 -0
  336. package/lib/typescript/src/db/secureKey.d.ts +10 -0
  337. package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
  338. package/lib/typescript/src/db/types.d.ts +115 -0
  339. package/lib/typescript/src/db/types.d.ts.map +1 -0
  340. package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
  341. package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
  342. package/lib/typescript/src/defaultIcons.d.ts +139 -0
  343. package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
  344. package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
  345. package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
  346. package/lib/typescript/src/errors.d.ts +95 -0
  347. package/lib/typescript/src/errors.d.ts.map +1 -0
  348. package/lib/typescript/src/iconConfig.d.ts +176 -0
  349. package/lib/typescript/src/iconConfig.d.ts.map +1 -0
  350. package/lib/typescript/src/icons.d.ts +217 -0
  351. package/lib/typescript/src/icons.d.ts.map +1 -0
  352. package/lib/typescript/src/index.d.ts +104 -0
  353. package/lib/typescript/src/index.d.ts.map +1 -0
  354. package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
  355. package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
  356. package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
  357. package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
  358. package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
  359. package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
  360. package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
  361. package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
  362. package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
  363. package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
  364. package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
  365. package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
  366. package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
  367. package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
  368. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
  369. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
  370. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
  371. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
  372. package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
  373. package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
  374. package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
  375. package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
  376. package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
  377. package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
  378. package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
  379. package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
  380. package/lib/typescript/src/library/index.d.ts +40 -0
  381. package/lib/typescript/src/library/index.d.ts.map +1 -0
  382. package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
  383. package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
  384. package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
  385. package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
  386. package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
  387. package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
  388. package/lib/typescript/src/library/palette.d.ts +104 -0
  389. package/lib/typescript/src/library/palette.d.ts.map +1 -0
  390. package/lib/typescript/src/library/responsive.d.ts +15 -0
  391. package/lib/typescript/src/library/responsive.d.ts.map +1 -0
  392. package/lib/typescript/src/locales.d.ts +103 -0
  393. package/lib/typescript/src/locales.d.ts.map +1 -0
  394. package/lib/typescript/src/localization.d.ts +1051 -0
  395. package/lib/typescript/src/localization.d.ts.map +1 -0
  396. package/lib/typescript/src/logger.d.ts +4 -0
  397. package/lib/typescript/src/logger.d.ts.map +1 -0
  398. package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
  399. package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
  400. package/lib/typescript/src/mediaHistory.d.ts +3 -0
  401. package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
  402. package/lib/typescript/src/mediaStorageService.d.ts +155 -0
  403. package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
  404. package/lib/typescript/src/memory.d.ts +21 -0
  405. package/lib/typescript/src/memory.d.ts.map +1 -0
  406. package/lib/typescript/src/nativeActions.d.ts +174 -0
  407. package/lib/typescript/src/nativeActions.d.ts.map +1 -0
  408. package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
  409. package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
  410. package/lib/typescript/src/networkPerformance.d.ts +12 -0
  411. package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
  412. package/lib/typescript/src/performance.d.ts +32 -0
  413. package/lib/typescript/src/performance.d.ts.map +1 -0
  414. package/lib/typescript/src/performanceMonitor.d.ts +45 -0
  415. package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
  416. package/lib/typescript/src/permissions.d.ts +132 -0
  417. package/lib/typescript/src/permissions.d.ts.map +1 -0
  418. package/lib/typescript/src/rendering.d.ts +9 -0
  419. package/lib/typescript/src/rendering.d.ts.map +1 -0
  420. package/lib/typescript/src/security.d.ts +6 -0
  421. package/lib/typescript/src/security.d.ts.map +1 -0
  422. package/lib/typescript/src/state.d.ts +102 -0
  423. package/lib/typescript/src/state.d.ts.map +1 -0
  424. package/lib/typescript/src/storage.d.ts +117 -0
  425. package/lib/typescript/src/storage.d.ts.map +1 -0
  426. package/lib/typescript/src/theme.d.ts +15 -0
  427. package/lib/typescript/src/theme.d.ts.map +1 -0
  428. package/lib/typescript/src/types.d.ts +802 -0
  429. package/lib/typescript/src/types.d.ts.map +1 -0
  430. package/lib/typescript/src/utils.d.ts +50 -0
  431. package/lib/typescript/src/utils.d.ts.map +1 -0
  432. package/lib/typescript/src/validation.d.ts +52 -0
  433. package/lib/typescript/src/validation.d.ts.map +1 -0
  434. package/package.json +204 -0
  435. package/react-native.config.js +17 -0
  436. package/src/AudioPlayerContext.tsx +220 -0
  437. package/src/CameraPreview.tsx +352 -0
  438. package/src/NativeAnimatedImageView.tsx +71 -0
  439. package/src/NativeAudioPlayerView.tsx +39 -0
  440. package/src/NativeBlurView.tsx +40 -0
  441. package/src/NativeCustomCamera.ts +305 -0
  442. package/src/NativeCustomCamera.web.ts +192 -0
  443. package/src/NativeCustomVideoView.ts +30 -0
  444. package/src/NativeDocumentPreviewView.tsx +196 -0
  445. package/src/NativeMediaThumbnailView.tsx +167 -0
  446. package/src/NativeSvgIcon.tsx +46 -0
  447. package/src/ResponsivePreviewContainer.tsx +227 -0
  448. package/src/accessibility.ts +80 -0
  449. package/src/config.ts +406 -0
  450. package/src/cpuOptimization.ts +365 -0
  451. package/src/db/DatabaseManager.ts +212 -0
  452. package/src/db/MediaMapper.ts +377 -0
  453. package/src/db/MediaRepository.ts +313 -0
  454. package/src/db/index.ts +73 -0
  455. package/src/db/migrations.ts +222 -0
  456. package/src/db/react-native-sqlcipher.d.ts +76 -0
  457. package/src/db/secureKey.ts +100 -0
  458. package/src/db/types.ts +123 -0
  459. package/src/db/useMediaDatabase.ts +191 -0
  460. package/src/defaultIcons.tsx +2390 -0
  461. package/src/deviceCapabilities.ts +438 -0
  462. package/src/errors.ts +350 -0
  463. package/src/iconConfig.tsx +962 -0
  464. package/src/icons.tsx +788 -0
  465. package/src/index.tsx +763 -0
  466. package/src/jest-globals.d.ts +9 -0
  467. package/src/library/MediaLibrary.tsx +141 -0
  468. package/src/library/MediaLibraryScreen.tsx +3003 -0
  469. package/src/library/components/AudioPreview.tsx +73 -0
  470. package/src/library/components/DocumentPreview.tsx +361 -0
  471. package/src/library/components/ExpandableFab.tsx +342 -0
  472. package/src/library/components/FileTiles.tsx +252 -0
  473. package/src/library/components/ImagePreview.tsx +72 -0
  474. package/src/library/components/MediaMetadataHeader.tsx +208 -0
  475. package/src/library/components/RecordAudioScreen.tsx +845 -0
  476. package/src/library/components/VideoPreview.tsx +478 -0
  477. package/src/library/components/ViewerCounter.tsx +71 -0
  478. package/src/library/compressionEstimate.ts +662 -0
  479. package/src/library/fileMetadata.ts +212 -0
  480. package/src/library/index.ts +66 -0
  481. package/src/library/libraryIconRegistry.tsx +86 -0
  482. package/src/library/libraryIcons.tsx +928 -0
  483. package/src/library/localizedFormat.ts +207 -0
  484. package/src/library/palette.ts +281 -0
  485. package/src/library/responsive.ts +49 -0
  486. package/src/locales.ts +381 -0
  487. package/src/localization.ts +1947 -0
  488. package/src/logger.ts +20 -0
  489. package/src/lowMemoryCache.ts +218 -0
  490. package/src/mediaHistory.ts +42 -0
  491. package/src/mediaStorageService.ts +546 -0
  492. package/src/memory.ts +102 -0
  493. package/src/nativeActions.ts +422 -0
  494. package/src/nativeComponentSupport.ts +60 -0
  495. package/src/networkPerformance.ts +30 -0
  496. package/src/performance.ts +137 -0
  497. package/src/performanceMonitor.ts +219 -0
  498. package/src/permissions.ts +403 -0
  499. package/src/rendering.ts +47 -0
  500. package/src/security.ts +19 -0
  501. package/src/state.ts +333 -0
  502. package/src/storage.ts +453 -0
  503. package/src/theme.tsx +467 -0
  504. package/src/types.ts +946 -0
  505. package/src/utils.ts +177 -0
  506. package/src/validation.ts +372 -0
@@ -0,0 +1,1992 @@
1
+ package com.customcamera
2
+
3
+ import android.content.Context
4
+ import android.graphics.SurfaceTexture
5
+ import android.media.MediaCodec
6
+ import android.media.MediaCodecInfo
7
+ import android.media.MediaExtractor
8
+ import android.media.MediaFormat
9
+ import android.media.MediaMuxer
10
+ import android.net.Uri
11
+ import android.opengl.EGL14
12
+ import android.opengl.EGLConfig
13
+ import android.opengl.EGLContext
14
+ import android.opengl.EGLDisplay
15
+ import android.opengl.EGLExt
16
+ import android.opengl.EGLSurface
17
+ import android.opengl.GLES11Ext
18
+ import android.opengl.GLES20
19
+ import android.opengl.Matrix
20
+ import android.os.SystemClock
21
+ import android.util.Log
22
+ import android.view.Surface
23
+ import java.io.File
24
+ import java.nio.ByteBuffer
25
+ import java.nio.ByteOrder
26
+ import java.nio.FloatBuffer
27
+
28
+ /**
29
+ * Physically rotates every frame of a captured video so the recorded scene is stored
30
+ * UPRIGHT in the pixels themselves — not via a display transform or an orientation
31
+ * metadata flag. This is the video counterpart to the still-photo pixel rotation done in
32
+ * [CustomCameraActivity.normalizeCapturedPhotoOrientation]: the encoded frames are
33
+ * decoded, rotated on the GPU, and re-encoded, and the container's rotation metadata is
34
+ * reset to 0 so no downstream consumer double-rotates. The single re-encoded file is then
35
+ * used everywhere (editing preview, full-screen viewer, final review, export/send), so
36
+ * every stage shows the identical upright video.
37
+ *
38
+ * Pipeline (the classic decode → OpenGL → encode → mux transcode, no third-party lib):
39
+ * MediaExtractor → MediaCodec decoder → SurfaceTexture (external texture)
40
+ * → OpenGL ES rotation → MediaCodec encoder input Surface → MediaCodec encoder
41
+ * → MediaMuxer (rotated video track + the ORIGINAL audio track copied through
42
+ * sample-for-sample so audio, its timestamps, and A/V sync are bit-exact).
43
+ *
44
+ * Quality is preserved: resolution is carried over exactly (only width/height are swapped
45
+ * for a 90°/270° rotation), the source frame rate and bit rate are reused, every frame is
46
+ * rendered (none dropped), presentation timestamps are copied verbatim (so duration and
47
+ * sync are unchanged), and there is no cropping, scaling, or stretching — the rotation is
48
+ * a pure 90°-multiple remap onto an equally sized surface.
49
+ *
50
+ * The description above is [rotate]'s contract. [compress] reuses the same pipeline for a very
51
+ * different job and is deliberately ORIENTATION-NEUTRAL: it scales the frame in its coded
52
+ * orientation and copies the source's rotation metadata onto the output, so a compressed clip is
53
+ * displayed exactly like the clip it came from. Captured video stores its orientation as
54
+ * metadata rather than in the pixels, so compression must not be the one operation that changes
55
+ * that — see [compress] for why baking it there mis-shaped the compressed preview.
56
+ */
57
+ object VideoRotationTranscoder {
58
+ private const val TAG = "VideoRotationTranscoder"
59
+ private const val TIMEOUT_US = 10_000L
60
+ private const val DEFAULT_FRAME_RATE = 30
61
+ private const val DEFAULT_I_FRAME_INTERVAL = 1
62
+ // The HEVC encode target a profile may prefer over the source codec. A literal (not
63
+ // MediaFormat.MIMETYPE_VIDEO_HEVC) so this file compiles identically on any compileSdk; the
64
+ // resolver only sets [VideoCompressionPlan.preferHevc] when the device actually has this encoder.
65
+ private const val MIME_VIDEO_HEVC = "video/hevc"
66
+ // Reasonable bit-rate floor so a source that does not advertise one is still encoded at
67
+ // high quality (≈ 0.25 bits per pixel per frame at 30fps for 1080p ≈ 15 Mbps).
68
+ private const val FALLBACK_BPP = 0.25
69
+ // Smallest edge any encoded output may have. Applied to the SCALE (see compress), never to a
70
+ // single edge, so honouring it can never change the frame's aspect ratio.
71
+ private const val MIN_ENCODED_EDGE = 160
72
+
73
+ // ── Compression targets ─────────────────────────────────────────────────────────────────
74
+ // The band [compress] clamps its encoder bit rate into, and the lowest quality ratio it
75
+ // honours. Named rather than left inline in [compress] because the size a menu quotes for a
76
+ // level has to be computed from the SAME numbers the encoder is configured with (see
77
+ // [estimateCompressedBytes]); a prediction built on a hand-copied second set of limits drifts
78
+ // away from the encoder the moment either one is touched.
79
+ private const val MIN_COMPRESSED_BIT_RATE = 200_000
80
+ private const val MAX_COMPRESSED_BIT_RATE = 25_000_000
81
+ // Quality floor. Keep the 99% menu level representable instead of collapsing the 80%..99%
82
+ // ladder onto one ratio. The independent encoder bitrate floor still prevents an invalid target
83
+ // on low-bitrate clips. Shared verbatim with the size estimate ([qualityRatioForPercent] /
84
+ // [estimateCompressedBytes]) so the number the menu quotes always matches the encode that runs.
85
+ private const val MIN_QUALITY_RATIO = 0.01f
86
+
87
+ // ── The quality floor: what a reduction may never cost ──────────────────────────────────
88
+ // A percentage is a request for a SIZE, and taken literally it will ask a full-size frame to
89
+ // survive on a rate no encoder can make look like the source: 1% of a 17 Mbps 1080p capture is
90
+ // 170 kbps, which is blocking and mud. So the percent-derived rate is a TARGET, and this is the
91
+ // floor underneath it — the fewest bits per pixel per frame an encode will accept before it stops
92
+ // honouring the requested reduction. Past that point the file simply stops getting smaller; that
93
+ // is the honest trade, and it is the one the user asked for.
94
+ //
95
+ // Expressed in bits per pixel per frame rather than as an absolute rate because that is the unit
96
+ // quality lives in: the same 4 Mbps is generous for 720p, adequate for 1080p and hopeless for 4K,
97
+ // so a fixed-rate floor would protect one resolution and abandon the others. Both figures are the
98
+ // low end of the "visually close to the source" range for their codec on camera-style content,
99
+ // which is what a compression of a capture is.
100
+ //
101
+ // Not a CQ/CRF encode, deliberately: MediaCodec's constant-quality mode is optional, unevenly
102
+ // implemented across vendors, and — being quality- rather than rate-targeted — makes the output
103
+ // size unpredictable, which every size a menu row quotes depends on. Rate-targeted VBR with a
104
+ // quality floor gets the same protection from a knob every device actually implements.
105
+ private const val MIN_BITS_PER_PIXEL_AVC = 0.065
106
+ // HEVC carries the same picture in roughly two thirds the bits, so its floor sits proportionately
107
+ // lower: equal protection, lower cost.
108
+ private const val MIN_BITS_PER_PIXEL_HEVC = 0.045
109
+ //
110
+ // The floor's own limit. A clip can arrive ALREADY below it — a re-shared social video, a screen
111
+ // recording of static content — and for such a clip the floor would say "no reduction is available
112
+ // at all", which is not an answer a Compress menu can give. So the floor is never allowed above
113
+ // this share of the source's own rate: quality is protected wherever there is fat to trim, and a
114
+ // real reduction is always on offer. Half, so the guarantee is easy to state — every clip can
115
+ // always be made at least about twice as small, and past that point the pipeline protects the
116
+ // picture rather than the percentage.
117
+ private const val MAX_FLOOR_SOURCE_SHARE = 0.5
118
+
119
+ // ── How wide a prediction can honestly be ───────────────────────────────────────────────
120
+ // [compress] hands the encoder a TARGET bit rate (KEY_BIT_RATE) and no bitrate mode, which on
121
+ // Android means VBR on virtually every device: the encoder tracks the target over the clip but
122
+ // spends fewer bits than asked on easy content (static scenes, low motion, letterboxing) and a
123
+ // few more on hard content. So the video half of an output's size is a band around the target,
124
+ // not a single number — which is why a level is quoted as a RANGE and the band is expressed
125
+ // here, next to the clamps it shares, rather than guessed at by the caller.
126
+ //
127
+ // The bounds are deliberately asymmetric: undershoot is both more common and much larger than
128
+ // overshoot, because a rate-limited encoder is free to spend LESS than its budget but is actively
129
+ // driven not to spend more.
130
+ /** Lowest share of the target bit rate the video half is expected to actually spend. */
131
+ private const val ESTIMATE_RATE_UNDERSHOOT = 0.75
132
+ /** Highest share of it, for content the encoder cannot hold to the target. */
133
+ private const val ESTIMATE_RATE_OVERSHOOT = 1.10
134
+
135
+ // ── Progress reporting ──────────────────────────────────────────────────────────────────
136
+ // [compress] reports how far through the pass it is so the caller's overlay can show a REAL
137
+ // remaining time instead of counting a prediction down. The measure is the encoder's own output
138
+ // timestamps against the source duration: it is the position of the work actually written to the
139
+ // muxer, so it advances at exactly the rate the transcode does — on a slow device it crawls, on
140
+ // a fast one it races, and either way the caller's arithmetic lands on the truth.
141
+ /** Smallest gap between two progress callbacks. Enough to animate; far too coarse to cost. */
142
+ private const val PROGRESS_MIN_INTERVAL_MS = 150L
143
+ /** …unless the fraction has moved this much, which forces a callback however recent the last. */
144
+ private const val PROGRESS_MIN_DELTA = 0.01f
145
+ /**
146
+ * Ceiling while the loop is running. The last steps of the pass — flushing the encoder, copying
147
+ * the audio track, stopping the muxer — happen after the final video sample, so reporting 1.0
148
+ * from inside the loop would announce a finished file that is still being written. 1.0 is sent
149
+ * by [compress] itself, once there really is nothing left to do.
150
+ */
151
+ private const val PROGRESS_TRANSCODE_CEILING = 0.99f
152
+
153
+ // The conversion's tail ([convertContainer]). Its loop stops well short of the ceiling above
154
+ // because what follows it is NOT a moment: the audio elementary stream is copied through
155
+ // sample-for-sample and the muxer then writes the container's index. On a long clip that is
156
+ // seconds of real work, and reporting the loop's end as ~1.0 told the caller the export was
157
+ // finished while all of it was still to come — which is exactly what left the countdown at zero
158
+ // watching an overlay that would not go away.
159
+ /** Ceiling while the conversion's transcode loop is running. */
160
+ private const val PROGRESS_CONVERT_LOOP_CEILING = 0.88f
161
+ /** Fraction complete once the audio track has been copied into the output. */
162
+ private const val PROGRESS_CONVERT_AFTER_AUDIO = 0.95f
163
+ /**
164
+ * Reported before the muxer is stopped — the one step of the pass with no measurable length.
165
+ * Deliberately above the caller's "finalizing" threshold, so the countdown hands over to a static
166
+ * line for it instead of quoting a second count it cannot justify.
167
+ */
168
+ private const val PROGRESS_CONVERT_TAIL = 0.99f
169
+
170
+ /**
171
+ * Round [value] to the nearest even pixel count (encoders reject odd dimensions), never
172
+ * below 2. Applied to both axes of an already aspect-correct size, so the ≤1px adjustment
173
+ * is the only difference from the exact scaled dimension.
174
+ */
175
+ private fun evenDimension(value: Float): Int {
176
+ val rounded = (value + 0.5f).toInt().coerceAtLeast(2)
177
+ return (rounded / 2) * 2
178
+ }
179
+
180
+ /** The encoder's frame size for a compression — see [encodedFrameSize]. */
181
+ data class EncodedFrameSize(val width: Int, val height: Int)
182
+
183
+ /**
184
+ * The frame a compression encodes, from the source's CODED size and the profile's resolution cap.
185
+ *
186
+ * FULL RESOLUTION IS PRESERVED. Compression lowers the bit rate; it does NOT shrink the frame.
187
+ * The old quality-derived downscale (sqrt(ratio) for the heavier levels) is gone: it was the
188
+ * single largest source of visible degradation, trading sharpness away on exactly the clips the
189
+ * user most wanted to keep. Nothing here reads the quality level at all, so resolution is a
190
+ * function of the DEVICE, never of the compression strength.
191
+ *
192
+ * Two things may still move the frame, and only these two:
193
+ * - [maxEncodedEdge] — the profile's device-safety ceiling. Size is bitrate × duration, so this
194
+ * buys no reduction whatsoever; it is a pure stability knob that lightens decode/encode work and
195
+ * holds peak memory down on small devices. 0 means "no cap" (the HIGH tier), and the caps that
196
+ * remain sit at or above 1080p so an ordinary capture is never touched.
197
+ * - [MIN_ENCODED_EDGE] — the encoder minimum, which takes priority over the cap so an extreme
198
+ * aspect can never land below it.
199
+ *
200
+ * Both apply as ONE factor to BOTH axes, so the aspect ratio is untouched; clamping each edge
201
+ * separately (the original behaviour) stretches a frame whenever only one of them is out of
202
+ * range. Coded orientation is preserved as well — no width/height swap — because it is the
203
+ * container's rotation metadata, re-stamped on the output, that orients the clip.
204
+ *
205
+ * Shared with [estimateCompressedByteRange] so a quoted size and the encode it predicts are
206
+ * always derived from the same frame.
207
+ */
208
+ internal fun encodedFrameSize(
209
+ codedWidth: Int,
210
+ codedHeight: Int,
211
+ maxEncodedEdge: Int
212
+ ): EncodedFrameSize {
213
+ val longestEdge = maxOf(codedWidth, codedHeight)
214
+ val cappedScale = if (maxEncodedEdge > 0 && longestEdge > maxEncodedEdge) {
215
+ maxEncodedEdge.toFloat() / longestEdge
216
+ } else {
217
+ 1.0f
218
+ }
219
+ val smallestEdge = minOf(codedWidth, codedHeight)
220
+ val scale = if (smallestEdge > 0 && smallestEdge * cappedScale < MIN_ENCODED_EDGE) {
221
+ (MIN_ENCODED_EDGE.toFloat() / smallestEdge).coerceAtMost(1.0f)
222
+ } else {
223
+ cappedScale
224
+ }
225
+ return EncodedFrameSize(
226
+ evenDimension(codedWidth * scale),
227
+ evenDimension(codedHeight * scale)
228
+ )
229
+ }
230
+
231
+ /**
232
+ * The centre-crop needed to reduce a frame shown at [displayW]×[displayH] to [targetAspect]
233
+ * (width / height). This is the pure arithmetic behind cropping a captured clip to the selected
234
+ * aspect ratio: it keeps the FULL shorter display axis and trims only the longer display axis,
235
+ * symmetrically about the centre — exactly the region the live viewfinder (PreviewView
236
+ * FILL_CENTER) showed when the box already matched the selected ratio.
237
+ *
238
+ * @param displayW display (rotation-corrected) width in pixels.
239
+ * @param displayH display (rotation-corrected) height in pixels.
240
+ * @param targetAspect requested width/height ratio (≤ 0 → no crop).
241
+ * @param swapsAxes true when the source's rotation (90/270) swaps which coded axis is the display
242
+ * width, i.e. coded V instead of coded U carries the trimmed display-width band.
243
+ * @return the cropped DISPLAY dimensions and the fraction of each CODED axis (u, v) that remains
244
+ * (1.0 = untouched). The sampled band — (1.0, scaleV) or (scaleU, 1.0) — always has aspect
245
+ * [targetAspect], so mapping it onto a [AspectCropPlan.outDisplayW]×[outDisplayH] surface never
246
+ * distorts.
247
+ */
248
+ internal fun computeAspectCropPlan(
249
+ displayW: Int,
250
+ displayH: Int,
251
+ targetAspect: Float,
252
+ swapsAxes: Boolean
253
+ ): AspectCropPlan {
254
+ var outDisplayW = displayW
255
+ var outDisplayH = displayH
256
+ var cropScaleU = 1f
257
+ var cropScaleV = 1f
258
+ if (targetAspect > 0f && displayW > 0 && displayH > 0) {
259
+ val srcDisplayAspect = displayW.toFloat() / displayH.toFloat()
260
+ if (kotlin.math.abs(srcDisplayAspect - targetAspect) > 0.0001f) {
261
+ if (srcDisplayAspect > targetAspect) {
262
+ // Source frame is WIDER than the target → trim width, keep full height.
263
+ outDisplayH = displayH
264
+ outDisplayW = evenDimension(displayH.toFloat() * targetAspect)
265
+ val frac = outDisplayW.toFloat() / displayW.toFloat()
266
+ // Display width is coded V when the axes are swapped, else coded U.
267
+ if (swapsAxes) cropScaleV = frac else cropScaleU = frac
268
+ } else {
269
+ // Source frame is TALLER than the target → trim height, keep full width.
270
+ outDisplayW = displayW
271
+ outDisplayH = evenDimension(displayW.toFloat() / targetAspect)
272
+ val frac = outDisplayH.toFloat() / displayH.toFloat()
273
+ if (swapsAxes) cropScaleU = frac else cropScaleV = frac
274
+ }
275
+ }
276
+ }
277
+ return AspectCropPlan(outDisplayW, outDisplayH, cropScaleU, cropScaleV)
278
+ }
279
+
280
+ /** Result of [computeAspectCropPlan]. See it for the meaning of each field. */
281
+ data class AspectCropPlan(
282
+ val outDisplayW: Int,
283
+ val outDisplayH: Int,
284
+ val cropScaleU: Float,
285
+ val cropScaleV: Float
286
+ )
287
+
288
+ /**
289
+ * Re-encodes [input] into [output] with every frame rotated [rotationDegrees] clockwise
290
+ * (a multiple of 90) so the pixels are stored upright, writing rotation metadata of 0.
291
+ * The audio track, frame rate, bit rate, resolution, and every presentation timestamp
292
+ * are preserved.
293
+ *
294
+ * @return true only if [output] was produced successfully; on any failure the caller
295
+ * must keep using the untouched [input] (capture must never fail because of this).
296
+ */
297
+ fun rotate(input: File, output: File, rotationDegrees: Int): Boolean {
298
+ val degrees = ((rotationDegrees % 360) + 360) % 360
299
+ if (degrees % 90 != 0) {
300
+ Log.w(TAG, "Unsupported rotation $rotationDegrees; skipping")
301
+ return false
302
+ }
303
+
304
+ var extractor: MediaExtractor? = null
305
+ var decoder: MediaCodec? = null
306
+ var encoder: MediaCodec? = null
307
+ var muxer: MediaMuxer? = null
308
+ var inputSurface: InputSurface? = null
309
+ var outputSurface: OutputSurface? = null
310
+ var audioInfo: AudioPassthrough? = null
311
+ try {
312
+ extractor = MediaExtractor()
313
+ extractor.setDataSource(input.absolutePath)
314
+
315
+ val videoTrack = firstTrack(extractor, "video/") ?: run {
316
+ Log.w(TAG, "No video track found")
317
+ return false
318
+ }
319
+ val sourceFormat = extractor.getTrackFormat(videoTrack)
320
+ val codedWidth = sourceFormat.getInteger(MediaFormat.KEY_WIDTH)
321
+ val codedHeight = sourceFormat.getInteger(MediaFormat.KEY_HEIGHT)
322
+
323
+ // A 90°/270° rotation swaps the frame's width and height; 0°/180° keeps them.
324
+ val swapsAxes = degrees == 90 || degrees == 270
325
+ val outWidth = if (swapsAxes) codedHeight else codedWidth
326
+ val outHeight = if (swapsAxes) codedWidth else codedHeight
327
+
328
+ val frameRate = readFrameRate(sourceFormat)
329
+ val bitRate = readBitRate(sourceFormat, codedWidth, codedHeight, frameRate)
330
+ val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
331
+ val encodeMime = if (isEncodableVideoMime(sourceMime)) sourceMime else "video/avc"
332
+
333
+ // Configure the encoder first so we can obtain its input Surface for the GL output.
334
+ val encoderFormat = MediaFormat.createVideoFormat(encodeMime, outWidth, outHeight).apply {
335
+ setInteger(
336
+ MediaFormat.KEY_COLOR_FORMAT,
337
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
338
+ )
339
+ setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
340
+ setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
341
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL)
342
+ }
343
+ encoder = MediaCodec.createEncoderByType(encodeMime)
344
+ encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
345
+ inputSurface = InputSurface(encoder.createInputSurface())
346
+ inputSurface.makeCurrent()
347
+ encoder.start()
348
+
349
+ // The decoder renders decoded frames into a SurfaceTexture we own; the GL renderer
350
+ // then draws that texture — rotated — into the encoder's input Surface.
351
+ outputSurface = OutputSurface(degrees)
352
+ decoder = MediaCodec.createDecoderByType(sourceMime)
353
+ decoder.configure(sourceFormat, outputSurface.surface, null, 0)
354
+ decoder.start()
355
+ extractor.selectTrack(videoTrack)
356
+
357
+ muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
358
+ // Pixels are physically upright now, so the container must advertise no rotation.
359
+ muxer.setOrientationHint(0)
360
+
361
+ audioInfo = AudioPassthrough.open(input)
362
+
363
+ val ok = runVideoTranscode(
364
+ extractor, decoder, encoder, inputSurface, outputSurface, muxer, audioInfo
365
+ )
366
+ if (!ok) return false
367
+
368
+ // Copy the untouched audio track after the video track is written; timestamps are
369
+ // preserved verbatim so audio stays perfectly in sync and the duration is unchanged.
370
+ audioInfo?.copyTo(muxer)
371
+ return true
372
+ } catch (e: Exception) {
373
+ Log.e(TAG, "Video rotation transcode failed", e)
374
+ return false
375
+ } catch (e: OutOfMemoryError) {
376
+ Log.e(TAG, "Video rotation transcode ran out of memory", e)
377
+ return false
378
+ } finally {
379
+ try { decoder?.stop() } catch (_: Exception) {}
380
+ try { decoder?.release() } catch (_: Exception) {}
381
+ try { encoder?.stop() } catch (_: Exception) {}
382
+ try { encoder?.release() } catch (_: Exception) {}
383
+ try { muxer?.stop() } catch (_: Exception) {}
384
+ try { muxer?.release() } catch (_: Exception) {}
385
+ try { outputSurface?.release() } catch (_: Exception) {}
386
+ try { inputSurface?.release() } catch (_: Exception) {}
387
+ try { audioInfo?.release() } catch (_: Exception) {}
388
+ try { extractor?.release() } catch (_: Exception) {}
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Re-encodes [input] into a new [output] file at a reduced bit rate specified by [qualityRatio],
394
+ * at the source's FULL resolution. The source file [input] is never overwritten, modified, or
395
+ * deleted.
396
+ *
397
+ * QUALITY IS PROTECTED, NOT TRADED AWAY. Compression here is a bit-rate change and nothing more:
398
+ *
399
+ * - **Resolution** keeps its full pixel dimensions at every level (the old quality-derived
400
+ * downscale is gone). The only thing that may reduce it is the profile's device-safety cap
401
+ * ([VideoCompressionPlan.maxEncodedEdge]) — a per-device memory ceiling applied equally to every
402
+ * level, set above what the camera records, never the compression strength.
403
+ * - **Frame rate, duration and A/V sync** are the source's own: presentation timestamps are copied
404
+ * verbatim, no frames are dropped, and the audio track is copied sample-for-sample rather than
405
+ * re-encoded.
406
+ * - **Orientation and aspect ratio** are carried through untouched (see below).
407
+ * - **The rate has a QUALITY FLOOR under it** ([qualityFloorBitRate], measured in bits per pixel
408
+ * per frame on the frame and codec actually being encoded), so no level can starve a full-size
409
+ * frame into blocking. A percentage is a request, and beyond the floor the answer is "the file
410
+ * stops getting smaller" rather than "the picture stops being watchable". The clamp on the ratio
411
+ * itself ([MIN_QUALITY_RATIO]) remains only as the outer bound on the requested target.
412
+ * - **The encoder is asked for its high-quality profile** (AVC High / HEVC Main) and VBR, so the
413
+ * budget it does have is spent where the eye notices.
414
+ *
415
+ * The file still shrinks; it just does so with far less visible degradation.
416
+ *
417
+ * ORIENTATION-NEUTRAL BY CONSTRUCTION. Compression is a size change and nothing else: the
418
+ * frames are re-encoded in their CODED orientation (no axis swap, no GPU rotation) and the
419
+ * source's rotation metadata is copied verbatim onto the output container. The compressed
420
+ * clip therefore has the SAME rotation-corrected display width/height as the source — scaled
421
+ * by the same factor on both axes — so its aspect ratio, and the preview box every consumer
422
+ * derives from it, are identical to the original's.
423
+ *
424
+ * This matters because captured clips store their orientation as container metadata and keep
425
+ * their pixels in the sensor's coded orientation (see
426
+ * CustomCameraActivity.normalizeCapturedVideoOrientation). Baking that rotation into the
427
+ * pixels here — the previous behaviour, which swapped the output dimensions, rotated in GL and
428
+ * wrote an orientation hint of 0 — re-oriented the clip relative to its own original: the
429
+ * decoder ALSO applies the container rotation to its output Surface (MediaCodec honours
430
+ * MediaFormat.KEY_ROTATION, which MediaExtractor reports, and a SurfaceTexture surfaces it
431
+ * through getTransformMatrix), so the frame was rotated twice and re-encoded into a buffer
432
+ * shaped for the un-rotated frame. The result was a compressed clip whose stored geometry no
433
+ * longer matched the original's — which is what made the preview shrink in height and render
434
+ * the frame squashed/sideways after compressing.
435
+ *
436
+ * @param qualityRatio Target quality level, clamped into [[MIN_QUALITY_RATIO], 1.0]: the share of
437
+ * the source bit rate to keep. Values below the floor are raised to it so the 99% menu level
438
+ * remains a small but valid target.
439
+ * @param rotationDegrees Rotation metadata (a multiple of 90) to stamp on [output]. The
440
+ * default 0 means "carry the source's own rotation through unchanged", which is what every
441
+ * caller wants; a non-zero value overrides it for a source whose tag is known to be wrong.
442
+ * @param onProgress Called on the CALLING (worker) thread with how much of the pass is done,
443
+ * 0.0..1.0, as the encoder writes frames — see [PROGRESS_MIN_INTERVAL_MS]. It is the measured
444
+ * position of the transcode, not a prediction, which is what lets a caller display a remaining
445
+ * time that tracks the real work instead of a clock. Values are monotonic, start at 0.0 and
446
+ * end at exactly 1.0 on success (nothing further is reported on failure). A source whose
447
+ * duration cannot be read reports only the 0.0 and the final 1.0, so a caller must treat a
448
+ * silent transcode as "progress unknown" rather than "stalled".
449
+ * @return true only if [output] was produced successfully.
450
+ */
451
+ fun compress(
452
+ input: File,
453
+ output: File,
454
+ qualityRatio: Float,
455
+ rotationDegrees: Int = 0,
456
+ // The active compression profile resolved to concrete encoder knobs (bitrate multiplier,
457
+ // resolution cap, codec, GOP, threading). The default leaves every historical value in place,
458
+ // so a caller that passes nothing gets exactly the pre-profile behaviour.
459
+ plan: VideoCompressionPlan = VideoCompressionPlan(),
460
+ onProgress: ((Float) -> Unit)? = null
461
+ ): Boolean {
462
+ val ratio = qualityRatio.coerceIn(MIN_QUALITY_RATIO, 1.0f)
463
+ val degrees = ((rotationDegrees % 360) + 360) % 360
464
+ if (degrees % 90 != 0) {
465
+ Log.w(TAG, "Unsupported rotation $rotationDegrees; skipping compression")
466
+ return false
467
+ }
468
+
469
+ var extractor: MediaExtractor? = null
470
+ var decoder: MediaCodec? = null
471
+ var encoder: MediaCodec? = null
472
+ var muxer: MediaMuxer? = null
473
+ var inputSurface: InputSurface? = null
474
+ var outputSurface: OutputSurface? = null
475
+ var audioInfo: AudioPassthrough? = null
476
+ try {
477
+ onProgress?.invoke(0f)
478
+ extractor = MediaExtractor()
479
+ extractor.setDataSource(input.absolutePath)
480
+
481
+ val videoTrack = firstTrack(extractor, "video/") ?: run {
482
+ Log.w(TAG, "No video track found for compression")
483
+ return false
484
+ }
485
+ val sourceFormat = extractor.getTrackFormat(videoTrack)
486
+ val codedWidth = sourceFormat.getInteger(MediaFormat.KEY_WIDTH)
487
+ val codedHeight = sourceFormat.getInteger(MediaFormat.KEY_HEIGHT)
488
+
489
+ // Read (but do not apply) the source's orientation: it is re-stamped on the output
490
+ // container below so the compressed clip is displayed exactly like the original.
491
+ val sourceRotation = if (rotationDegrees != 0) degrees else readRotation(input)
492
+
493
+ // FULL RESOLUTION IS PRESERVED — see [encodedFrameSize], which owns this arithmetic and is
494
+ // read by the size estimate too, so a quote can never describe a different frame from the one
495
+ // that is encoded. Compression lowers the bit rate; it does NOT shrink the frame. Coded
496
+ // orientation is preserved as well (no width/height swap): the container's rotation metadata,
497
+ // re-stamped below, is what orients the clip — exactly as it does for the source file.
498
+ val frame = encodedFrameSize(codedWidth, codedHeight, plan.maxEncodedEdge)
499
+ val outWidth = frame.width
500
+ val outHeight = frame.height
501
+
502
+ val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
503
+ // Codec choice: honour the profile's HEVC preference (already gated on "device has an HEVC
504
+ // encoder AND is not a LOW tier" by the resolver) for more reduction at equal quality, else
505
+ // keep the source's own codec when it is re-encodable, else fall back to AVC. The DECODER
506
+ // always uses [sourceMime]; only the ENCODE target moves, and the MP4 muxer carries both.
507
+ //
508
+ // Resolved BEFORE the bit rate because the quality floor is codec-aware — HEVC carries the
509
+ // same picture in fewer bits, so it is allowed a proportionately lower floor.
510
+ val encodeMime = when {
511
+ plan.preferHevc -> MIME_VIDEO_HEVC
512
+ isEncodableVideoMime(sourceMime) -> sourceMime
513
+ else -> "video/avc"
514
+ }
515
+
516
+ val frameRate = readFrameRate(sourceFormat)
517
+ // The yardstick every progress fraction is measured against, and the length the size the
518
+ // menu quoted for this level was predicted over — read once, used by both.
519
+ val durationUs = readDurationUs(sourceFormat)
520
+ val origBitRate = compressionSourceBitRate(
521
+ sourceFormat, input.length(), codedWidth, codedHeight, frameRate, durationUs
522
+ )
523
+ // FRAME RATE IS PRESERVED TOO: [frameRate] is the source's own, handed to the encoder
524
+ // unchanged, so the floor below is quoted against the real frame cost. Dropping frames would
525
+ // be another way to buy size, and it is not taken — the request is for a smaller file, not a
526
+ // choppier clip.
527
+ //
528
+ // The floor is measured on the ENCODED frame (not the coded source) because that is what the
529
+ // encoder actually has to carry, and it is capped at the source's own rate inside the helper
530
+ // so an already-thin clip is never inflated past its original.
531
+ val floorBitRate = qualityFloorBitRate(
532
+ outWidth, outHeight, frameRate, encodeMime == MIME_VIDEO_HEVC, origBitRate
533
+ )
534
+ val targetBitRate =
535
+ targetCompressedBitRate(origBitRate, ratio, plan.bitrateScale, floorBitRate)
536
+
537
+ val encoderFormat = MediaFormat.createVideoFormat(encodeMime, outWidth, outHeight).apply {
538
+ setInteger(
539
+ MediaFormat.KEY_COLOR_FORMAT,
540
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
541
+ )
542
+ setInteger(MediaFormat.KEY_BIT_RATE, targetBitRate)
543
+ setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
544
+ setInteger(
545
+ MediaFormat.KEY_I_FRAME_INTERVAL,
546
+ plan.keyFrameIntervalSeconds.coerceAtLeast(1)
547
+ )
548
+ // Spend the bit budget where it is needed. VBR lets the encoder move bits from easy frames
549
+ // (static scenes, letterboxing) to hard ones (motion, detail) while still tracking the
550
+ // target over the clip, so the same average rate buys a visibly cleaner picture than CBR.
551
+ // It also matches the assumption the size estimate is built on (see [ESTIMATE_RATE_*]).
552
+ setInteger(
553
+ MediaFormat.KEY_BITRATE_MODE,
554
+ MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR
555
+ )
556
+ applyEncoderThreadingHints(this, plan)
557
+ }
558
+ // Ask for the encoder's high-quality profile (AVC High / HEVC Main) so it uses the more
559
+ // efficient coding tools — the same bit rate then carries a sharper image. Only a profile the
560
+ // encoder actually advertises is requested, and if it still rejects the pairing at configure
561
+ // time the encoder is recreated with the profile stripped, so the compression always runs.
562
+ encoder = configureWithHighQualityProfile(
563
+ MediaCodec.createEncoderByType(encodeMime), encoderFormat, encodeMime
564
+ )
565
+ inputSurface = InputSurface(encoder.createInputSurface())
566
+ inputSurface.makeCurrent()
567
+ encoder.start()
568
+
569
+ // Identity render: the frame is copied through at its coded orientation, so the encoder's
570
+ // buffer holds exactly the pixels the source track holds (only smaller).
571
+ outputSurface = OutputSurface(0)
572
+ decoder = MediaCodec.createDecoderByType(sourceMime)
573
+ // Neutralise the rotation the DECODER would otherwise apply for us. MediaExtractor reports
574
+ // the container's rotation as KEY_ROTATION, and MediaCodec honours it by setting a
575
+ // transform on its output Surface — which a SurfaceTexture hands back through
576
+ // getTransformMatrix(). Left in place, frames would arrive pre-rotated and be re-encoded
577
+ // into a buffer sized for the un-rotated frame: a squashed, sideways clip whose stored
578
+ // geometry no longer matches the original's. Decoding in the coded orientation keeps the
579
+ // re-encode a pure size change.
580
+ sourceFormat.setInteger(MediaFormat.KEY_ROTATION, 0)
581
+ decoder.configure(sourceFormat, outputSurface.surface, null, 0)
582
+ decoder.start()
583
+ extractor.selectTrack(videoTrack)
584
+
585
+ muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
586
+ // Carry the SOURCE's orientation onto the copy. Together with the un-swapped, un-rotated
587
+ // pixels above this makes the compressed clip's rotation-corrected display dimensions the
588
+ // original's, scaled — so both files preview at the identical aspect ratio.
589
+ muxer.setOrientationHint(sourceRotation)
590
+
591
+ audioInfo = AudioPassthrough.open(input)
592
+
593
+ val ok = runVideoTranscode(
594
+ extractor, decoder, encoder, inputSurface, outputSurface, muxer, audioInfo,
595
+ // Read from the SOURCE track (the pass copies presentation timestamps through verbatim, so
596
+ // the output's timeline is the same one) and 0 when the container does not declare it —
597
+ // which reports no progress rather than a fraction of a guessed length.
598
+ durationUs = durationUs,
599
+ onProgress = onProgress
600
+ )
601
+ if (!ok) return false
602
+
603
+ audioInfo?.copyTo(muxer)
604
+ // Only now is there a complete, muxed file: the encoder is flushed and the audio track is
605
+ // written. Reporting 1.0 any earlier would tell the caller the copy was ready while the
606
+ // muxer was still finishing it.
607
+ onProgress?.invoke(1f)
608
+ return true
609
+ } catch (e: Exception) {
610
+ Log.e(TAG, "Video compression transcode failed", e)
611
+ return false
612
+ } catch (e: OutOfMemoryError) {
613
+ Log.e(TAG, "Video compression transcode ran out of memory", e)
614
+ return false
615
+ } finally {
616
+ try { decoder?.stop() } catch (_: Exception) {}
617
+ try { decoder?.release() } catch (_: Exception) {}
618
+ try { encoder?.stop() } catch (_: Exception) {}
619
+ try { encoder?.release() } catch (_: Exception) {}
620
+ try { muxer?.stop() } catch (_: Exception) {}
621
+ try { muxer?.release() } catch (_: Exception) {}
622
+ try { outputSurface?.release() } catch (_: Exception) {}
623
+ try { inputSurface?.release() } catch (_: Exception) {}
624
+ try { audioInfo?.release() } catch (_: Exception) {}
625
+ try { extractor?.release() } catch (_: Exception) {}
626
+ }
627
+ }
628
+
629
+ // ─────────────────────────────────────────────────────────────────────────────────────────
630
+ // Predicting a compression's size — what a menu can quote before anything is encoded
631
+ // ─────────────────────────────────────────────────────────────────────────────────────────
632
+
633
+ /**
634
+ * The quality ratio a "smaller by [percent]%" request maps onto.
635
+ *
636
+ * The one definition of that mapping. [compress] is handed this and [estimateCompressedBytes]
637
+ * predicts from this, so a level's quoted size and the encode it describes can never be derived
638
+ * from two different readings of the same percentage.
639
+ */
640
+ fun qualityRatioForPercent(percent: Int): Float =
641
+ ((100 - percent) / 100f).coerceIn(MIN_QUALITY_RATIO, 1.0f)
642
+
643
+ /**
644
+ * The bit rate [compress] configures its encoder with, for a source at [sourceBitRate] and a
645
+ * quality ratio of [ratio].
646
+ *
647
+ * A function rather than a multiplication repeated at each call site because of the CLAMP and the
648
+ * FLOOR: on a low-bitrate clip every heavy level lands on the same floor, so a prediction that
649
+ * scaled without clamping would promise files the encoder is never going to write.
650
+ */
651
+ private fun targetCompressedBitRate(
652
+ sourceBitRate: Int,
653
+ ratio: Float,
654
+ // The active profile's bitrate multiplier, folded in BEFORE the clamp so the estimate and the
655
+ // encoder read one number — a profile that halves the rate quotes half the size and writes it.
656
+ bitrateScale: Float = 1f,
657
+ // The quality floor from [qualityFloorBitRate] (0 = none/unknown). Raising the rate TO the floor
658
+ // rather than clamping down to it is the whole point: past this rate the file stops getting
659
+ // smaller instead of getting uglier. Applied before the absolute clamp so the hard device
660
+ // limits still have the last word.
661
+ floorBitRate: Int = 0
662
+ ): Int =
663
+ maxOf((sourceBitRate * ratio * bitrateScale).toInt(), floorBitRate)
664
+ .coerceIn(MIN_COMPRESSED_BIT_RATE, MAX_COMPRESSED_BIT_RATE)
665
+
666
+ /**
667
+ * The lowest rate an encode of a [width]×[height] frame at [frameRate] fps is allowed to run at —
668
+ * the quality floor described on [MIN_BITS_PER_PIXEL_AVC], turned into a bit rate for one specific
669
+ * frame.
670
+ *
671
+ * `bits-per-pixel × pixels × frames-per-second`, which is simply the definition of the unit read
672
+ * forwards. [hevc] picks the codec's floor, so an HEVC encode is allowed the lower rate its extra
673
+ * efficiency earns rather than being held to AVC's.
674
+ *
675
+ * Capped at [MAX_FLOOR_SOURCE_SHARE] of [sourceBitRate] because a floor must never INFLATE, and
676
+ * must never leave a menu with nothing to offer: a clip that already sits below the floor is
677
+ * honestly cheap rather than under-encoded, and re-encoding it upwards would spend bits to add
678
+ * nothing and hand back a copy larger than the original. Returns 0 — no floor — when the frame or
679
+ * rate is unreadable, leaving the percentage to be honoured literally as before.
680
+ */
681
+ internal fun qualityFloorBitRate(
682
+ width: Int,
683
+ height: Int,
684
+ frameRate: Int,
685
+ hevc: Boolean,
686
+ sourceBitRate: Int
687
+ ): Int {
688
+ if (width <= 0 || height <= 0 || frameRate <= 0) return 0
689
+ val bitsPerPixel = if (hevc) MIN_BITS_PER_PIXEL_HEVC else MIN_BITS_PER_PIXEL_AVC
690
+ val floor = (bitsPerPixel * width.toDouble() * height.toDouble() * frameRate.toDouble())
691
+ .coerceAtMost(Int.MAX_VALUE.toDouble())
692
+ .toInt()
693
+ if (sourceBitRate <= 0) return floor
694
+ return floor.coerceAtMost((sourceBitRate * MAX_FLOOR_SOURCE_SHARE).toInt())
695
+ }
696
+
697
+ /**
698
+ * The source bit rate [compress] scales: the video track's own declared rate, capped by the rate
699
+ * the FILE actually sustains ([sourceBytes] over [durationUs]).
700
+ *
701
+ * The cap is what keeps "smaller by N%" meaning anything. [readBitRate] reports the track's
702
+ * declared rate and — for the many MP4s whose extractor track format carries no `bitrate` key at
703
+ * all — falls back to a high-quality rate derived from pixel throughput (≈15 Mbps for 1080p30).
704
+ * Either figure can sit far above what the clip really spends: an already-compressed 1080p clip
705
+ * at 2 Mbps probes as 15 Mbps, and scaling THAT by 0.7 asks the encoder for several times the
706
+ * source's own rate, so the "compressed" copy comes out larger than the file it came from. A
707
+ * file cannot hold more video than it holds bytes, so its measured average rate is a ceiling
708
+ * that never takes away bits the source actually spent.
709
+ *
710
+ * Only [compress] uses it. [rotate] and [convertContainer] re-encode to PRESERVE what is there
711
+ * and keep reading the declared rate, whose headroom over the average is the point for them.
712
+ */
713
+ private fun compressionSourceBitRate(
714
+ format: MediaFormat,
715
+ sourceBytes: Long,
716
+ width: Int,
717
+ height: Int,
718
+ frameRate: Int,
719
+ durationUs: Long
720
+ ): Int {
721
+ val declared = readBitRate(format, width, height, frameRate)
722
+ val durationSeconds = durationUs / 1_000_000.0
723
+ if (sourceBytes <= 0L || durationSeconds <= 0.0) return declared
724
+ val measured = (sourceBytes * 8.0 / durationSeconds)
725
+ .coerceAtMost(Int.MAX_VALUE.toDouble())
726
+ .toInt()
727
+ return if (measured > 0) minOf(declared, measured) else declared
728
+ }
729
+
730
+ /**
731
+ * Everything about a source clip that determines how large a compression of it comes out — read
732
+ * once from the file, then good for every level in a ladder.
733
+ */
734
+ data class CompressionSizeModel(
735
+ /** Length of the file the encoder would read, in bytes. */
736
+ val sourceBytes: Long,
737
+ /** The rate [compress] would scale for this source — see [compressionSourceBitRate]. */
738
+ val sourceVideoBitRate: Int,
739
+ /** The source's duration in seconds, or 0 when its container declares none. */
740
+ val durationSeconds: Double,
741
+ /**
742
+ * The video track's CODED width/height and frame rate — what the quality floor is measured
743
+ * against, so a prediction can apply the same floor the encode will. 0 when unreadable, which
744
+ * simply leaves the prediction floor-free (the percentage is then honoured literally).
745
+ */
746
+ val codedWidth: Int = 0,
747
+ val codedHeight: Int = 0,
748
+ val frameRate: Int = 0
749
+ ) {
750
+ /** Whether there is enough here to predict a size at all. */
751
+ val isUsable: Boolean
752
+ get() = sourceBytes > 0L && sourceVideoBitRate > 0 && durationSeconds > 0.0
753
+ }
754
+
755
+ /**
756
+ * Read the [CompressionSizeModel] for a local file, or null when it holds no readable video
757
+ * track. Call from a background thread: it opens the file.
758
+ */
759
+ fun readCompressionSizeModel(input: File): CompressionSizeModel? {
760
+ val bytes = runCatching { input.takeIf { it.isFile }?.length() ?: 0L }.getOrDefault(0L)
761
+ if (bytes <= 0L) return null
762
+ return readCompressionSizeModel(bytes) { it.setDataSource(input.absolutePath) }
763
+ }
764
+
765
+ /**
766
+ * The [readCompressionSizeModel] above for a clip that only exists behind a `content://` URI.
767
+ * [sourceBytes] is its real length (the descriptor's, not a MediaStore column's), which a URI
768
+ * cannot be asked for here. Call from a background thread.
769
+ */
770
+ fun readCompressionSizeModel(
771
+ context: Context,
772
+ uri: Uri,
773
+ sourceBytes: Long
774
+ ): CompressionSizeModel? {
775
+ if (sourceBytes <= 0L) return null
776
+ return readCompressionSizeModel(sourceBytes) { it.setDataSource(context, uri, null) }
777
+ }
778
+
779
+ private fun readCompressionSizeModel(
780
+ sourceBytes: Long,
781
+ configure: (MediaExtractor) -> Unit
782
+ ): CompressionSizeModel? {
783
+ var extractor: MediaExtractor? = null
784
+ return try {
785
+ extractor = MediaExtractor()
786
+ configure(extractor)
787
+ val track = firstTrack(extractor, "video/") ?: return null
788
+ val format = extractor.getTrackFormat(track)
789
+ val durationUs = readDurationUs(format)
790
+ val codedWidth = readDimension(format, MediaFormat.KEY_WIDTH)
791
+ val codedHeight = readDimension(format, MediaFormat.KEY_HEIGHT)
792
+ val frameRate = readFrameRate(format)
793
+ CompressionSizeModel(
794
+ sourceBytes = sourceBytes,
795
+ sourceVideoBitRate = compressionSourceBitRate(
796
+ format,
797
+ sourceBytes,
798
+ codedWidth,
799
+ codedHeight,
800
+ frameRate,
801
+ durationUs
802
+ ),
803
+ durationSeconds = durationUs / 1_000_000.0,
804
+ codedWidth = codedWidth,
805
+ codedHeight = codedHeight,
806
+ frameRate = frameRate
807
+ )
808
+ } catch (e: Exception) {
809
+ Log.w(TAG, "Could not read a compression size model", e)
810
+ null
811
+ } finally {
812
+ try { extractor?.release() } catch (_: Exception) {}
813
+ }
814
+ }
815
+
816
+ /**
817
+ * The descriptive facts about a clip the compression cache records for a source or an output:
818
+ * rotation-corrected display dimensions, duration, frame rate, declared/estimated bit rate and
819
+ * the video codec's MIME. Purely informational — nothing about the encode depends on it — so
820
+ * every field is nullable and a partial read is still worth keeping.
821
+ */
822
+ data class VideoMediaInfo(
823
+ /** Duration in milliseconds, or null when the container declares none. */
824
+ val durationMs: Long?,
825
+ /** Display width in px (already swapped for a 90/270 rotation), or null when unreadable. */
826
+ val width: Int?,
827
+ /** Display height in px (already swapped for a 90/270 rotation), or null when unreadable. */
828
+ val height: Int?,
829
+ /** Frame rate in fps, or null when the track declares none. */
830
+ val frameRate: Int?,
831
+ /** Declared (or pixel-estimated) video bit rate in bits/sec, or null when unreadable. */
832
+ val bitRate: Int?,
833
+ /** Video track MIME, e.g. "video/avc" / "video/hevc", or null when unreadable. */
834
+ val mimeType: String?
835
+ )
836
+
837
+ /**
838
+ * Read a [VideoMediaInfo] for a local file, or null when it holds no readable video track. Reuses
839
+ * the same private track readers the size model and the transcode use, so the numbers recorded in
840
+ * the cache match the ones the encode saw. Rotation is corrected via [readRotation] so width/height
841
+ * are display dimensions (what a preview lays out with), not the coded ones. Call from a background
842
+ * thread: it opens the file.
843
+ */
844
+ fun readMediaInfo(file: File): VideoMediaInfo? {
845
+ if (!file.isFile || file.length() <= 0L) return null
846
+ var extractor: MediaExtractor? = null
847
+ return try {
848
+ extractor = MediaExtractor()
849
+ extractor.setDataSource(file.absolutePath)
850
+ val track = firstTrack(extractor, "video/") ?: return null
851
+ val format = extractor.getTrackFormat(track)
852
+ val codedWidth = readDimension(format, MediaFormat.KEY_WIDTH)
853
+ val codedHeight = readDimension(format, MediaFormat.KEY_HEIGHT)
854
+ val frameRate = readFrameRate(format)
855
+ val durationUs = readDurationUs(format)
856
+ // Display dims: a 90/270 recording stores its frame landscape and carries a rotation the
857
+ // player applies, so swap here to describe the clip as it is actually shown.
858
+ val rotation = readRotation(file)
859
+ val swap = rotation == 90 || rotation == 270
860
+ VideoMediaInfo(
861
+ durationMs = (durationUs / 1000L).takeIf { it > 0L },
862
+ width = (if (swap) codedHeight else codedWidth).takeIf { it > 0 },
863
+ height = (if (swap) codedWidth else codedHeight).takeIf { it > 0 },
864
+ frameRate = frameRate.takeIf { it > 0 },
865
+ bitRate = readBitRate(format, codedWidth, codedHeight, frameRate).takeIf { it > 0 },
866
+ mimeType = format.getString(MediaFormat.KEY_MIME)
867
+ )
868
+ } catch (e: Exception) {
869
+ Log.w(TAG, "Could not read media info for ${file.name}", e)
870
+ null
871
+ } finally {
872
+ try { extractor?.release() } catch (_: Exception) {}
873
+ }
874
+ }
875
+
876
+ /**
877
+ * The band of output sizes a compression of this source at [percent] is expected to land in —
878
+ * what a dropdown row quotes ("~5.8 – 7.1 MB") — or null when the model holds too little to
879
+ * predict anything.
880
+ *
881
+ * [expected] is the arithmetic the encoder's own configuration implies; [low] and [high] are that
882
+ * figure with the encoder's rate-tracking tolerance applied to the part of the file it re-encodes
883
+ * (see [ESTIMATE_RATE_UNDERSHOOT] / [ESTIMATE_RATE_OVERSHOOT]). Every bound is clamped the same
884
+ * way, so `low <= expected <= high` always holds and no bound can exceed the original.
885
+ */
886
+ data class CompressedSizeEstimate(val low: Long, val expected: Long, val high: Long)
887
+
888
+ /**
889
+ *
890
+ * The band predicts the ACTUAL file the encoder will write, so it is computed entirely from
891
+ * [model] — the source the pass actually reads and re-encodes ([CompressionSizeModel.sourceBytes]).
892
+ * [originalBytes] is the length the caller presents as the clip's "original" (0 = unknown); for a
893
+ * just-recorded clip that is the RECORDED length shown on the "Original video" row, which is LARGER
894
+ * than the smaller aspect-cropped file the encoder actually reads. It no longer anchors the
895
+ * arithmetic — doing so inflated every rung by the crop delta, so a level read high against the
896
+ * file that really lands on disk — and is kept only as a defensive upper bound, so a quote can
897
+ * never read larger than the figure the caller is showing as the original.
898
+ *
899
+ * The arithmetic follows what [compress] actually does, which is precisely why a flat
900
+ * `original × (1 - percent/100)` does not:
901
+ *
902
+ * - **Only the video track is re-encoded.** The audio elementary stream is copied through
903
+ * sample-for-sample by [AudioPassthrough], and the container's own overhead (index tables
904
+ * sized by a sample count this pass does not change) rides along with it. Those bytes are the
905
+ * same in the copy as in the source, so they are held OUT of the scaling and added back
906
+ * whole — and out of the tolerance band too, since a copied byte is not a predicted one. On a
907
+ * short clip with a full-rate audio track they are a large share of the file, and scaling them
908
+ * away was most of the old error: "smaller by 70%" quoted 30% of a file whose audio alone
909
+ * accounts for 25% of it.
910
+ * - **The encoder is given a BIT RATE, so the video half scales with time, not with the file.**
911
+ * Its size is the target rate over the source's duration — and that target is clamped
912
+ * ([targetCompressedBitRate]), so on a low-bitrate clip the heavy levels all land on the same
913
+ * floor and quote the same size, which is exactly what the encoder will produce.
914
+ * - **Resolution does not enter into it.** The frame keeps its full size at every level, and even
915
+ * the profile's device-safety cap changes only how many pixels are encoded, not the target
916
+ * rate — so it does not change the size the rate-over-duration arithmetic predicts.
917
+ *
918
+ * Still an estimate, and shown as one: an encoder targets a rate rather than guaranteeing it, so
919
+ * easy content undershoots. That is what the range is for — the width comes from the encoder's
920
+ * documented behaviour, not from padding a guess. What it no longer does is describe a different
921
+ * calculation from the one the encoder runs.
922
+ */
923
+ fun estimateCompressedByteRange(
924
+ model: CompressionSizeModel,
925
+ originalBytes: Long,
926
+ percent: Int,
927
+ // Must match the [VideoCompressionPlan] the encode will use, so the quoted size and the produced
928
+ // file stay in lockstep across every profile. The whole plan rather than just its bitrate scale
929
+ // because the quality floor is measured on the ENCODED frame and the ENCODE codec, both of which
930
+ // the plan decides ([VideoCompressionPlan.maxEncodedEdge], [VideoCompressionPlan.preferHevc]).
931
+ plan: VideoCompressionPlan = VideoCompressionPlan()
932
+ ): CompressedSizeEstimate? {
933
+ if (!model.isUsable) return null
934
+ // Predict the ACTUAL output: everything rests on the file the encoder actually reads and
935
+ // re-encodes ([model.sourceBytes]), NOT on [originalBytes]. For a background-aspect-cropped
936
+ // recording the two differ — the "Original video" row shows the larger RECORDED length while the
937
+ // encoder reads the smaller cropped file — and anchoring passthrough/ceiling to the recorded
938
+ // figure inflated every rung by that crop delta, quoting sizes the encode never produces. Basing
939
+ // them on the real source makes each rung equal the file that will actually land on disk.
940
+ val encodedSourceBytes = model.sourceBytes
941
+ val sourceVideoBytes = model.sourceVideoBitRate / 8.0 * model.durationSeconds
942
+ // Everything the pass copies rather than re-encodes, as a residual: the encoded source minus what
943
+ // its video track spends. Derived rather than probed so it needs no second pass over the audio
944
+ // track, and so the ladder's lightest rung lands on the source's own size rather than near it.
945
+ val passthroughBytes = (encodedSourceBytes - sourceVideoBytes).coerceAtLeast(0.0)
946
+ // The same floor [compress] will apply, computed from the same frame and codec it will encode
947
+ // with. Without this the heavy rungs would quote sizes the encoder no longer produces, and the
948
+ // dropdown would promise a reduction the pipeline now (deliberately) refuses to make.
949
+ val frame = encodedFrameSize(model.codedWidth, model.codedHeight, plan.maxEncodedEdge)
950
+ val floorBitRate = qualityFloorBitRate(
951
+ frame.width, frame.height, model.frameRate, plan.preferHevc, model.sourceVideoBitRate
952
+ )
953
+ val targetBitRate = targetCompressedBitRate(
954
+ model.sourceVideoBitRate, qualityRatioForPercent(percent), plan.bitrateScale, floorBitRate
955
+ )
956
+ val compressedVideoBytes = targetBitRate / 8.0 * model.durationSeconds
957
+ // Never quote a "smaller" level as bigger than the file it comes from: the video half rests on a
958
+ // DECLARED rate, and a clip that over-declares its own would otherwise be shown growing. The cap
959
+ // is the encoded source (the output cannot exceed what it was re-encoded from at these rates),
960
+ // and never above the caller's presented original either. The floor keeps a sub-byte prediction
961
+ // from reading as an empty file.
962
+ val ceiling = if (originalBytes > 0L) minOf(encodedSourceBytes, originalBytes) else encodedSourceBytes
963
+ fun bytes(videoBytes: Double): Long =
964
+ Math.round(videoBytes + passthroughBytes).coerceIn(1L, ceiling)
965
+ return CompressedSizeEstimate(
966
+ low = bytes(compressedVideoBytes * ESTIMATE_RATE_UNDERSHOOT),
967
+ expected = bytes(compressedVideoBytes),
968
+ high = bytes(compressedVideoBytes * ESTIMATE_RATE_OVERSHOOT)
969
+ )
970
+ }
971
+
972
+ /**
973
+ * The single most likely output size for [percent] — [estimateCompressedByteRange]'s
974
+ * [CompressedSizeEstimate.expected], and 0 when the model cannot predict at all.
975
+ *
976
+ * Kept for callers that need one number rather than a band (the compression cache's recorded
977
+ * expectation, the seed arithmetic behind the countdown); the dropdown quotes the range.
978
+ */
979
+ fun estimateCompressedBytes(
980
+ model: CompressionSizeModel,
981
+ originalBytes: Long,
982
+ percent: Int,
983
+ plan: VideoCompressionPlan = VideoCompressionPlan()
984
+ ): Long =
985
+ estimateCompressedByteRange(model, originalBytes, percent, plan)?.expected ?: 0L
986
+
987
+ /**
988
+ * The smallest a compression of [model] can honestly come out — the quality floor expressed as a
989
+ * FILE SIZE rather than a rate, for the menus that quote a proportional band instead of predicting
990
+ * from the bit rate.
991
+ *
992
+ * `floor-rate × duration + everything the pass copies` (the audio residual, which no video setting
993
+ * can shrink). A row whose proportional figure lands below this is quoting a file the pipeline will
994
+ * not write, so the quoting side raises it to this number and the two agree again.
995
+ *
996
+ * 0 when the model cannot say — an unreadable frame or duration leaves the proportional band
997
+ * exactly as it was.
998
+ */
999
+ fun qualityFloorVideoBytes(
1000
+ model: CompressionSizeModel,
1001
+ plan: VideoCompressionPlan = VideoCompressionPlan()
1002
+ ): Long {
1003
+ if (!model.isUsable) return 0L
1004
+ val frame = encodedFrameSize(model.codedWidth, model.codedHeight, plan.maxEncodedEdge)
1005
+ val floorBitRate = qualityFloorBitRate(
1006
+ frame.width, frame.height, model.frameRate, plan.preferHevc, model.sourceVideoBitRate
1007
+ )
1008
+ if (floorBitRate <= 0) return 0L
1009
+ val sourceVideoBytes = model.sourceVideoBitRate / 8.0 * model.durationSeconds
1010
+ val passthroughBytes = (model.sourceBytes - sourceVideoBytes).coerceAtLeast(0.0)
1011
+ val floorBytes = floorBitRate / 8.0 * model.durationSeconds + passthroughBytes
1012
+ return Math.round(floorBytes).coerceIn(0L, model.sourceBytes)
1013
+ }
1014
+
1015
+ /** Outcome of a container conversion: whether it worked, and what it cost. */
1016
+ data class ConversionResult(val success: Boolean, val audioDropped: Boolean = false)
1017
+
1018
+ /**
1019
+ * Re-encodes [input] into [output] using [container]'s codec and container format — the video
1020
+ * half of the preview editor's "Export By" menu. [input] is never overwritten, modified, or
1021
+ * deleted.
1022
+ *
1023
+ * Everything that describes the clip is carried across:
1024
+ *
1025
+ * - **Resolution.** The frame is re-encoded at its exact coded size (only rounded to even
1026
+ * pixels, which every encoder requires); nothing is scaled, cropped or stretched.
1027
+ * - **Quality.** The source track's own bit rate is reused, so the conversion targets the
1028
+ * quality the clip already had rather than a fixed ladder.
1029
+ * - **Timing.** Presentation timestamps are copied verbatim by [runVideoTranscode], so
1030
+ * duration, frame rate and A/V sync are unchanged.
1031
+ * - **Orientation.** Handled two different ways on purpose. MP4 and 3GP store rotation as
1032
+ * container metadata, so the frames go through in their CODED orientation and the source's
1033
+ * rotation is re-stamped on the copy — identical to [compress], and for the same reason
1034
+ * (rotating pixels that the container already rotates displays the clip twice-turned).
1035
+ * WebM has no rotation field, so for it the rotation is instead baked into the pixels and
1036
+ * the axes are swapped. Either way the exported clip DISPLAYS at the original's dimensions,
1037
+ * aspect ratio and orientation.
1038
+ * - **Audio**, when the target container can carry the source's audio codec — the elementary
1039
+ * stream is copied sample-for-sample, never re-encoded. WebM accepts only Vorbis/Opus, so an
1040
+ * AAC track cannot go with it; that is reported through [ConversionResult.audioDropped]
1041
+ * rather than failing the whole export, so the caller can say so out loud.
1042
+ *
1043
+ * @param dropAudio omit the audio track deliberately (the clip was muted in the editor). This
1044
+ * is how a muted export is silenced: remuxing the finished copy afterwards is not an option,
1045
+ * because [VideoEditUtils.mute] always writes an MP4 and would put MP4 bytes inside a `.webm`
1046
+ * or `.3gp` name.
1047
+ * @param onProgress Called on the CALLING (worker) thread with how much of the pass is done,
1048
+ * 0.0..1.0, as the encoder writes frames — the same decode → GL → encode → mux loop [compress]
1049
+ * runs, measured the same way, but carried on through the tail this conversion has to pay for:
1050
+ * the loop stops at [PROGRESS_CONVERT_LOOP_CEILING], the audio copy runs to
1051
+ * [PROGRESS_CONVERT_AFTER_AUDIO] and the muxer's own close-out is reported as
1052
+ * [PROGRESS_CONVERT_TAIL]. Monotonic, starts at 0.0 and ends at exactly 1.0 on success (nothing
1053
+ * further is reported on failure). A source whose duration cannot be read reports only the 0.0,
1054
+ * the tail and the final 1.0, so a caller must treat a silent conversion as "progress unknown"
1055
+ * rather than "stalled".
1056
+ */
1057
+ fun convertContainer(
1058
+ input: File,
1059
+ output: File,
1060
+ container: MediaExportFormats.VideoContainer,
1061
+ dropAudio: Boolean = false,
1062
+ onProgress: ((Float) -> Unit)? = null,
1063
+ /** Optional target aspect ratio (width/height) to center-crop the frames to. */
1064
+ cropAspect: Float? = null,
1065
+ // The active profile resolved to encoder knobs. Only the bitrate multiplier, the resolution cap
1066
+ // and the threading/GOP hints apply here — the CODEC is fixed by [container], never the profile,
1067
+ // so an "Export By WebM" stays VP8 whatever profile is chosen. The default preserves the exact
1068
+ // pre-profile conversion (declared source rate, coded resolution, 1s GOP).
1069
+ plan: VideoCompressionPlan = VideoCompressionPlan()
1070
+ ): ConversionResult {
1071
+ var extractor: MediaExtractor? = null
1072
+ var decoder: MediaCodec? = null
1073
+ var encoder: MediaCodec? = null
1074
+ var muxer: MediaMuxer? = null
1075
+ var inputSurface: InputSurface? = null
1076
+ var outputSurface: OutputSurface? = null
1077
+ var audioInfo: AudioPassthrough? = null
1078
+ // The muxer is stopped explicitly below so its close-out is inside the reported pass rather
1079
+ // than in the finally, where the caller would already have been told the export was finished.
1080
+ var muxerStopped = false
1081
+ try {
1082
+ onProgress?.invoke(0f)
1083
+ extractor = MediaExtractor()
1084
+ extractor.setDataSource(input.absolutePath)
1085
+
1086
+ val videoTrack = firstTrack(extractor, "video/") ?: run {
1087
+ Log.w(TAG, "No video track found for conversion")
1088
+ return ConversionResult(false)
1089
+ }
1090
+ val sourceFormat = extractor.getTrackFormat(videoTrack)
1091
+ val codedWidth = sourceFormat.getInteger(MediaFormat.KEY_WIDTH)
1092
+ val codedHeight = sourceFormat.getInteger(MediaFormat.KEY_HEIGHT)
1093
+ val sourceRotation = readRotation(input)
1094
+
1095
+ // Containers that carry a rotation field keep the pixels exactly as coded (renderDegrees
1096
+ // 0) and re-stamp the hint below; the rest have the rotation drawn into the frames here.
1097
+ val renderDegrees = if (container.supportsOrientationHint) 0 else sourceRotation
1098
+
1099
+ // The source's own rotation metadata ALWAYS defines how a coded pixel maps to the upright
1100
+ // DISPLAY frame (the hint MP4/3GP re-stamp for playback, and the same value WebM bakes into
1101
+ // the pixels). A 90°/270° rotation swaps which coded axis becomes the display width.
1102
+ val displaySwapsAxes = sourceRotation % 180 != 0
1103
+ val displayW = if (displaySwapsAxes) codedHeight else codedWidth
1104
+ val displayH = if (displaySwapsAxes) codedWidth else codedHeight
1105
+
1106
+ // Target-cropped DISPLAY dimensions plus the centre-crop texture scales needed to reproduce
1107
+ // the selected aspect ratio in the re-encoded pixels. When a capture aspect ratio is active
1108
+ // the clip must be genuinely centre-cropped to it — keeping only the same frame the viewfinder
1109
+ // showed — not merely zoomed (see [computeAspectCropPlan]).
1110
+ val cropPlan = computeAspectCropPlan(displayW, displayH, cropAspect ?: 0f, displaySwapsAxes)
1111
+ var outDisplayW = cropPlan.outDisplayW
1112
+ var outDisplayH = cropPlan.outDisplayH
1113
+ val cropScaleU = cropPlan.cropScaleU
1114
+ val cropScaleV = cropPlan.cropScaleV
1115
+
1116
+ // Profile resolution cap, applied to the DISPLAY dimensions (aspect + orientation preserved by
1117
+ // scaling both by one factor). The crop texture scales above are normalised, so lowering the
1118
+ // encode resolution just fills the smaller surface — the framing is unchanged. 0 = no cap,
1119
+ // which is the RECOMMENDED case on medium/high devices, leaving the export at coded size.
1120
+ if (plan.maxEncodedEdge > 0) {
1121
+ val longest = maxOf(outDisplayW, outDisplayH)
1122
+ if (longest > plan.maxEncodedEdge) {
1123
+ val s = plan.maxEncodedEdge.toFloat() / longest
1124
+ outDisplayW = (outDisplayW * s).toInt().coerceAtLeast(1)
1125
+ outDisplayH = (outDisplayH * s).toInt().coerceAtLeast(1)
1126
+ }
1127
+ }
1128
+
1129
+ // STORED (encoded) dimensions depend on how the container handles rotation:
1130
+ // • MP4/3GP store pixels in CODED orientation and rotate them at playback, so the stored
1131
+ // size mirrors the source's coded↔display axis mapping.
1132
+ // • WebM bakes the rotation into the pixels (renderDegrees above), so stored == display.
1133
+ val outWRaw: Int
1134
+ val outHRaw: Int
1135
+ if (container.supportsOrientationHint) {
1136
+ outWRaw = if (displaySwapsAxes) outDisplayH else outDisplayW
1137
+ outHRaw = if (displaySwapsAxes) outDisplayW else outDisplayH
1138
+ } else {
1139
+ outWRaw = outDisplayW
1140
+ outHRaw = outDisplayH
1141
+ }
1142
+ val outWidth = evenDimension(outWRaw.toFloat())
1143
+ val outHeight = evenDimension(outHRaw.toFloat())
1144
+
1145
+ val frameRate = readFrameRate(sourceFormat)
1146
+ // Bit rate. RECOMMENDED (scale 1.0) keeps the historical behaviour exactly — the declared
1147
+ // source rate, whose headroom is the point of a quality-preserving conversion. A profile that
1148
+ // moves the rate anchors instead to what the file ACTUALLY sustains (declared can be wildly
1149
+ // over-stated for MP4s carrying no bitrate key) before scaling, so a "High Compression" export
1150
+ // is genuinely smaller and a "Maximum Quality" export adds real headroom, not a guess.
1151
+ val bitRate = if (plan.bitrateScale != 1f) {
1152
+ val durationUs = readDurationUs(sourceFormat)
1153
+ val anchor = compressionSourceBitRate(
1154
+ sourceFormat, input.length(), codedWidth, codedHeight, frameRate, durationUs
1155
+ )
1156
+ (anchor * plan.bitrateScale).toInt()
1157
+ .coerceIn(MIN_COMPRESSED_BIT_RATE, MAX_COMPRESSED_BIT_RATE)
1158
+ } else {
1159
+ readBitRate(sourceFormat, codedWidth, codedHeight, frameRate)
1160
+ }
1161
+ val encodeMime = container.videoMimeType
1162
+
1163
+ val encoderFormat = MediaFormat.createVideoFormat(encodeMime, outWidth, outHeight).apply {
1164
+ setInteger(
1165
+ MediaFormat.KEY_COLOR_FORMAT,
1166
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
1167
+ )
1168
+ setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
1169
+ setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
1170
+ setInteger(
1171
+ MediaFormat.KEY_I_FRAME_INTERVAL,
1172
+ plan.keyFrameIntervalSeconds.coerceAtLeast(1)
1173
+ )
1174
+ applyEncoderThreadingHints(this, plan)
1175
+ }
1176
+ encoder = MediaCodec.createEncoderByType(encodeMime)
1177
+ encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
1178
+ inputSurface = InputSurface(encoder.createInputSurface())
1179
+ inputSurface.makeCurrent()
1180
+ encoder.start()
1181
+
1182
+ outputSurface = OutputSurface(renderDegrees, cropScaleU, cropScaleV)
1183
+ val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
1184
+ decoder = MediaCodec.createDecoderByType(sourceMime)
1185
+ // Decode in the CODED orientation. MediaCodec otherwise honours the container rotation
1186
+ // MediaExtractor reports as KEY_ROTATION and hands back pre-rotated frames, which would
1187
+ // either double-rotate the baked path or silently rotate the metadata path. See [compress].
1188
+ sourceFormat.setInteger(MediaFormat.KEY_ROTATION, 0)
1189
+ decoder.configure(sourceFormat, outputSurface.surface, null, 0)
1190
+ decoder.start()
1191
+ extractor.selectTrack(videoTrack)
1192
+
1193
+ muxer = MediaMuxer(output.absolutePath, container.muxerOutputFormat)
1194
+ if (container.supportsOrientationHint) {
1195
+ // Un-rotated pixels + the source's own hint = a copy displayed exactly like the original.
1196
+ muxer.setOrientationHint(sourceRotation)
1197
+ }
1198
+
1199
+ audioInfo = if (dropAudio) null else AudioPassthrough.open(input)
1200
+ // Only an audio track the user still WANTS but the container cannot hold counts as dropped;
1201
+ // a deliberately muted export is not something to warn about.
1202
+ val audioDropped = audioInfo != null && !container.carriesAudio(audioInfo.mimeType)
1203
+ val audioToMux = if (audioDropped) null else audioInfo
1204
+
1205
+ val ok = runVideoTranscode(
1206
+ extractor, decoder, encoder, inputSurface, outputSurface, muxer, audioToMux,
1207
+ // The yardstick every progress fraction is measured against, read from the SOURCE track
1208
+ // exactly as [compress] reads it: this pass copies presentation timestamps through
1209
+ // verbatim, so the output rides the same timeline. 0 when the container does not declare a
1210
+ // duration, which reports no progress rather than a fraction of a guessed length.
1211
+ durationUs = readDurationUs(sourceFormat),
1212
+ onProgress = onProgress,
1213
+ progressCeiling = PROGRESS_CONVERT_LOOP_CEILING
1214
+ )
1215
+ if (!ok) return ConversionResult(false)
1216
+
1217
+ // The audio copy is a full pass over the source's audio track, so it reports its own
1218
+ // position rather than being folded into the step above it.
1219
+ val audioSpan = PROGRESS_CONVERT_AFTER_AUDIO - PROGRESS_CONVERT_LOOP_CEILING
1220
+ audioToMux?.copyTo(muxer) { copied ->
1221
+ onProgress?.invoke(PROGRESS_CONVERT_LOOP_CEILING + audioSpan * copied)
1222
+ }
1223
+ onProgress?.invoke(PROGRESS_CONVERT_AFTER_AUDIO)
1224
+
1225
+ // Stopping the muxer is what writes the container's index, and on a long clip it is not
1226
+ // instant. Reported BEFORE it runs, so the caller's overlay describes the step it is
1227
+ // actually waiting on; the finally below then has nothing left to stop.
1228
+ onProgress?.invoke(PROGRESS_CONVERT_TAIL)
1229
+ runCatching { muxer.stop() }.onFailure {
1230
+ Log.w(TAG, "Muxer did not stop cleanly after conversion", it)
1231
+ }
1232
+ muxerStopped = true
1233
+
1234
+ // Only now is there a complete, muxed file: the encoder is flushed, the audio track is
1235
+ // written and the container is closed. Reporting 1.0 any earlier would tell the caller the
1236
+ // copy was ready while the muxer was still finishing it.
1237
+ onProgress?.invoke(1f)
1238
+ return ConversionResult(true, audioDropped)
1239
+ } catch (e: Exception) {
1240
+ Log.e(TAG, "Video container conversion to ${container.extension} failed", e)
1241
+ return ConversionResult(false)
1242
+ } catch (e: OutOfMemoryError) {
1243
+ Log.e(TAG, "Video container conversion ran out of memory", e)
1244
+ return ConversionResult(false)
1245
+ } finally {
1246
+ try { decoder?.stop() } catch (_: Exception) {}
1247
+ try { decoder?.release() } catch (_: Exception) {}
1248
+ try { encoder?.stop() } catch (_: Exception) {}
1249
+ try { encoder?.release() } catch (_: Exception) {}
1250
+ // Only if the success path did not already stop it — stopping a stopped muxer throws.
1251
+ if (!muxerStopped) {
1252
+ try { muxer?.stop() } catch (_: Exception) {}
1253
+ }
1254
+ try { muxer?.release() } catch (_: Exception) {}
1255
+ try { outputSurface?.release() } catch (_: Exception) {}
1256
+ try { inputSurface?.release() } catch (_: Exception) {}
1257
+ try { audioInfo?.release() } catch (_: Exception) {}
1258
+ try { extractor?.release() } catch (_: Exception) {}
1259
+ }
1260
+ }
1261
+
1262
+ /**
1263
+ * Drives the decode → rotate → encode loop until the encoder signals end-of-stream,
1264
+ * writing each encoded frame to [muxer]. Returns false if the muxer could not be started
1265
+ * (e.g. the encoder never produced an output format).
1266
+ *
1267
+ * @param durationUs the source's length in microseconds, which is what [onProgress]'s fraction
1268
+ * is measured against. 0 (the default) means "unknown", and reports nothing at all rather than
1269
+ * inventing a denominator.
1270
+ * @param onProgress how far through the pass this is, 0.0..[progressCeiling], called on this
1271
+ * thread as encoded frames are muxed. Never called twice with the same value and never called
1272
+ * with a smaller one than the last.
1273
+ * @param progressCeiling the highest fraction this loop may report, which is how much of the
1274
+ * caller's 0..1 range belongs to the loop as opposed to the steps that follow it. A caller with
1275
+ * an expensive tail of its own (an audio copy, a muxer close-out) hands over a lower ceiling
1276
+ * and reports the rest itself.
1277
+ */
1278
+ private fun runVideoTranscode(
1279
+ extractor: MediaExtractor,
1280
+ decoder: MediaCodec,
1281
+ encoder: MediaCodec,
1282
+ inputSurface: InputSurface,
1283
+ outputSurface: OutputSurface,
1284
+ muxer: MediaMuxer,
1285
+ audioInfo: AudioPassthrough?,
1286
+ durationUs: Long = 0L,
1287
+ onProgress: ((Float) -> Unit)? = null,
1288
+ progressCeiling: Float = PROGRESS_TRANSCODE_CEILING
1289
+ ): Boolean {
1290
+ val bufferInfo = MediaCodec.BufferInfo()
1291
+ var videoTrackIndex = -1
1292
+ var muxerStarted = false
1293
+
1294
+ var inputDone = false
1295
+ var decoderDone = false
1296
+ var encoderDone = false
1297
+
1298
+ // Last fraction handed to [onProgress] and when, so the callback is throttled by BOTH time and
1299
+ // movement: a 4K frame can take longer than the interval (movement wins) and a 60fps clip
1300
+ // produces frames far faster than it (time wins).
1301
+ var lastReportedProgress = 0f
1302
+ var lastReportedAtMs = 0L
1303
+
1304
+ while (!encoderDone) {
1305
+ // 1) Feed compressed samples into the decoder.
1306
+ if (!inputDone) {
1307
+ val inIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
1308
+ if (inIndex >= 0) {
1309
+ val inputBuffer = decoder.getInputBuffer(inIndex)!!
1310
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
1311
+ if (sampleSize < 0) {
1312
+ decoder.queueInputBuffer(
1313
+ inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
1314
+ )
1315
+ inputDone = true
1316
+ } else {
1317
+ decoder.queueInputBuffer(
1318
+ inIndex, 0, sampleSize, extractor.sampleTime, 0
1319
+ )
1320
+ extractor.advance()
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ // 2) Pull decoded frames, render them rotated into the encoder's input Surface.
1326
+ if (!decoderDone) {
1327
+ val outIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
1328
+ if (outIndex >= 0) {
1329
+ val eos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
1330
+ val render = bufferInfo.size != 0
1331
+ decoder.releaseOutputBuffer(outIndex, render)
1332
+ if (render) {
1333
+ outputSurface.awaitNewImage()
1334
+ outputSurface.drawImage()
1335
+ // Carry the source presentation timestamp forward so duration and A/V sync
1336
+ // are preserved exactly.
1337
+ inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000L)
1338
+ inputSurface.swapBuffers()
1339
+ }
1340
+ if (eos) {
1341
+ decoderDone = true
1342
+ encoder.signalEndOfInputStream()
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ // 3) Drain the encoder, starting the muxer once its real output format is known.
1348
+ var encoderOutputAvailable = true
1349
+ while (encoderOutputAvailable) {
1350
+ val outIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
1351
+ when {
1352
+ outIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderOutputAvailable = false
1353
+ outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
1354
+ if (muxerStarted) throw RuntimeException("Encoder format changed twice")
1355
+ videoTrackIndex = muxer.addTrack(encoder.outputFormat)
1356
+ audioInfo?.addTrack(muxer)
1357
+ muxer.start()
1358
+ muxerStarted = true
1359
+ }
1360
+ outIndex >= 0 -> {
1361
+ val encoded = encoder.getOutputBuffer(outIndex)!!
1362
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
1363
+ // Codec config is folded into the track format by the muxer; drop it.
1364
+ bufferInfo.size = 0
1365
+ }
1366
+ if (bufferInfo.size != 0) {
1367
+ if (!muxerStarted) throw RuntimeException("Muxer not started before data")
1368
+ encoded.position(bufferInfo.offset)
1369
+ encoded.limit(bufferInfo.offset + bufferInfo.size)
1370
+ muxer.writeSampleData(videoTrackIndex, encoded, bufferInfo)
1371
+ // Progress is reported for the sample just WRITTEN, so the fraction only ever
1372
+ // describes work that is already in the output file.
1373
+ if (onProgress != null && durationUs > 0L && bufferInfo.presentationTimeUs > 0L) {
1374
+ val fraction = (bufferInfo.presentationTimeUs.toFloat() / durationUs.toFloat())
1375
+ .coerceIn(0f, progressCeiling)
1376
+ val now = SystemClock.elapsedRealtime()
1377
+ val moved = fraction - lastReportedProgress
1378
+ if (moved > 0f &&
1379
+ (moved >= PROGRESS_MIN_DELTA || now - lastReportedAtMs >= PROGRESS_MIN_INTERVAL_MS)
1380
+ ) {
1381
+ lastReportedProgress = fraction
1382
+ lastReportedAtMs = now
1383
+ onProgress(fraction)
1384
+ }
1385
+ }
1386
+ }
1387
+ encoder.releaseOutputBuffer(outIndex, false)
1388
+ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
1389
+ encoderDone = true
1390
+ encoderOutputAvailable = false
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+ return muxerStarted
1397
+ }
1398
+
1399
+ private fun firstTrack(extractor: MediaExtractor, mimePrefix: String): Int? {
1400
+ for (i in 0 until extractor.trackCount) {
1401
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: continue
1402
+ if (mime.startsWith(mimePrefix)) return i
1403
+ }
1404
+ return null
1405
+ }
1406
+
1407
+ /**
1408
+ * The track's length in microseconds, or 0 when the container does not declare one.
1409
+ *
1410
+ * 0 is not a failure: it is the honest "there is no denominator here", and every caller treats
1411
+ * it as "report no progress" rather than dividing by a guess. Nothing about the transcode
1412
+ * depends on it — only the progress fraction does.
1413
+ */
1414
+ private fun readDurationUs(format: MediaFormat): Long {
1415
+ if (!format.containsKey(MediaFormat.KEY_DURATION)) return 0L
1416
+ return try {
1417
+ format.getLong(MediaFormat.KEY_DURATION).coerceAtLeast(0L)
1418
+ } catch (_: Exception) {
1419
+ 0L
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * A dimension key off a track format, or 0 when the format does not carry it. [compress] reads
1425
+ * width/height directly because an extractor's video format always declares them; the size-model
1426
+ * probe goes through here instead, because a model is worth reading even off a format that is
1427
+ * missing one — a declared bit rate makes the pixel fallback moot.
1428
+ */
1429
+ private fun readDimension(format: MediaFormat, key: String): Int =
1430
+ if (!format.containsKey(key)) 0 else runCatching { format.getInteger(key) }.getOrDefault(0)
1431
+
1432
+ private fun readFrameRate(format: MediaFormat): Int {
1433
+ if (!format.containsKey(MediaFormat.KEY_FRAME_RATE)) return DEFAULT_FRAME_RATE
1434
+ // KEY_FRAME_RATE is usually stored as an Int in an extractor track format; on the rare
1435
+ // source that stores it as a Float, getInteger throws and we fall back to the default
1436
+ // (MediaFormat.getFloat is API 29+, so it is not used here — minSdk is 24).
1437
+ return try {
1438
+ val fr = format.getInteger(MediaFormat.KEY_FRAME_RATE)
1439
+ if (fr > 0) fr else DEFAULT_FRAME_RATE
1440
+ } catch (_: Exception) {
1441
+ DEFAULT_FRAME_RATE
1442
+ }
1443
+ }
1444
+
1445
+ /**
1446
+ * A track's declared bit rate, or a high-quality rate derived from its pixel throughput when the
1447
+ * container declares none. Deliberately generous, because [rotate] and [convertContainer] re-encode
1448
+ * to PRESERVE the source — [compressionSourceBitRate] is what caps it for a size-targeted pass.
1449
+ */
1450
+ private fun readBitRate(
1451
+ format: MediaFormat,
1452
+ width: Int,
1453
+ height: Int,
1454
+ frameRate: Int
1455
+ ): Int {
1456
+ // Prefer the source video track's declared bit rate so re-encoding preserves quality.
1457
+ if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
1458
+ val br = try { format.getInteger(MediaFormat.KEY_BIT_RATE) } catch (_: Exception) { 0 }
1459
+ if (br > 0) return br
1460
+ }
1461
+ // Otherwise estimate a high-quality bit rate from the pixel throughput.
1462
+ val estimated = (width.toDouble() * height.toDouble() * frameRate.toDouble() * FALLBACK_BPP)
1463
+ return estimated.toInt().coerceAtLeast(2_000_000)
1464
+ }
1465
+
1466
+ private fun isEncodableVideoMime(mime: String): Boolean {
1467
+ return mime == "video/avc" || mime == "video/hevc" ||
1468
+ mime == "video/x-vnd.on2.vp8" || mime == "video/x-vnd.on2.vp9" ||
1469
+ mime == "video/mp4v-es"
1470
+ }
1471
+
1472
+ /**
1473
+ * Stamp the profile's threading hints onto an encoder [format]. KEY_PRIORITY tells the codec
1474
+ * whether this is a realtime job (0, push the hardware) or a best-effort background one (1,
1475
+ * gentler — the low/medium-tier default that keeps a small device responsive). KEY_OPERATING_RATE
1476
+ * asks a capable device to run the transcode faster than realtime; it is left unset (0) on tiers
1477
+ * that should not be pushed. Both keys exist from API 23 and are advisory — a codec that ignores
1478
+ * them still produces a correct file. Applied to [compress] and [convertContainer] alike so the
1479
+ * profile's speed/stability trade-off is identical on both paths.
1480
+ */
1481
+ private fun applyEncoderThreadingHints(format: MediaFormat, plan: VideoCompressionPlan) {
1482
+ runCatching {
1483
+ format.setInteger(MediaFormat.KEY_PRIORITY, plan.encoderPriority.coerceIn(0, 1))
1484
+ if (plan.operatingRate > 0) {
1485
+ format.setInteger(MediaFormat.KEY_OPERATING_RATE, plan.operatingRate)
1486
+ }
1487
+ }
1488
+ }
1489
+
1490
+ /**
1491
+ * Requests the encoder's high-quality profile on [format] and configures [encoder] with it. The
1492
+ * profile (AVC High / HEVC Main) selects the codec's more efficient coding tools, so the SAME
1493
+ * target bit rate carries a visibly sharper picture — the point of the "best quality per size"
1494
+ * behaviour. Only a (profile, level) pair the encoder actually ADVERTISES in its capabilities is
1495
+ * requested, so a configure failure is not expected; if one happens anyway the profile is dropped
1496
+ * and the encoder is reconfigured at its default, so a compression never fails just for asking for
1497
+ * more quality. Returns the successfully-configured encoder (the original, or its replacement).
1498
+ */
1499
+ private fun configureWithHighQualityProfile(
1500
+ encoder: MediaCodec,
1501
+ format: MediaFormat,
1502
+ encodeMime: String
1503
+ ): MediaCodec {
1504
+ val profileLevel = preferredProfileLevel(encoder.codecInfo, encodeMime)
1505
+ if (profileLevel != null) {
1506
+ format.setInteger(MediaFormat.KEY_PROFILE, profileLevel.first)
1507
+ format.setInteger(MediaFormat.KEY_LEVEL, profileLevel.second)
1508
+ }
1509
+ return try {
1510
+ encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
1511
+ encoder
1512
+ } catch (e: Exception) {
1513
+ Log.w(TAG, "Encoder rejected high-quality profile for $encodeMime; encoding at default", e)
1514
+ runCatching { encoder.release() }
1515
+ // removeKey is API 29+; on 24-28 the advertised pair is authoritative, so a reconfigure would
1516
+ // reproduce the same rejection and the outer catch turns it into a clean "couldn't compress".
1517
+ if (profileLevel != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1518
+ format.removeKey(MediaFormat.KEY_PROFILE)
1519
+ format.removeKey(MediaFormat.KEY_LEVEL)
1520
+ }
1521
+ val retry = MediaCodec.createEncoderByType(encodeMime)
1522
+ try {
1523
+ retry.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
1524
+ } catch (e2: Exception) {
1525
+ runCatching { retry.release() }
1526
+ throw e2
1527
+ }
1528
+ retry
1529
+ }
1530
+ }
1531
+
1532
+ /**
1533
+ * The best profile the encoder for [mime] advertises, paired with the highest level it lists for
1534
+ * that profile — a combination the codec has declared it can encode, so it is safe to request. AVC
1535
+ * favours High, HEVC favours Main; returns null when neither the capabilities nor the desired
1536
+ * profile can be read, in which case the caller leaves the profile unset (encoder default).
1537
+ */
1538
+ private fun preferredProfileLevel(codecInfo: MediaCodecInfo, mime: String): Pair<Int, Int>? {
1539
+ val caps = runCatching { codecInfo.getCapabilitiesForType(mime) }.getOrNull() ?: return null
1540
+ val wanted = when (mime) {
1541
+ MIME_VIDEO_HEVC -> MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
1542
+ else -> MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
1543
+ }
1544
+ val best = caps.profileLevels
1545
+ ?.filter { it.profile == wanted }
1546
+ ?.maxByOrNull { it.level }
1547
+ ?: return null
1548
+ return wanted to best.level
1549
+ }
1550
+
1551
+ // ---------------------------------------------------------------------------------------
1552
+ // Audio passthrough: copies the original audio elementary stream sample-for-sample, so
1553
+ // audio content, its presentation timestamps, and A/V sync are preserved bit-exact.
1554
+ // ---------------------------------------------------------------------------------------
1555
+ private class AudioPassthrough private constructor(
1556
+ private val extractor: MediaExtractor,
1557
+ private val trackIndex: Int,
1558
+ private val format: MediaFormat
1559
+ ) {
1560
+ private var muxerTrackIndex = -1
1561
+
1562
+ /**
1563
+ * The source audio track's codec, so a caller can ask whether a target container is able to
1564
+ * carry it BEFORE adding the track — [MediaMuxer.addTrack] throws for a codec the container
1565
+ * does not support, which would fail the whole conversion instead of just its audio.
1566
+ */
1567
+ val mimeType: String? get() = format.getString(MediaFormat.KEY_MIME)
1568
+
1569
+ fun addTrack(muxer: MediaMuxer) {
1570
+ muxerTrackIndex = muxer.addTrack(format)
1571
+ }
1572
+
1573
+ /**
1574
+ * @param onProgress how much of the track has been copied, 0.0..1.0, called on this thread as
1575
+ * samples are written. A whole audio track is a real pass over the file — seconds of it on a
1576
+ * long clip — so it is reported rather than left as a silent gap at the end of a conversion.
1577
+ * Nothing is reported when the track declares no duration; there is no denominator to divide
1578
+ * by, and inventing one is worse than saying nothing.
1579
+ */
1580
+ fun copyTo(muxer: MediaMuxer, onProgress: ((Float) -> Unit)? = null) {
1581
+ if (muxerTrackIndex < 0) return
1582
+ val maxInputSize = if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
1583
+ format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE).coerceAtLeast(64 * 1024)
1584
+ } else {
1585
+ 256 * 1024
1586
+ }
1587
+ val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) {
1588
+ runCatching { format.getLong(MediaFormat.KEY_DURATION) }.getOrDefault(0L)
1589
+ } else {
1590
+ 0L
1591
+ }
1592
+ var lastReportedAtMs = 0L
1593
+ val buffer = ByteBuffer.allocate(maxInputSize)
1594
+ val info = MediaCodec.BufferInfo()
1595
+ extractor.selectTrack(trackIndex)
1596
+ extractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
1597
+ while (true) {
1598
+ buffer.clear()
1599
+ val size = extractor.readSampleData(buffer, 0)
1600
+ if (size < 0) break
1601
+ info.offset = 0
1602
+ info.size = size
1603
+ info.presentationTimeUs = extractor.sampleTime
1604
+ info.flags = extractorFlagsToBufferFlags(extractor.sampleFlags)
1605
+ muxer.writeSampleData(muxerTrackIndex, buffer, info)
1606
+ // Audio samples are tiny and there are thousands of them, so this is throttled by time
1607
+ // alone — the fraction moves far too little between two of them to be worth a callback.
1608
+ if (onProgress != null && durationUs > 0L && info.presentationTimeUs > 0L) {
1609
+ val now = SystemClock.elapsedRealtime()
1610
+ if (now - lastReportedAtMs >= PROGRESS_MIN_INTERVAL_MS) {
1611
+ lastReportedAtMs = now
1612
+ onProgress(
1613
+ (info.presentationTimeUs.toFloat() / durationUs.toFloat()).coerceIn(0f, 1f)
1614
+ )
1615
+ }
1616
+ }
1617
+ extractor.advance()
1618
+ }
1619
+ }
1620
+
1621
+ fun release() {
1622
+ try { extractor.release() } catch (_: Exception) {}
1623
+ }
1624
+
1625
+ private fun extractorFlagsToBufferFlags(sampleFlags: Int): Int {
1626
+ var flags = 0
1627
+ if (sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) {
1628
+ flags = flags or MediaCodec.BUFFER_FLAG_KEY_FRAME
1629
+ }
1630
+ return flags
1631
+ }
1632
+
1633
+ companion object {
1634
+ fun open(input: File): AudioPassthrough? {
1635
+ return try {
1636
+ val extractor = MediaExtractor()
1637
+ extractor.setDataSource(input.absolutePath)
1638
+ var audioTrack = -1
1639
+ for (i in 0 until extractor.trackCount) {
1640
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: continue
1641
+ if (mime.startsWith("audio/")) { audioTrack = i; break }
1642
+ }
1643
+ if (audioTrack < 0) {
1644
+ extractor.release()
1645
+ null
1646
+ } else {
1647
+ AudioPassthrough(extractor, audioTrack, extractor.getTrackFormat(audioTrack))
1648
+ }
1649
+ } catch (e: Exception) {
1650
+ Log.w(TAG, "No audio track to copy", e)
1651
+ null
1652
+ }
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ private fun readRotation(input: File): Int {
1658
+ var retriever: android.media.MediaMetadataRetriever? = null
1659
+ return try {
1660
+ retriever = android.media.MediaMetadataRetriever()
1661
+ retriever.setDataSource(input.absolutePath)
1662
+ retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
1663
+ } catch (e: Exception) {
1664
+ 0
1665
+ } finally {
1666
+ runCatching { retriever?.release() }
1667
+ }
1668
+ }
1669
+
1670
+ // ---------------------------------------------------------------------------------------
1671
+ // EGL / OpenGL helpers (adapted from the well-known Android "bigflake" decode-edit-encode
1672
+ // pattern) used to draw the decoded frame, rotated, into the encoder's input Surface.
1673
+ // ---------------------------------------------------------------------------------------
1674
+
1675
+ /** Wraps the encoder's input [Surface] in an EGL window surface + context. */
1676
+ private class InputSurface(private val surface: Surface) {
1677
+ private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
1678
+ private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
1679
+ private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
1680
+
1681
+ // EGL_RECORDABLE_ANDROID — marks the config as usable by a MediaCodec input surface.
1682
+ private val eglRecordableAndroid = 0x3142
1683
+
1684
+ init {
1685
+ eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
1686
+ if (eglDisplay === EGL14.EGL_NO_DISPLAY) throw RuntimeException("no EGL display")
1687
+ val version = IntArray(2)
1688
+ if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
1689
+ throw RuntimeException("eglInitialize failed")
1690
+ }
1691
+ val configAttribs = intArrayOf(
1692
+ EGL14.EGL_RED_SIZE, 8,
1693
+ EGL14.EGL_GREEN_SIZE, 8,
1694
+ EGL14.EGL_BLUE_SIZE, 8,
1695
+ EGL14.EGL_ALPHA_SIZE, 8,
1696
+ EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
1697
+ eglRecordableAndroid, 1,
1698
+ EGL14.EGL_NONE
1699
+ )
1700
+ val configs = arrayOfNulls<EGLConfig>(1)
1701
+ val numConfigs = IntArray(1)
1702
+ if (!EGL14.eglChooseConfig(
1703
+ eglDisplay, configAttribs, 0, configs, 0, configs.size, numConfigs, 0
1704
+ )
1705
+ ) {
1706
+ throw RuntimeException("eglChooseConfig failed")
1707
+ }
1708
+ val contextAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
1709
+ eglContext = EGL14.eglCreateContext(
1710
+ eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
1711
+ )
1712
+ val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
1713
+ eglSurface = EGL14.eglCreateWindowSurface(
1714
+ eglDisplay, configs[0], surface, surfaceAttribs, 0
1715
+ )
1716
+ if (eglSurface === EGL14.EGL_NO_SURFACE) throw RuntimeException("eglCreateWindowSurface failed")
1717
+ }
1718
+
1719
+ fun makeCurrent() {
1720
+ if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
1721
+ throw RuntimeException("eglMakeCurrent failed")
1722
+ }
1723
+ }
1724
+
1725
+ fun swapBuffers(): Boolean = EGL14.eglSwapBuffers(eglDisplay, eglSurface)
1726
+
1727
+ fun setPresentationTime(nsecs: Long) {
1728
+ EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
1729
+ }
1730
+
1731
+ fun release() {
1732
+ if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
1733
+ EGL14.eglMakeCurrent(
1734
+ eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
1735
+ )
1736
+ EGL14.eglDestroySurface(eglDisplay, eglSurface)
1737
+ EGL14.eglDestroyContext(eglDisplay, eglContext)
1738
+ EGL14.eglReleaseThread()
1739
+ EGL14.eglTerminate(eglDisplay)
1740
+ }
1741
+ eglDisplay = EGL14.EGL_NO_DISPLAY
1742
+ eglContext = EGL14.EGL_NO_CONTEXT
1743
+ eglSurface = EGL14.EGL_NO_SURFACE
1744
+ surface.release()
1745
+ }
1746
+ }
1747
+
1748
+ /**
1749
+ * Owns the [Surface] the decoder renders into (backed by a [SurfaceTexture] mapped to an
1750
+ * external GL texture), plus the [TextureRender] that redraws that texture rotated into
1751
+ * the current EGL surface (the encoder input).
1752
+ */
1753
+ private class OutputSurface(
1754
+ rotationDegrees: Int,
1755
+ private val cropScaleX: Float = 1f,
1756
+ private val cropScaleY: Float = 1f
1757
+ ) : SurfaceTexture.OnFrameAvailableListener {
1758
+ private val textureRender = TextureRender(rotationDegrees, cropScaleX, cropScaleY)
1759
+ private val surfaceTexture: SurfaceTexture
1760
+ val surface: Surface
1761
+
1762
+ // java.lang.Object (not kotlin.Any) so wait()/notifyAll() are available for the
1763
+ // cross-thread frame handshake between onFrameAvailable and awaitNewImage.
1764
+ private val frameSyncObject = java.lang.Object()
1765
+ private var frameAvailable = false
1766
+
1767
+ init {
1768
+ textureRender.surfaceCreated()
1769
+ surfaceTexture = SurfaceTexture(textureRender.textureId)
1770
+ surfaceTexture.setOnFrameAvailableListener(this)
1771
+ surface = Surface(surfaceTexture)
1772
+ }
1773
+
1774
+ fun awaitNewImage() {
1775
+ val timeoutMs = 2500L
1776
+ synchronized(frameSyncObject) {
1777
+ while (!frameAvailable) {
1778
+ frameSyncObject.wait(timeoutMs)
1779
+ if (!frameAvailable) throw RuntimeException("Surface frame wait timed out")
1780
+ }
1781
+ frameAvailable = false
1782
+ }
1783
+ surfaceTexture.updateTexImage()
1784
+ }
1785
+
1786
+ fun drawImage() {
1787
+ textureRender.drawFrame(surfaceTexture)
1788
+ }
1789
+
1790
+ override fun onFrameAvailable(st: SurfaceTexture) {
1791
+ synchronized(frameSyncObject) {
1792
+ frameAvailable = true
1793
+ frameSyncObject.notifyAll()
1794
+ }
1795
+ }
1796
+
1797
+ fun release() {
1798
+ surface.release()
1799
+ surfaceTexture.release()
1800
+ }
1801
+ }
1802
+
1803
+ /**
1804
+ * Draws an external OES texture (the decoded frame) onto a full-screen quad, applying a
1805
+ * 90°-multiple rotation. The quad fills normalized device coordinates, so rotating the
1806
+ * geometry by a multiple of 90° maps the source onto the (equally sized, axis-swapped)
1807
+ * destination surface with no cropping, scaling, or stretching.
1808
+ */
1809
+ private class TextureRender(rotationDegrees: Int, private val cropScaleX: Float = 1f, private val cropScaleY: Float = 1f) {
1810
+ var textureId = -1
1811
+ private set
1812
+
1813
+ private val triangleVerticesData = floatArrayOf(
1814
+ // X, Y, Z, U, V
1815
+ -1.0f, -1.0f, 0f, 0f, 0f,
1816
+ 1.0f, -1.0f, 0f, 1f, 0f,
1817
+ -1.0f, 1.0f, 0f, 0f, 1f,
1818
+ 1.0f, 1.0f, 0f, 1f, 1f
1819
+ )
1820
+ private val triangleVertices: FloatBuffer
1821
+
1822
+ private val mvpMatrix = FloatArray(16)
1823
+ private val stMatrix = FloatArray(16)
1824
+ // Video rotation metadata is clockwise; a clockwise image rotation is a negative
1825
+ // (clockwise) rotation about +Z in the y-up GL/NDC space used here.
1826
+ private val rotation = (((-rotationDegrees) % 360) + 360) % 360
1827
+
1828
+ private var program = 0
1829
+ private var uMVPMatrixHandle = 0
1830
+ private var uSTMatrixHandle = 0
1831
+ private var aPositionHandle = 0
1832
+ private var aTextureHandle = 0
1833
+
1834
+ private val vertexShader = """
1835
+ uniform mat4 uMVPMatrix;
1836
+ uniform mat4 uSTMatrix;
1837
+ attribute vec4 aPosition;
1838
+ attribute vec4 aTextureCoord;
1839
+ varying vec2 vTextureCoord;
1840
+ void main() {
1841
+ gl_Position = uMVPMatrix * aPosition;
1842
+ vTextureCoord = (uSTMatrix * aTextureCoord).xy;
1843
+ }
1844
+ """.trimIndent()
1845
+
1846
+ private val fragmentShader = """
1847
+ #extension GL_OES_EGL_image_external : require
1848
+ precision mediump float;
1849
+ varying vec2 vTextureCoord;
1850
+ uniform samplerExternalOES sTexture;
1851
+ void main() {
1852
+ gl_FragColor = texture2D(sTexture, vTextureCoord);
1853
+ }
1854
+ """.trimIndent()
1855
+
1856
+ init {
1857
+ triangleVertices = ByteBuffer
1858
+ .allocateDirect(triangleVerticesData.size * FLOAT_SIZE_BYTES)
1859
+ .order(ByteOrder.nativeOrder())
1860
+ .asFloatBuffer()
1861
+ triangleVertices.put(triangleVerticesData).position(0)
1862
+ Matrix.setIdentityM(stMatrix, 0)
1863
+ }
1864
+
1865
+ fun surfaceCreated() {
1866
+ program = createProgram(vertexShader, fragmentShader)
1867
+ if (program == 0) throw RuntimeException("failed to create GL program")
1868
+ aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
1869
+ aTextureHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
1870
+ uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
1871
+ uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
1872
+
1873
+ val textures = IntArray(1)
1874
+ GLES20.glGenTextures(1, textures, 0)
1875
+ textureId = textures[0]
1876
+ GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
1877
+ GLES20.glTexParameterf(
1878
+ GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
1879
+ GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
1880
+ )
1881
+ GLES20.glTexParameterf(
1882
+ GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
1883
+ GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
1884
+ )
1885
+ GLES20.glTexParameteri(
1886
+ GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
1887
+ GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
1888
+ )
1889
+ GLES20.glTexParameteri(
1890
+ GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
1891
+ GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
1892
+ )
1893
+ }
1894
+
1895
+ fun drawFrame(st: SurfaceTexture) {
1896
+ st.getTransformMatrix(stMatrix)
1897
+ Matrix.setIdentityM(mvpMatrix, 0)
1898
+ if (rotation != 0) {
1899
+ Matrix.rotateM(mvpMatrix, 0, rotation.toFloat(), 0f, 0f, 1f)
1900
+ }
1901
+
1902
+ // Apply the requested aspect-ratio crop as a NORMALISED-texture (u/v) scale on top of the
1903
+ // SurfaceTexture's own orientation transform — NOT an MVP scale. Scaling only the longer
1904
+ // axis about the centre trims exactly that axis, so the re-encoded frame is genuinely
1905
+ // centre-cropped to the target aspect (a uniform MVP scale would magnify both axes equally
1906
+ // and leave the frame's aspect unchanged). cropScaleX/cropScaleY are the fraction of the
1907
+ // coded width/height axis that remains (the trimmed axis < 1, the untouched axis == 1).
1908
+ if (cropScaleX < 1f || cropScaleY < 1f) {
1909
+ val crop = FloatArray(16)
1910
+ Matrix.setIdentityM(crop, 0)
1911
+ // Centre the kept band: offset by half the trimmed margin on each trimmed axis, then
1912
+ // scale about that offset (translate-then-scale → u' = 0.5 + (u - 0.5) * scaleX).
1913
+ Matrix.translateM(crop, 0, 0.5f * (1f - cropScaleX), 0.5f * (1f - cropScaleY), 0f)
1914
+ if (cropScaleX < 1f) Matrix.scaleM(crop, 0, cropScaleX, 1f, 1f)
1915
+ if (cropScaleY < 1f) Matrix.scaleM(crop, 0, 1f, cropScaleY, 1f)
1916
+ val combined = FloatArray(16)
1917
+ // st * crop → the crop selects the central band first, then the SurfaceTexture's own
1918
+ // transform maps that band to the buffer. Safe against aliasing via a temp array.
1919
+ Matrix.multiplyMM(combined, 0, stMatrix, 0, crop, 0)
1920
+ System.arraycopy(combined, 0, stMatrix, 0, 16)
1921
+ }
1922
+
1923
+ GLES20.glClearColor(0f, 0f, 0f, 1f)
1924
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
1925
+
1926
+ GLES20.glUseProgram(program)
1927
+
1928
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
1929
+ GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
1930
+
1931
+ triangleVertices.position(TRIANGLE_VERTICES_DATA_POS_OFFSET)
1932
+ GLES20.glVertexAttribPointer(
1933
+ aPositionHandle, 3, GLES20.GL_FLOAT, false,
1934
+ TRIANGLE_VERTICES_DATA_STRIDE_BYTES, triangleVertices
1935
+ )
1936
+ GLES20.glEnableVertexAttribArray(aPositionHandle)
1937
+
1938
+ triangleVertices.position(TRIANGLE_VERTICES_DATA_UV_OFFSET)
1939
+ GLES20.glVertexAttribPointer(
1940
+ aTextureHandle, 2, GLES20.GL_FLOAT, false,
1941
+ TRIANGLE_VERTICES_DATA_STRIDE_BYTES, triangleVertices
1942
+ )
1943
+ GLES20.glEnableVertexAttribArray(aTextureHandle)
1944
+
1945
+ GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
1946
+ GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
1947
+
1948
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
1949
+ GLES20.glFinish()
1950
+ }
1951
+
1952
+ private fun createProgram(vertexSource: String, fragmentSource: String): Int {
1953
+ val vertex = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
1954
+ if (vertex == 0) return 0
1955
+ val fragment = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
1956
+ if (fragment == 0) return 0
1957
+ var prog = GLES20.glCreateProgram()
1958
+ if (prog == 0) return 0
1959
+ GLES20.glAttachShader(prog, vertex)
1960
+ GLES20.glAttachShader(prog, fragment)
1961
+ GLES20.glLinkProgram(prog)
1962
+ val linkStatus = IntArray(1)
1963
+ GLES20.glGetProgramiv(prog, GLES20.GL_LINK_STATUS, linkStatus, 0)
1964
+ if (linkStatus[0] != GLES20.GL_TRUE) {
1965
+ GLES20.glDeleteProgram(prog)
1966
+ prog = 0
1967
+ }
1968
+ return prog
1969
+ }
1970
+
1971
+ private fun loadShader(shaderType: Int, source: String): Int {
1972
+ var shader = GLES20.glCreateShader(shaderType)
1973
+ if (shader == 0) return 0
1974
+ GLES20.glShaderSource(shader, source)
1975
+ GLES20.glCompileShader(shader)
1976
+ val compiled = IntArray(1)
1977
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
1978
+ if (compiled[0] == 0) {
1979
+ GLES20.glDeleteShader(shader)
1980
+ shader = 0
1981
+ }
1982
+ return shader
1983
+ }
1984
+
1985
+ companion object {
1986
+ private const val FLOAT_SIZE_BYTES = 4
1987
+ private const val TRIANGLE_VERTICES_DATA_STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
1988
+ private const val TRIANGLE_VERTICES_DATA_POS_OFFSET = 0
1989
+ private const val TRIANGLE_VERTICES_DATA_UV_OFFSET = 3
1990
+ }
1991
+ }
1992
+ }