Skip to content

Commit b837d4b

Browse files
committed
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)
1 parent 45e9c81 commit b837d4b

21 files changed

+389
-15
lines changed

docs/docs/guides/TAKING_PHOTOS.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,40 @@ const photo = await camera.current.takePhoto({
8181

8282
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.
8383

84+
### Thumbnail Generation
85+
86+
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.
87+
88+
To generate a thumbnail, provide the [`thumbnailSize`](/docs/api/interfaces/TakePhotoOptions#thumbnailsize) and [`onThumbnailReady`](/docs/api/interfaces/TakePhotoOptions#onthumbnailready) options:
89+
90+
```tsx
91+
const photo = await camera.current.takePhoto({
92+
thumbnailSize: { width: 200, height: 200 },
93+
onThumbnailReady: (thumbnail) => {
94+
// Thumbnail is ready! Display it immediately
95+
setThumbnailUri(`file://${thumbnail.path}`)
96+
}
97+
})
98+
99+
// Full photo is now ready
100+
setPhotoUri(`file://${photo.path}`)
101+
```
102+
103+
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:
104+
- Display a preview to the user immediately
105+
- Show a loading state with the thumbnail while uploading the full image
106+
- Reduce memory usage by rendering thumbnails in lists instead of full photos
107+
108+
**Platform implementations:**
109+
- **iOS**: Uses the embedded thumbnail from the camera capture if available for maximum performance
110+
- **Android**: Uses memory-efficient downsampling with hardware-accelerated decoding (never loads the full image into memory)
111+
112+
Both implementations are optimized for their respective platforms and generate thumbnails asynchronously without blocking photo capture.
113+
114+
:::tip
115+
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.
116+
:::
117+
84118
### Photo Quality Balance
85119

86120
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.

example/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2098,8 +2098,8 @@ SPEC CHECKSUMS:
20982098
RNVectorIcons: 182892e7d1a2f27b52d3c627eca5d2665a22ee28
20992099
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
21002100
VisionCamera: 4146fa2612c154f893a42a9b1feedf868faa6b23
2101-
Yoga: aa3df615739504eebb91925fc9c58b4922ea9a08
2101+
Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6
21022102

21032103
PODFILE CHECKSUM: 2ad84241179871ca890f7c65c855d117862f1a68
21042104

2105-
COCOAPODS: 1.15.2
2105+
COCOAPODS: 1.16.2

example/src/CameraPage.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { GestureResponderEvent } from 'react-native'
44
import { StyleSheet, Text, View } from 'react-native'
55
import type { PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'
66
import { PinchGestureHandler, TapGestureHandler } from 'react-native-gesture-handler'
7-
import type { CameraProps, CameraRuntimeError, PhotoFile, VideoFile } from 'react-native-vision-camera'
7+
import type { CameraProps, CameraRuntimeError, PhotoFile, ThumbnailFile, VideoFile } from 'react-native-vision-camera'
88
import {
99
runAtTargetFps,
1010
useCameraDevice,
@@ -55,6 +55,7 @@ export function CameraPage({ navigation }: Props): React.ReactElement {
5555
const [enableHdr, setEnableHdr] = useState(false)
5656
const [flash, setFlash] = useState<'off' | 'on'>('off')
5757
const [enableNightMode, setEnableNightMode] = useState(false)
58+
const [thumbnail, setThumbnail] = useState<ThumbnailFile | null>(null)
5859

5960
// camera device settings
6061
const [preferredDevice] = usePreferredCameraDevice()
@@ -112,12 +113,15 @@ export function CameraPage({ navigation }: Props): React.ReactElement {
112113
const onMediaCaptured = useCallback(
113114
(media: PhotoFile | VideoFile, type: 'photo' | 'video') => {
114115
console.log(`Media captured! ${JSON.stringify(media)}`)
116+
console.log(`Thumbnail: ${JSON.stringify(thumbnail)}`)
117+
console.log(new Date())
115118
navigation.navigate('MediaPage', {
116119
path: media.path,
117120
type: type,
121+
thumbnail: thumbnail,
118122
})
119123
},
120-
[navigation],
124+
[navigation, thumbnail],
121125
)
122126
const onFlipCameraPressed = useCallback(() => {
123127
setCameraPosition((p) => (p === 'back' ? 'front' : 'back'))
@@ -178,6 +182,15 @@ export function CameraPage({ navigation }: Props): React.ReactElement {
178182
location.requestPermission()
179183
}, [location])
180184

185+
const onThumbnailReady = useCallback(
186+
(t: ThumbnailFile) => {
187+
console.log(`=============thumbnail Ready=============\n${t.width}x${t.height}`)
188+
console.log(new Date())
189+
setThumbnail(t)
190+
},
191+
[setThumbnail],
192+
)
193+
181194
const frameProcessor = useFrameProcessor((frame) => {
182195
'worklet'
183196

@@ -248,6 +261,7 @@ export function CameraPage({ navigation }: Props): React.ReactElement {
248261
flash={supportsFlash ? flash : 'off'}
249262
enabled={isCameraInitialized && isActive}
250263
setIsPressingButton={setIsPressingButton}
264+
onThumbnailReady={onThumbnailReady}
251265
/>
252266

253267
<StatusBarBlurBackground />

example/src/MediaPage.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const isVideoOnLoadEvent = (event: OnLoadData | OnLoadImage): event is OnLoadDat
3333

3434
type Props = NativeStackScreenProps<Routes, 'MediaPage'>
3535
export function MediaPage({ navigation, route }: Props): React.ReactElement {
36-
const { path, type } = route.params
36+
const { path, type, thumbnail } = route.params
3737
const [hasMediaLoaded, setHasMediaLoaded] = useState(false)
3838
const isForeground = useIsForeground()
3939
const isScreenFocused = useIsFocused()
@@ -85,7 +85,10 @@ export function MediaPage({ navigation, route }: Props): React.ReactElement {
8585
return (
8686
<View style={[styles.container, screenStyle]}>
8787
{type === 'photo' && (
88-
<Image source={source} style={StyleSheet.absoluteFill} resizeMode="cover" onLoadEnd={onMediaLoadEnd} onLoad={onMediaLoad} />
88+
<>
89+
<Image source={source} style={StyleSheet.absoluteFill} resizeMode="cover" onLoadEnd={onMediaLoadEnd} onLoad={onMediaLoad} />
90+
{thumbnail !== null && <Image source={{ uri: `file://${thumbnail.path}` }} style={styles.thumbnail} resizeMode="contain" />}
91+
</>
8992
)}
9093
{type === 'video' && (
9194
<Video
@@ -152,4 +155,14 @@ const styles = StyleSheet.create({
152155
},
153156
textShadowRadius: 1,
154157
},
158+
thumbnail: {
159+
position: 'absolute',
160+
right: SAFE_AREA_PADDING.paddingLeft,
161+
bottom: SAFE_AREA_PADDING.paddingBottom,
162+
width: 75,
163+
height: 120,
164+
borderRadius: 8,
165+
borderWidth: 2,
166+
borderColor: 'white',
167+
},
155168
})

