Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/docs/guides/TAKING_PHOTOS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 16 additions & 2 deletions example/src/CameraPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ThumbnailFile | null>(null)

// camera device settings
const [preferredDevice] = usePreferredCameraDevice()
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -248,6 +261,7 @@ export function CameraPage({ navigation }: Props): React.ReactElement {
flash={supportsFlash ? flash : 'off'}
enabled={isCameraInitialized && isActive}
setIsPressingButton={setIsPressingButton}
onThumbnailReady={onThumbnailReady}
/>

<StatusBarBlurBackground />
Expand Down
17 changes: 15 additions & 2 deletions example/src/MediaPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const isVideoOnLoadEvent = (event: OnLoadData | OnLoadImage): event is OnLoadDat

type Props = NativeStackScreenProps<Routes, 'MediaPage'>
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()
Expand Down Expand Up @@ -85,7 +85,10 @@ export function MediaPage({ navigation, route }: Props): React.ReactElement {
return (
<View style={[styles.container, screenStyle]}>
{type === 'photo' && (
<Image source={source} style={StyleSheet.absoluteFill} resizeMode="cover" onLoadEnd={onMediaLoadEnd} onLoad={onMediaLoad} />
<>
<Image source={source} style={StyleSheet.absoluteFill} resizeMode="cover" onLoadEnd={onMediaLoadEnd} onLoad={onMediaLoad} />
{thumbnail !== null && <Image source={{ uri: `file://${thumbnail.path}` }} style={styles.thumbnail} resizeMode="contain" />}
</>
)}
{type === 'video' && (
<Video
Expand Down Expand Up @@ -152,4 +155,14 @@ const styles = StyleSheet.create({
},
textShadowRadius: 1,
},
thumbnail: {
position: 'absolute',
right: SAFE_AREA_PADDING.paddingLeft,
bottom: SAFE_AREA_PADDING.paddingBottom,
width: 75,
height: 120,
borderRadius: 8,
borderWidth: 2,
borderColor: 'white',
},
})
3 changes: 3 additions & 0 deletions example/src/Routes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { ThumbnailFile } from 'react-native-vision-camera'

export type Routes = {
PermissionsPage: undefined
CameraPage: undefined
CodeScannerPage: undefined
MediaPage: {
path: string
type: 'video' | 'photo'
thumbnail: ThumbnailFile | null
}
Devices: undefined
}
13 changes: 10 additions & 3 deletions example/src/views/CaptureButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import Reanimated, {
useSharedValue,
withRepeat,
} from 'react-native-reanimated'
import type { Camera, PhotoFile, VideoFile } from 'react-native-vision-camera'
import type { Camera, PhotoFile, ThumbnailFile, VideoFile } from 'react-native-vision-camera'
import { CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH } from './../Constants'

const START_RECORDING_DELAY = 200
Expand All @@ -24,7 +24,7 @@ const BORDER_WIDTH = CAPTURE_BUTTON_SIZE * 0.1
interface Props extends ViewProps {
camera: React.RefObject<Camera>
onMediaCaptured: (media: PhotoFile | VideoFile, type: 'photo' | 'video') => void

onThumbnailReady: (thumbnail: ThumbnailFile) => void
minZoom: number
maxZoom: number
cameraZoom: Reanimated.SharedValue<number>
Expand All @@ -46,6 +46,7 @@ const _CaptureButton: React.FC<Props> = ({
enabled,
setIsPressingButton,
style,
onThumbnailReady,
...props
}): React.ReactElement => {
const pressDownDate = useRef<Date | undefined>(undefined)
Expand All @@ -59,15 +60,21 @@ const _CaptureButton: React.FC<Props> = ({
if (camera.current == null) throw new Error('Camera ref is null!')

console.log('Taking photo...')
console.log(new Date());
const photo = await camera.current.takePhoto({
flash: flash,
enableShutterSound: false,
thumbnailSize: {
width: 300,
height: 300,
},
onThumbnailReady,
})
onMediaCaptured(photo, 'photo')
} catch (e) {
console.error('Failed to take photo!', e)
}
}, [camera, flash, onMediaCaptured])
}, [camera, flash, onMediaCaptured, onThumbnailReady])

const onStoppedRecording = useCallback(() => {
isRecording.current = false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package com.mrousavy.camera.core

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.AudioManager
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.mrousavy.camera.core.extensions.takePicture
import com.mrousavy.camera.core.types.Flash
import com.mrousavy.camera.core.types.Orientation
import com.mrousavy.camera.core.types.TakePhotoOptions
import com.mrousavy.camera.core.utils.FileUtils
import com.mrousavy.camera.core.utils.runOnUiThread
import java.io.File
import java.io.FileOutputStream

suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
val camera = camera ?: throw CameraNotReadyError()
Expand Down Expand Up @@ -33,6 +41,17 @@ suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
CameraQueues.cameraExecutor
)

// Generate thumbnail if requested (async, non-blocking)
if (options.thumbnailSize != null) {
CameraQueues.cameraExecutor.execute {
try {
generateThumbnailSync(photoFile.uri.path, options.thumbnailSize)
} catch (e: Exception) {
Log.e("CameraSession", "Failed to generate thumbnail", e)
}
}
}

// Parse resulting photo (EXIF data)
val size = FileUtils.getImageSize(photoFile.uri.path)
val rotation = photoOutput.targetRotation
Expand All @@ -41,5 +60,90 @@ suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo {
return Photo(photoFile.uri.path, size.width, size.height, orientation, isMirrored)
}

private fun CameraSession.generateThumbnailSync(photoPath: String, thumbnailSize: TakePhotoOptions.Size) {
try {
val photoFile = File(photoPath)
if (!photoFile.exists()) {
Log.w("CameraSession", "Photo file not found for thumbnail generation")
return
}

// Read EXIF orientation
val exif = ExifInterface(photoFile)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)

// Decode image with inSampleSize for memory efficiency
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(photoPath, options)

// Calculate inSampleSize
val maxSize = maxOf(thumbnailSize.width, thumbnailSize.height)
val imageSize = maxOf(options.outWidth, options.outHeight)
var inSampleSize = 1
while (imageSize / inSampleSize > maxSize) {
inSampleSize *= 2
}

// Decode bitmap with sample size
options.inJustDecodeBounds = false
options.inSampleSize = inSampleSize
var bitmap = BitmapFactory.decodeFile(photoPath, options)
?: run {
Log.w("CameraSession", "Failed to decode photo for thumbnail")
return
}

// Apply EXIF orientation
bitmap = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f)
ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f)
ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f)
else -> bitmap
}

