From b837d4b53a9737e45e9a11f9e83441f1928dc13b Mon Sep 17 00:00:00 2001 From: Mikael Amborn Date: Sun, 31 Aug 2025 19:51:22 +0200 Subject: [PATCH] feat: Add onThumbnailReady callback for photo capture Add support for generating thumbnails during photo capture with an asynchronous callback that fires before the full photo is saved. This enables instant preview functionality and better UX in camera applications. **Changes:** - Add `thumbnailSize` and `onThumbnailReady` options to `TakePhotoOptions` - Add `ThumbnailFile` type for thumbnail metadata - iOS: Extract embedded thumbnail from AVCapturePhoto using ImageIO for maximum performance - Android: Implement memory-efficient downsampling with hardware-accelerated decoding - Add event bridge `onThumbnailReady` for both platforms - Update documentation with usage examples and platform implementation details - Add thumbnail display in example app **Platform implementations:** - iOS: Uses embedded thumbnail from camera capture if available - Android: Uses BitmapFactory.Options.inSampleSize for efficient downsampling without loading full image into memory Both implementations are asynchronous and non-blocking. Tested on iPhone 14, iOS 18.6.2 Android implementation compiles successfully (needs device testing) --- docs/docs/guides/TAKING_PHOTOS.mdx | 34 ++++++ example/ios/Podfile.lock | 4 +- example/src/CameraPage.tsx | 18 ++- example/src/MediaPage.tsx | 17 ++- example/src/Routes.ts | 3 + example/src/views/CaptureButton.tsx | 13 ++- .../camera/core/CameraSession+Photo.kt | 104 ++++++++++++++++++ .../com/mrousavy/camera/core/CameraSession.kt | 1 + .../camera/core/types/TakePhotoOptions.kt | 18 ++- .../camera/react/CameraView+Events.kt | 13 +++ .../com/mrousavy/camera/react/CameraView.kt | 4 + .../java/com/mrousavy/camera/react/Events.kt | 9 ++ package/ios/Core/CameraSession+Photo.swift | 18 +++ package/ios/Core/PhotoCaptureDelegate.swift | 46 ++++++++ package/ios/Core/Types/TakePhotoOptions.swift | 8 ++ package/ios/React/CameraView+TakePhoto.swift | 8 +- package/ios/React/CameraView.swift | 1 + package/ios/React/CameraViewManager.m | 1 + package/src/Camera.tsx | 27 ++++- package/src/NativeCameraView.ts | 6 + package/src/types/PhotoFile.ts | 51 +++++++++ 21 files changed, 389 insertions(+), 15 deletions(-) diff --git a/docs/docs/guides/TAKING_PHOTOS.mdx b/docs/docs/guides/TAKING_PHOTOS.mdx index a800503cd8..4d4a9dde9a 100644 --- a/docs/docs/guides/TAKING_PHOTOS.mdx +++ b/docs/docs/guides/TAKING_PHOTOS.mdx @@ -81,6 +81,40 @@ const photo = await camera.current.takePhoto({ Note that flash is only available on camera devices where [`hasFlash`](/docs/api/interfaces/CameraDevice#hasflash) is `true`; for example most front cameras don't have a flash. +### Thumbnail Generation + +For a better user experience, you can generate a low-resolution thumbnail that loads while the full photo is being processed and saved. This is especially useful for displaying a preview in your UI without waiting for the full-resolution image. + +To generate a thumbnail, provide the [`thumbnailSize`](/docs/api/interfaces/TakePhotoOptions#thumbnailsize) and [`onThumbnailReady`](/docs/api/interfaces/TakePhotoOptions#onthumbnailready) options: + +```tsx +const photo = await camera.current.takePhoto({ + thumbnailSize: { width: 200, height: 200 }, + onThumbnailReady: (thumbnail) => { + // Thumbnail is ready! Display it immediately + setThumbnailUri(`file://${thumbnail.path}`) + } +}) + +// Full photo is now ready +setPhotoUri(`file://${photo.path}`) +``` + +The `onThumbnailReady` callback is invoked as soon as the thumbnail is generated, which typically happens before the full photo is saved. This allows you to: +- Display a preview to the user immediately +- Show a loading state with the thumbnail while uploading the full image +- Reduce memory usage by rendering thumbnails in lists instead of full photos + +**Platform implementations:** +- **iOS**: Uses the embedded thumbnail from the camera capture if available for maximum performance +- **Android**: Uses memory-efficient downsampling with hardware-accelerated decoding (never loads the full image into memory) + +Both implementations are optimized for their respective platforms and generate thumbnails asynchronously without blocking photo capture. + +:::tip +The thumbnail is stored in a temporary directory just like the main photo. Remember to clean up temporary files when you're done with them. +::: + ### Photo Quality Balance The photo capture pipeline can be configured to prioritize speed over quality, quality over speed or balance both quality and speed using the [`photoQualityBalance`](/docs/api/interfaces/CameraProps#photoQualityBalance) prop. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 863dcfc1a7..abe182bbd2 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2098,8 +2098,8 @@ SPEC CHECKSUMS: RNVectorIcons: 182892e7d1a2f27b52d3c627eca5d2665a22ee28 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d VisionCamera: 4146fa2612c154f893a42a9b1feedf868faa6b23 - Yoga: aa3df615739504eebb91925fc9c58b4922ea9a08 + Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6 PODFILE CHECKSUM: 2ad84241179871ca890f7c65c855d117862f1a68 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/src/CameraPage.tsx b/example/src/CameraPage.tsx index af65e71462..49ad15c5b9 100644 --- a/example/src/CameraPage.tsx +++ b/example/src/CameraPage.tsx @@ -4,7 +4,7 @@ import type { GestureResponderEvent } from 'react-native' import { StyleSheet, Text, View } from 'react-native' import type { PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler' import { PinchGestureHandler, TapGestureHandler } from 'react-native-gesture-handler' -import type { CameraProps, CameraRuntimeError, PhotoFile, VideoFile } from 'react-native-vision-camera' +import type { CameraProps, CameraRuntimeError, PhotoFile, ThumbnailFile, VideoFile } from 'react-native-vision-camera' import { runAtTargetFps, useCameraDevice, @@ -55,6 +55,7 @@ export function CameraPage({ navigation }: Props): React.ReactElement { const [enableHdr, setEnableHdr] = useState(false) const [flash, setFlash] = useState<'off' | 'on'>('off') const [enableNightMode, setEnableNightMode] = useState(false) + const [thumbnail, setThumbnail] = useState(null) // camera device settings const [preferredDevice] = usePreferredCameraDevice() @@ -112,12 +113,15 @@ export function CameraPage({ navigation }: Props): React.ReactElement { const onMediaCaptured = useCallback( (media: PhotoFile | VideoFile, type: 'photo' | 'video') => { console.log(`Media captured! ${JSON.stringify(media)}`) + console.log(`Thumbnail: ${JSON.stringify(thumbnail)}`) + console.log(new Date()) navigation.navigate('MediaPage', { path: media.path, type: type, + thumbnail: thumbnail, }) }, - [navigation], + [navigation, thumbnail], ) const onFlipCameraPressed = useCallback(() => { setCameraPosition((p) => (p === 'back' ? 'front' : 'back')) @@ -178,6 +182,15 @@ export function CameraPage({ navigation }: Props): React.ReactElement { location.requestPermission() }, [location]) + const onThumbnailReady = useCallback( + (t: ThumbnailFile) => { + console.log(`=============thumbnail Ready=============\n${t.width}x${t.height}`) + console.log(new Date()) + setThumbnail(t) + }, + [setThumbnail], + ) + const frameProcessor = useFrameProcessor((frame) => { 'worklet' @@ -248,6 +261,7 @@ export function CameraPage({ navigation }: Props): React.ReactElement { flash={supportsFlash ? flash : 'off'} enabled={isCameraInitialized && isActive} setIsPressingButton={setIsPressingButton} + onThumbnailReady={onThumbnailReady} /> diff --git a/example/src/MediaPage.tsx b/example/src/MediaPage.tsx index 8eff60dd05..281a350f15 100644 --- a/example/src/MediaPage.tsx +++ b/example/src/MediaPage.tsx @@ -33,7 +33,7 @@ const isVideoOnLoadEvent = (event: OnLoadData | OnLoadImage): event is OnLoadDat type Props = NativeStackScreenProps export function MediaPage({ navigation, route }: Props): React.ReactElement { - const { path, type } = route.params + const { path, type, thumbnail } = route.params const [hasMediaLoaded, setHasMediaLoaded] = useState(false) const isForeground = useIsForeground() const isScreenFocused = useIsFocused() @@ -85,7 +85,10 @@ export function MediaPage({ navigation, route }: Props): React.ReactElement { return ( {type === 'photo' && ( - + <> + + {thumbnail !== null && } + )} {type === 'video' && (