example/src/Routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import type { ThumbnailFile } from 'react-native-vision-camera'
2+
13
export type Routes = {
24
PermissionsPage: undefined
35
CameraPage: undefined
46
CodeScannerPage: undefined
57
MediaPage: {
68
path: string
79
type: 'video' | 'photo'
10+
thumbnail: ThumbnailFile | null
811
}
912
Devices: undefined
1013
}

example/src/views/CaptureButton.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import Reanimated, {
1515
useSharedValue,
1616
withRepeat,
1717
} from 'react-native-reanimated'
18-
import type { Camera, PhotoFile, VideoFile } from 'react-native-vision-camera'
18+
import type { Camera, PhotoFile, ThumbnailFile, VideoFile } from 'react-native-vision-camera'
1919
import { CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH } from './../Constants'
2020

2121
const START_RECORDING_DELAY = 200
@@ -24,7 +24,7 @@ const BORDER_WIDTH = CAPTURE_BUTTON_SIZE * 0.1
2424
interface Props extends ViewProps {
2525
camera: React.RefObject<Camera>
2626
onMediaCaptured: (media: PhotoFile | VideoFile, type: 'photo' | 'video') => void
27-
27+
onThumbnailReady: (thumbnail: ThumbnailFile) => void
2828
minZoom: number
2929
maxZoom: number
3030
cameraZoom: Reanimated.SharedValue<number>
@@ -46,6 +46,7 @@ const _CaptureButton: React.FC<Props> = ({
4646
enabled,
4747
setIsPressingButton,
4848
style,
49+
onThumbnailReady,
4950
...props
5051
}): React.ReactElement => {
5152
const pressDownDate = useRef<Date | undefined>(undefined)
@@ -59,15 +60,21 @@ const _CaptureButton: React.FC<Props> = ({
5960
if (camera.current == null) throw new Error('Camera ref is null!')
6061

6162
console.log('Taking photo...')
63+
console.log(new Date());
6264
const photo = await camera.current.takePhoto({
6365
flash: flash,
6466
enableShutterSound: false,
67+
thumbnailSize: {
68+
width: 300,
69+
height: 300,
70+
},
71+
onThumbnailReady,
6572
})
6673
onMediaCaptured(photo, 'photo')
6774
} catch (e) {
6875
console.error('Failed to take photo!', e)
6976
}
70-
}, [camera, flash, onMediaCaptured])
77+
}, [camera, flash, onMediaCaptured, onThumbnailReady])
7178