// Scale to target size maintaining aspect ratio
val scale = minOf(
thumbnailSize.width.toFloat() / bitmap.width,
thumbnailSize.height.toFloat() / bitmap.height
)
val scaledWidth = (bitmap.width * scale).toInt()
val scaledHeight = (bitmap.height * scale).toInt()
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true)
if (scaledBitmap != bitmap) {
bitmap.recycle()
}

// Save thumbnail to temp file
val thumbnailFile = File(photoFile.parent, "thumbnail_${photoFile.name}")
FileOutputStream(thumbnailFile).use { out ->
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
scaledBitmap.recycle()

// Invoke callback on main thread
runOnUiThread {
callback.onThumbnailReady(thumbnailFile.absolutePath, scaledWidth, scaledHeight)
}

Log.i("CameraSession", "Thumbnail generated: ${thumbnailFile.absolutePath}")
} catch (e: Exception) {
Log.e("CameraSession", "Error generating thumbnail", e)
}
}

private fun rotateBitmap(source: Bitmap, degrees: Float): Bitmap {
val matrix = Matrix().apply { postRotate(degrees) }
val rotated = Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
if (rotated != source) {
source.recycle()
}
return rotated
}

private val AudioManager.isSilent: Boolean
get() = ringerMode != AudioManager.RINGER_MODE_NORMAL
Original file line number Diff line number Diff line change
Expand Up @@ -221,5 +221,6 @@ class CameraSession(internal val context: Context, internal val callback: Callba
fun onOutputOrientationChanged(outputOrientation: Orientation)
fun onPreviewOrientationChanged(previewOrientation: Orientation)
fun onCodeScanned(codes: List<Barcode>, scannerFrame: CodeScannerFrame)
fun onThumbnailReady(path: String, width: Int, height: Int)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,30 @@ import com.facebook.react.bridge.ReadableMap
import com.mrousavy.camera.core.utils.FileUtils
import com.mrousavy.camera.core.utils.OutputFile

data class TakePhotoOptions(val file: OutputFile, val flash: Flash, val enableShutterSound: Boolean) {
data class TakePhotoOptions(
val file: OutputFile,
val flash: Flash,
val enableShutterSound: Boolean,
val thumbnailSize: Size?
) {
data class Size(val width: Int, val height: Int)

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

// Parse thumbnailSize
val thumbnailSize = if (map.hasKey("thumbnailSize")) {
val sizeMap = map.getMap("thumbnailSize")
if (sizeMap != null && sizeMap.hasKey("width") && sizeMap.hasKey("height")) {
Size(sizeMap.getInt("width"), sizeMap.getInt("height"))
} else null
} else null

val outputFile = OutputFile(context, directory, ".jpg")
return TakePhotoOptions(outputFile, flash, enableShutterSound)
return TakePhotoOptions(outputFile, flash, enableShutterSound, thumbnailSize)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ fun CameraView.invokeOnCodeScanned(barcodes: List<Barcode>, scannerFrame: CodeSc
this.sendEvent(event)
}

fun CameraView.invokeOnThumbnailReady(path: String, width: Int, height: Int) {
Log.i(CameraView.TAG, "invokeOnThumbnailReady($path)")

val surfaceId = UIManagerHelper.getSurfaceId(this)
val data = Arguments.createMap()
data.putString("path", path)
data.putInt("width", width)
data.putInt("height", height)

val event = CameraThumbnailReadyEvent(surfaceId, id, data)
this.sendEvent(event)
}

private fun CameraView.sendEvent(event: Event<*>) {
val reactContext = context as ReactContext
val dispatcher =
Expand Down
Loading
Loading