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.
- package/CustomCamera.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +833 -0
- package/android/build.gradle +135 -0
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +72 -0
- package/android/src/main/java/android/print/PdfPrint.java +51 -0
- package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
- package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
- package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
- package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
- package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
- package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
- package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
- package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
- package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
- package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
- package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
- package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
- package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
- package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
- package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
- package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
- package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
- package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
- package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
- package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
- package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
- package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
- package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
- package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
- package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
- package/android/src/main/java/com/customcamera/Localization.kt +206 -0
- package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
- package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
- package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
- package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
- package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
- package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
- package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
- package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
- package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
- package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
- package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
- package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
- package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
- package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
- package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
- package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
- package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
- package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
- package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
- package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
- package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
- package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
- package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
- package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
- package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
- package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
- package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
- package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
- package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
- package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
- package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
- package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
- package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
- package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
- package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
- package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
- package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
- package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
- package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
- package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
- package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
- package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
- package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
- package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
- package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
- package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
- package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
- package/android/src/main/res/drawable/bg_pill.xml +6 -0
- package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
- package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
- package/android/src/main/res/drawable/bg_send_button.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
- package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
- package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
- package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
- package/android/src/main/res/drawable/ic_audio.xml +10 -0
- package/android/src/main/res/drawable/ic_back.xml +10 -0
- package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
- package/android/src/main/res/drawable/ic_check.xml +17 -0
- package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
- package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
- package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
- package/android/src/main/res/drawable/ic_close.xml +10 -0
- package/android/src/main/res/drawable/ic_cover.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +10 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_document.xml +10 -0
- package/android/src/main/res/drawable/ic_done.xml +10 -0
- package/android/src/main/res/drawable/ic_download.xml +10 -0
- package/android/src/main/res/drawable/ic_emoji.xml +10 -0
- package/android/src/main/res/drawable/ic_filter.xml +10 -0
- package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
- package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
- package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
- package/android/src/main/res/drawable/ic_flip.xml +15 -0
- package/android/src/main/res/drawable/ic_gallery.xml +10 -0
- package/android/src/main/res/drawable/ic_gif.xml +24 -0
- package/android/src/main/res/drawable/ic_lock.xml +19 -0
- package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
- package/android/src/main/res/drawable/ic_marker.xml +10 -0
- package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
- package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
- package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_redo.xml +10 -0
- package/android/src/main/res/drawable/ic_rotate.xml +9 -0
- package/android/src/main/res/drawable/ic_send.xml +10 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
- package/android/src/main/res/drawable/ic_sticker.xml +24 -0
- package/android/src/main/res/drawable/ic_text.xml +10 -0
- package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
- package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
- package/android/src/main/res/drawable/ic_trim.xml +23 -0
- package/android/src/main/res/drawable/ic_undo.xml +10 -0
- package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_video_note.xml +13 -0
- package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
- package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
- package/android/src/main/res/drawable/sticker_heart.xml +9 -0
- package/android/src/main/res/drawable/sticker_smile.xml +9 -0
- package/android/src/main/res/drawable/sticker_star.xml +9 -0
- package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
- package/android/src/main/res/values/colors.xml +34 -0
- package/android/src/main/res/values/ids.xml +15 -0
- package/android/src/main/res/values/strings.xml +15 -0
- package/android/src/main/res/values/styles.xml +20 -0
- package/android/src/main/res/xml/file_paths.xml +34 -0
- package/ios/CustomCamera.h +5 -0
- package/ios/CustomCamera.mm +214 -0
- package/lib/module/AudioPlayerContext.js +233 -0
- package/lib/module/AudioPlayerContext.js.map +1 -0
- package/lib/module/CameraPreview.js +304 -0
- package/lib/module/CameraPreview.js.map +1 -0
- package/lib/module/NativeAnimatedImageView.js +41 -0
- package/lib/module/NativeAnimatedImageView.js.map +1 -0
- package/lib/module/NativeAudioPlayerView.js +33 -0
- package/lib/module/NativeAudioPlayerView.js.map +1 -0
- package/lib/module/NativeBlurView.js +35 -0
- package/lib/module/NativeBlurView.js.map +1 -0
- package/lib/module/NativeCustomCamera.js +128 -0
- package/lib/module/NativeCustomCamera.js.map +1 -0
- package/lib/module/NativeCustomCamera.web.js +143 -0
- package/lib/module/NativeCustomCamera.web.js.map +1 -0
- package/lib/module/NativeCustomVideoView.js +5 -0
- package/lib/module/NativeCustomVideoView.js.map +1 -0
- package/lib/module/NativeDocumentPreviewView.js +156 -0
- package/lib/module/NativeDocumentPreviewView.js.map +1 -0
- package/lib/module/NativeMediaThumbnailView.js +110 -0
- package/lib/module/NativeMediaThumbnailView.js.map +1 -0
- package/lib/module/NativeSvgIcon.js +32 -0
- package/lib/module/NativeSvgIcon.js.map +1 -0
- package/lib/module/ResponsivePreviewContainer.js +171 -0
- package/lib/module/ResponsivePreviewContainer.js.map +1 -0
- package/lib/module/accessibility.js +61 -0
- package/lib/module/accessibility.js.map +1 -0
- package/lib/module/config.js +352 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/cpuOptimization.js +296 -0
- package/lib/module/cpuOptimization.js.map +1 -0
- package/lib/module/db/DatabaseManager.js +191 -0
- package/lib/module/db/DatabaseManager.js.map +1 -0
- package/lib/module/db/MediaMapper.js +284 -0
- package/lib/module/db/MediaMapper.js.map +1 -0
- package/lib/module/db/MediaRepository.js +242 -0
- package/lib/module/db/MediaRepository.js.map +1 -0
- package/lib/module/db/index.js +41 -0
- package/lib/module/db/index.js.map +1 -0
- package/lib/module/db/migrations.js +180 -0
- package/lib/module/db/migrations.js.map +1 -0
- package/lib/module/db/react-native-sqlcipher.d.js +2 -0
- package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
- package/lib/module/db/secureKey.js +87 -0
- package/lib/module/db/secureKey.js.map +1 -0
- package/lib/module/db/types.js +57 -0
- package/lib/module/db/types.js.map +1 -0
- package/lib/module/db/useMediaDatabase.js +123 -0
- package/lib/module/db/useMediaDatabase.js.map +1 -0
- package/lib/module/defaultIcons.js +2259 -0
- package/lib/module/defaultIcons.js.map +1 -0
- package/lib/module/deviceCapabilities.js +408 -0
- package/lib/module/deviceCapabilities.js.map +1 -0
- package/lib/module/errors.js +267 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/iconConfig.js +796 -0
- package/lib/module/iconConfig.js.map +1 -0
- package/lib/module/icons.js +514 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +251 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/jest-globals.d.js +2 -0
- package/lib/module/jest-globals.d.js.map +1 -0
- package/lib/module/library/MediaLibrary.js +107 -0
- package/lib/module/library/MediaLibrary.js.map +1 -0
- package/lib/module/library/MediaLibraryScreen.js +2597 -0
- package/lib/module/library/MediaLibraryScreen.js.map +1 -0
- package/lib/module/library/components/AudioPreview.js +74 -0
- package/lib/module/library/components/AudioPreview.js.map +1 -0
- package/lib/module/library/components/DocumentPreview.js +331 -0
- package/lib/module/library/components/DocumentPreview.js.map +1 -0
- package/lib/module/library/components/ExpandableFab.js +343 -0
- package/lib/module/library/components/ExpandableFab.js.map +1 -0
- package/lib/module/library/components/FileTiles.js +251 -0
- package/lib/module/library/components/FileTiles.js.map +1 -0
- package/lib/module/library/components/ImagePreview.js +70 -0
- package/lib/module/library/components/ImagePreview.js.map +1 -0
- package/lib/module/library/components/MediaMetadataHeader.js +175 -0
- package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
- package/lib/module/library/components/RecordAudioScreen.js +816 -0
- package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
- package/lib/module/library/components/VideoPreview.js +453 -0
- package/lib/module/library/components/VideoPreview.js.map +1 -0
- package/lib/module/library/components/ViewerCounter.js +55 -0
- package/lib/module/library/components/ViewerCounter.js.map +1 -0
- package/lib/module/library/compressionEstimate.js +507 -0
- package/lib/module/library/compressionEstimate.js.map +1 -0
- package/lib/module/library/fileMetadata.js +196 -0
- package/lib/module/library/fileMetadata.js.map +1 -0
- package/lib/module/library/index.js +42 -0
- package/lib/module/library/index.js.map +1 -0
- package/lib/module/library/libraryIconRegistry.js +69 -0
- package/lib/module/library/libraryIconRegistry.js.map +1 -0
- package/lib/module/library/libraryIcons.js +858 -0
- package/lib/module/library/libraryIcons.js.map +1 -0
- package/lib/module/library/localizedFormat.js +181 -0
- package/lib/module/library/localizedFormat.js.map +1 -0
- package/lib/module/library/palette.js +225 -0
- package/lib/module/library/palette.js.map +1 -0
- package/lib/module/library/responsive.js +53 -0
- package/lib/module/library/responsive.js.map +1 -0
- package/lib/module/locales.js +416 -0
- package/lib/module/locales.js.map +1 -0
- package/lib/module/localization.js +1064 -0
- package/lib/module/localization.js.map +1 -0
- package/lib/module/logger.js +20 -0
- package/lib/module/logger.js.map +1 -0
- package/lib/module/lowMemoryCache.js +206 -0
- package/lib/module/lowMemoryCache.js.map +1 -0
- package/lib/module/mediaHistory.js +33 -0
- package/lib/module/mediaHistory.js.map +1 -0
- package/lib/module/mediaStorageService.js +402 -0
- package/lib/module/mediaStorageService.js.map +1 -0
- package/lib/module/memory.js +79 -0
- package/lib/module/memory.js.map +1 -0
- package/lib/module/nativeActions.js +307 -0
- package/lib/module/nativeActions.js.map +1 -0
- package/lib/module/nativeComponentSupport.js +61 -0
- package/lib/module/nativeComponentSupport.js.map +1 -0
- package/lib/module/networkPerformance.js +24 -0
- package/lib/module/networkPerformance.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/performance.js +104 -0
- package/lib/module/performance.js.map +1 -0
- package/lib/module/performanceMonitor.js +155 -0
- package/lib/module/performanceMonitor.js.map +1 -0
- package/lib/module/permissions.js +370 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/rendering.js +40 -0
- package/lib/module/rendering.js.map +1 -0
- package/lib/module/security.js +18 -0
- package/lib/module/security.js.map +1 -0
- package/lib/module/state.js +266 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/storage.js +400 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +390 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +115 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/module/validation.js +288 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/CameraPreview.d.ts +13 -0
- package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
- package/lib/typescript/src/NativeBlurView.d.ts +12 -0
- package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
- package/lib/typescript/src/accessibility.d.ts +20 -0
- package/lib/typescript/src/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +145 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/cpuOptimization.d.ts +63 -0
- package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
- package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
- package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
- package/lib/typescript/src/db/index.d.ts +23 -0
- package/lib/typescript/src/db/index.d.ts.map +1 -0
- package/lib/typescript/src/db/migrations.d.ts +31 -0
- package/lib/typescript/src/db/migrations.d.ts.map +1 -0
- package/lib/typescript/src/db/secureKey.d.ts +10 -0
- package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
- package/lib/typescript/src/db/types.d.ts +115 -0
- package/lib/typescript/src/db/types.d.ts.map +1 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
- package/lib/typescript/src/defaultIcons.d.ts +139 -0
- package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
- package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
- package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +95 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/iconConfig.d.ts +176 -0
- package/lib/typescript/src/iconConfig.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +217 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
- package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
- package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
- package/lib/typescript/src/library/index.d.ts +40 -0
- package/lib/typescript/src/library/index.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
- package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
- package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
- package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
- package/lib/typescript/src/library/palette.d.ts +104 -0
- package/lib/typescript/src/library/palette.d.ts.map +1 -0
- package/lib/typescript/src/library/responsive.d.ts +15 -0
- package/lib/typescript/src/library/responsive.d.ts.map +1 -0
- package/lib/typescript/src/locales.d.ts +103 -0
- package/lib/typescript/src/locales.d.ts.map +1 -0
- package/lib/typescript/src/localization.d.ts +1051 -0
- package/lib/typescript/src/localization.d.ts.map +1 -0
- package/lib/typescript/src/logger.d.ts +4 -0
- package/lib/typescript/src/logger.d.ts.map +1 -0
- package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
- package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
- package/lib/typescript/src/mediaHistory.d.ts +3 -0
- package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
- package/lib/typescript/src/mediaStorageService.d.ts +155 -0
- package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
- package/lib/typescript/src/memory.d.ts +21 -0
- package/lib/typescript/src/memory.d.ts.map +1 -0
- package/lib/typescript/src/nativeActions.d.ts +174 -0
- package/lib/typescript/src/nativeActions.d.ts.map +1 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
- package/lib/typescript/src/networkPerformance.d.ts +12 -0
- package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
- package/lib/typescript/src/performance.d.ts +32 -0
- package/lib/typescript/src/performance.d.ts.map +1 -0
- package/lib/typescript/src/performanceMonitor.d.ts +45 -0
- package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +132 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/rendering.d.ts +9 -0
- package/lib/typescript/src/rendering.d.ts.map +1 -0
- package/lib/typescript/src/security.d.ts +6 -0
- package/lib/typescript/src/security.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +102 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +117 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +15 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +802 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils.d.ts +50 -0
- package/lib/typescript/src/utils.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +52 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +204 -0
- package/react-native.config.js +17 -0
- package/src/AudioPlayerContext.tsx +220 -0
- package/src/CameraPreview.tsx +352 -0
- package/src/NativeAnimatedImageView.tsx +71 -0
- package/src/NativeAudioPlayerView.tsx +39 -0
- package/src/NativeBlurView.tsx +40 -0
- package/src/NativeCustomCamera.ts +305 -0
- package/src/NativeCustomCamera.web.ts +192 -0
- package/src/NativeCustomVideoView.ts +30 -0
- package/src/NativeDocumentPreviewView.tsx +196 -0
- package/src/NativeMediaThumbnailView.tsx +167 -0
- package/src/NativeSvgIcon.tsx +46 -0
- package/src/ResponsivePreviewContainer.tsx +227 -0
- package/src/accessibility.ts +80 -0
- package/src/config.ts +406 -0
- package/src/cpuOptimization.ts +365 -0
- package/src/db/DatabaseManager.ts +212 -0
- package/src/db/MediaMapper.ts +377 -0
- package/src/db/MediaRepository.ts +313 -0
- package/src/db/index.ts +73 -0
- package/src/db/migrations.ts +222 -0
- package/src/db/react-native-sqlcipher.d.ts +76 -0
- package/src/db/secureKey.ts +100 -0
- package/src/db/types.ts +123 -0
- package/src/db/useMediaDatabase.ts +191 -0
- package/src/defaultIcons.tsx +2390 -0
- package/src/deviceCapabilities.ts +438 -0
- package/src/errors.ts +350 -0
- package/src/iconConfig.tsx +962 -0
- package/src/icons.tsx +788 -0
- package/src/index.tsx +763 -0
- package/src/jest-globals.d.ts +9 -0
- package/src/library/MediaLibrary.tsx +141 -0
- package/src/library/MediaLibraryScreen.tsx +3003 -0
- package/src/library/components/AudioPreview.tsx +73 -0
- package/src/library/components/DocumentPreview.tsx +361 -0
- package/src/library/components/ExpandableFab.tsx +342 -0
- package/src/library/components/FileTiles.tsx +252 -0
- package/src/library/components/ImagePreview.tsx +72 -0
- package/src/library/components/MediaMetadataHeader.tsx +208 -0
- package/src/library/components/RecordAudioScreen.tsx +845 -0
- package/src/library/components/VideoPreview.tsx +478 -0
- package/src/library/components/ViewerCounter.tsx +71 -0
- package/src/library/compressionEstimate.ts +662 -0
- package/src/library/fileMetadata.ts +212 -0
- package/src/library/index.ts +66 -0
- package/src/library/libraryIconRegistry.tsx +86 -0
- package/src/library/libraryIcons.tsx +928 -0
- package/src/library/localizedFormat.ts +207 -0
- package/src/library/palette.ts +281 -0
- package/src/library/responsive.ts +49 -0
- package/src/locales.ts +381 -0
- package/src/localization.ts +1947 -0
- package/src/logger.ts +20 -0
- package/src/lowMemoryCache.ts +218 -0
- package/src/mediaHistory.ts +42 -0
- package/src/mediaStorageService.ts +546 -0
- package/src/memory.ts +102 -0
- package/src/nativeActions.ts +422 -0
- package/src/nativeComponentSupport.ts +60 -0
- package/src/networkPerformance.ts +30 -0
- package/src/performance.ts +137 -0
- package/src/performanceMonitor.ts +219 -0
- package/src/permissions.ts +403 -0
- package/src/rendering.ts +47 -0
- package/src/security.ts +19 -0
- package/src/state.ts +333 -0
- package/src/storage.ts +453 -0
- package/src/theme.tsx +467 -0
- package/src/types.ts +946 -0
- package/src/utils.ts +177 -0
- 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
|
+
}
|