7279
const onStoppedRecording = useCallback(() => {
7380
isRecording.current = false

package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Photo.kt

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
package com.mrousavy.camera.core
22

3+
import android.graphics.Bitmap
4+
import android.graphics.BitmapFactory
5+
import android.graphics.Matrix
36
import android.media.AudioManager
7+
import android.util.Log
8+
import androidx.exifinterface.media.ExifInterface
49
import com.mrousavy.camera.core.extensions.takePicture
510
import com.mrousavy.camera.core.types.Flash
611
import com.mrousavy.camera.core.types.Orientation
712
import com.mrousavy.camera.core.types.TakePhotoOptions
813
import com.mrousavy.camera.core.utils.FileUtils
14+
import com.mrousavy.camera.core.utils.runOnUiThread
15+
import java.io.File
16+
import java.io.FileOutputStream
917

1018
suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
1119
val camera = camera ?: throw CameraNotReadyError()
@@ -33,6 +41,17 @@ suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
3341
CameraQueues.cameraExecutor
3442
)
3543

44+
// Generate thumbnail if requested (async, non-blocking)
45+
if (options.thumbnailSize != null) {
46+
CameraQueues.cameraExecutor.execute {
47+
try {
48+
generateThumbnailSync(photoFile.uri.path, options.thumbnailSize)
49+
} catch (e: Exception) {
50+
Log.e("CameraSession", "Failed to generate thumbnail", e)
51+
}
52+
}
53+
}
54+
3655
// Parse resulting photo (EXIF data)
3756
val size = FileUtils.getImageSize(photoFile.uri.path)
3857
val rotation = photoOutput.targetRotation
@@ -41,5 +60,90 @@ suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
4160
return Photo(photoFile.uri.path, size.width, size.height, orientation, isMirrored)
4261
}
4362

63+
private fun CameraSession.generateThumbnailSync(photoPath: String, thumbnailSize: TakePhotoOptions.Size) {
64+
try {
65+
val photoFile = File(photoPath)
66+
if (!photoFile.exists()) {
67+
Log.w("CameraSession", "Photo file not found for thumbnail generation")
68+
return
69+
}
70+
71+
// Read EXIF orientation
72+
val exif = ExifInterface(photoFile)
73+
val orientation = exif.getAttributeInt(
74+
ExifInterface.TAG_ORIENTATION,
75+
ExifInterface.ORIENTATION_NORMAL
76+
)
77+
78+
// Decode image with inSampleSize for memory efficiency
79+
val options = BitmapFactory.Options().apply {
80+
inJustDecodeBounds = true
81+
}
82+
BitmapFactory.decodeFile(photoPath, options)
83+
84+
// Calculate inSampleSize
85+
val maxSize = maxOf(thumbnailSize.width, thumbnailSize.height)
86+
val imageSize = maxOf(options.outWidth, options.outHeight)
87+
var inSampleSize = 1
88+
while (imageSize / inSampleSize > maxSize) {
89+
inSampleSize *= 2
90+
}
91+
92+
// Decode bitmap with sample size
93+
options.inJustDecodeBounds = false
94+
options.inSampleSize = inSampleSize
95+
var bitmap = BitmapFactory.decodeFile(photoPath, options)
96+
?: run {
97+
Log.w("CameraSession", "Failed to decode photo for thumbnail")
98+
return
99+
}
100+
101+
// Apply EXIF orientation
102+
bitmap = when (orientation) {
103+
ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f)
104+
ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f)
105+
ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f)
106+
else -> bitmap
107+
}
108+
109+
// Scale to target size maintaining aspect ratio
110+
val scale = minOf(
111+
thumbnailSize.width.toFloat() / bitmap.width,
112+
thumbnailSize.height.toFloat() / bitmap.height
113+
)
114+
val scaledWidth = (bitmap.width * scale).toInt()
115+
val scaledHeight = (bitmap.height * scale).toInt()
116+
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true)
117+
if (scaledBitmap != bitmap) {
118+
bitmap.recycle()
119+
}
120+
121+
// Save thumbnail to temp file
122+
val thumbnailFile = File(photoFile.parent, "thumbnail_${photoFile.name}")
123+
FileOutputStream(thumbnailFile).use { out ->
124+
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
125+
}
126+
scaledBitmap.recycle()
127+
128+
// Invoke callback on main thread
129+
runOnUiThread {
130+
callback.onThumbnailReady(thumbnailFile.absolutePath, scaledWidth, scaledHeight)
131+
}
132+
133+
Log.i("CameraSession", "Thumbnail generated: ${thumbnailFile.absolutePath}")
134+
} catch (e: Exception) {
135+
Log.e("CameraSession", "Error generating thumbnail", e)
136+
}
137+
}
138+
139+
private fun rotateBitmap(source: Bitmap, degrees: Float): Bitmap {
140+
val matrix = Matrix().apply { postRotate(degrees) }
141+
val rotated = Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
142+
if (rotated != source) {
143+
source.recycle()
144+
}
145+
return rotated
146+
}
147+
44148
private val AudioManager.isSilent: Boolean
45149
get() = ringerMode != AudioManager.RINGER_MODE_NORMAL

package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,5 +221,6 @@ class CameraSession(internal val context: Context, internal val callback: Callba
221221
fun onOutputOrientationChanged(outputOrientation: Orientation)
222222
fun onPreviewOrientationChanged(previewOrientation: Orientation)
223223
fun onCodeScanned(codes: List<Barcode>, scannerFrame: CodeScannerFrame)
224+
fun onThumbnailReady(path: String, width: Int, height: Int)
224225
}
225226
}

package/android/src/main/java/com/mrousavy/camera/core/types/TakePhotoOptions.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,30 @@ import com.facebook.react.bridge.ReadableMap
55
import com.mrousavy.camera.core.utils.FileUtils
66
import com.mrousavy.camera.core.utils.OutputFile
77

8-
data class TakePhotoOptions(val file: OutputFile, val flash: Flash, val enableShutterSound: Boolean) {
8+
data class TakePhotoOptions(
9+
val file: OutputFile,
10+
val flash: Flash,
11+
val enableShutterSound: Boolean,
12+
val thumbnailSize: Size?
13+
) {
14+
data class Size(val width: Int, val height: Int)
915

1016
companion object {
1117
fun fromJS(context: Context, map: ReadableMap): TakePhotoOptions {
1218
val flash = if (map.hasKey("flash")) Flash.fromUnionValue(map.getString("flash")) else Flash.OFF
1319
val enableShutterSound = if (map.hasKey("enableShutterSound")) map.getBoolean("enableShutterSound") else false
1420
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir
1521

22+
// Parse thumbnailSize
23+
val thumbnailSize = if (map.hasKey("thumbnailSize")) {
24+
val sizeMap = map.getMap("thumbnailSize")
25+
if (sizeMap != null && sizeMap.hasKey("width") && sizeMap.hasKey("height")) {
26+
Size(sizeMap.getInt("width"), sizeMap.getInt("height"))
27+
} else null
28+
} else null
29+
1630
val outputFile = OutputFile(context, directory, ".jpg")
17-
return TakePhotoOptions(outputFile, flash, enableShutterSound)
31+
return TakePhotoOptions(outputFile, flash, enableShutterSound, thumbnailSize)
1832
}
1933
}
2034
}

package/android/src/main/java/com/mrousavy/camera/react/CameraView+Events.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,19 @@ fun CameraView.invokeOnCodeScanned(barcodes: List<Barcode>, scannerFrame: CodeSc
167167
this.sendEvent(event)
168168
}
169169

170+
fun CameraView.invokeOnThumbnailReady(path: String, width: Int, height: Int) {
171+
Log.i(CameraView.TAG, "invokeOnThumbnailReady($path)")
172+
173+
val surfaceId = UIManagerHelper.getSurfaceId(this)
174+
val data = Arguments.createMap()
175+
data.putString("path", path)
176+
data.putInt("width", width)
177+
data.putInt("height", height)
178+
179+
val event = CameraThumbnailReadyEvent(surfaceId, id, data)
180+
this.sendEvent(event)
181+
}
182+
170183
private fun CameraView.sendEvent(event: Event<*>) {
171184
val reactContext = context as ReactContext
172185
val dispatcher =

0 commit comments

Comments
 (0)