Skip to content

Commit b0bff7f

Browse files
authored
Merge pull request #3 from michaelwolz/feat/add-save-to-file-option-for-image-capturing
Add save to file option for image capturing
2 parents 71ce30b + 22066f6 commit b0bff7f

33 files changed

+10476
-13615
lines changed

.github/FUNDING.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
github: michaelwolz

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
example-app

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ let package = Package(
2525
dependencies: ["CameraViewPlugin"],
2626
path: "ios/Tests/CameraViewPluginTests")
2727
]
28-
)
28+
)

README.md

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -272,16 +272,16 @@ Check if the camera view is currently running.
272272
### capture(...)
273273

274274
```typescript
275-
capture(options: { quality: number; }) => Promise<CaptureResponse>
275+
capture<T extends CaptureOptions>(options: T) => Promise<CaptureResponse<T>>
276276
```
277277

278278
Capture a photo using the current camera configuration.
279279

280-
| Param | Type | Description |
281-
| ------------- | --------------------------------- | ------------------------------- |
282-
| **`options`** | <code>{ quality: number; }</code> | - Capture configuration options |
280+
| Param | Type | Description |
281+
| ------------- | -------------- | ------------------------------- |
282+
| **`options`** | <code>T</code> | - Capture configuration options |
283283

284-
**Returns:** <code>Promise&lt;<a href="#captureresponse">CaptureResponse</a>&gt;</code>
284+
**Returns:** <code>Promise&lt;<a href="#captureresponse">CaptureResponse</a>&lt;T&gt;&gt;</code>
285285

286286
**Since:** 1.0.0
287287

@@ -291,7 +291,7 @@ Capture a photo using the current camera configuration.
291291
### captureSample(...)
292292

293293
```typescript
294-
captureSample(options: { quality: number; }) => Promise<CaptureResponse>
294+
captureSample<T extends CaptureOptions>(options: T) => Promise<CaptureResponse<T>>
295295
```
296296

297297
Captures a frame from the current camera preview without using the full camera capture pipeline.
@@ -304,11 +304,11 @@ On web this method does exactly the same as `capture()` as it only captures a fr
304304
because unfortunately [ImageCapture API](https://developer.mozilla.org/en-US/docs/Web/API/ImageCapture) is
305305
not yet well supported on the web.
306306

307-
| Param | Type | Description |
308-
| ------------- | --------------------------------- | ------------------------------- |
309-
| **`options`** | <code>{ quality: number; }</code> | - Capture configuration options |
307+
| Param | Type | Description |
308+
| ------------- | -------------- | ------------------------------- |
309+
| **`options`** | <code>T</code> | - Capture configuration options |
310310

311-
**Returns:** <code>Promise&lt;<a href="#captureresponse">CaptureResponse</a>&gt;</code>
311+
**Returns:** <code>Promise&lt;<a href="#captureresponse">CaptureResponse</a>&lt;T&gt;&gt;</code>
312312

313313
**Since:** 1.0.0
314314

@@ -517,13 +517,14 @@ Response for checking if the camera view is running.
517517
| **`isRunning`** | <code>boolean</code> | Indicates if the camera view is currently active and running |
518518

519519

520-
#### CaptureResponse
520+
#### CaptureOptions
521521

522-
Response for capturing a photo.
522+
Configuration options for capturing photos and samples.
523523

524-
| Prop | Type | Description |
525-
| ----------- | ------------------- | ----------------------------------------------- |
526-
| **`photo`** | <code>string</code> | The base64 encoded string of the captured photo |
524+
| Prop | Type | Description | Default | Since |
525+
| ---------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
526+
| **`quality`** | <code>number</code> | The JPEG quality of the captured photo/sample on a scale of 0-100 | | 1.1.0 |
527+
| **`saveToFile`** | <code>boolean</code> | If true, saves to a temporary file and returns the web path instead of base64. The web path can be used to set the src attribute of an image for efficient loading and rendering. This reduces the data that needs to be transferred over the bridge, which can improve performance especially for high-resolution images. | <code>false</code> | 1.1.0 |
527528

528529

529530
#### GetAvailableDevicesResponse
@@ -637,6 +638,15 @@ Maps to AVCaptureDevice DeviceTypes in iOS.
637638
<code>'wideAngle' | 'ultraWide' | 'telephoto' | 'dual' | 'dualWide' | 'triple' | 'trueDepth'</code>
638639

639640

641+
#### CaptureResponse
642+
643+
Response for capturing a photo
644+
This will contain either a base64 encoded string or a web path to the captured photo,
645+
depending on the `saveToFile` option in the <a href="#captureoptions">CaptureOptions</a>.
646+
647+
<code>T['saveToFile'] extends true ? { /** The web path to the captured photo that can be used to set the src attribute of an image for efficient loading and rendering (when saveToFile is true) */ webPath: string; } : { /** The base64 encoded string of the captured photo (when saveToFile is false or undefined) */ photo: string; }</code>
648+
649+
640650
#### FlashMode
641651

642652
Flash mode options for the camera.

android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt

Lines changed: 112 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.Context.CAMERA_SERVICE
55
import android.graphics.Bitmap
66
import android.hardware.camera2.CameraCharacteristics
77
import android.hardware.camera2.CameraManager
8+
import android.net.Uri
89
import android.util.Base64
910
import android.util.Log
1011
import android.view.Surface
@@ -25,6 +26,8 @@ import androidx.camera.view.LifecycleCameraController
2526
import androidx.camera.view.PreviewView
2627
import androidx.core.content.ContextCompat
2728
import androidx.lifecycle.LifecycleOwner
29+
import com.getcapacitor.FileUtils
30+
import com.getcapacitor.JSObject
2831
import com.getcapacitor.Plugin
2932
import com.google.mlkit.vision.barcode.BarcodeScanner
3033
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
@@ -35,6 +38,8 @@ import com.michaelwolz.capacitorcameraview.model.CameraDevice
3538
import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
3639
import com.michaelwolz.capacitorcameraview.model.ZoomFactors
3740
import java.io.ByteArrayOutputStream
41+
import java.io.File
42+
import java.io.FileOutputStream
3843
import java.util.concurrent.ExecutorService
3944
import java.util.concurrent.Executors
4045

@@ -118,7 +123,11 @@ class CameraView(plugin: Plugin) {
118123
}
119124

120125
/** Capture a photo with the current camera configuration */
121-
fun capturePhoto(quality: Int, callback: (String?, Exception?) -> Unit) {
126+
fun capturePhoto(
127+
quality: Int,
128+
saveToFile: Boolean = false,
129+
callback: (JSObject?, Exception?) -> Unit
130+
) {
122131
val startTime = System.currentTimeMillis()
123132
val controller =
124133
this.cameraController
@@ -145,23 +154,58 @@ class CameraView(plugin: Plugin) {
145154
)
146155

147156
try {
148-
controller.takePicture(
149-
cameraExecutor,
150-
object : ImageCapture.OnImageCapturedCallback() {
151-
override fun onCaptureSuccess(image: ImageProxy) {
152-
Log.d(
153-
TAG,
154-
"Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
155-
)
156-
handleCaptureSuccess(image, quality, imageRotationDegrees, callback)
157+
if (saveToFile) {
158+
// Direct file capture - much more efficient!
159+
val tempFile =
160+
File.createTempFile("camera_capture_photo", ".jpg", context.cacheDir)
161+
val outputFileOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
162+
163+
controller.takePicture(
164+
outputFileOptions,
165+
cameraExecutor,
166+
object : ImageCapture.OnImageSavedCallback {
167+
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
168+
val processingTime = System.currentTimeMillis() - startTime
169+
Log.d(TAG, "Image saved directly to file in ${processingTime}ms")
170+
171+
val result = JSObject().apply {
172+
val capacitorFilePath = FileUtils.getPortablePath(
173+
context,
174+
pluginDelegate.bridge.localUrl,
175+
Uri.fromFile(tempFile)
176+
)
177+
178+
put("webPath", capacitorFilePath)
179+
}
180+
callback(result, null)
181+
}
182+
183+
override fun onError(exception: ImageCaptureException) {
184+
Log.e(TAG, "Error saving image to file", exception)
185+
callback(null, exception)
186+
}
157187
}
158-
159-
override fun onError(exception: ImageCaptureException) {
160-
Log.e(TAG, "Error capturing image", exception)
161-
callback(null, exception)
188+
)
189+
} else {
190+
// Base64 capture using ImageProxy
191+
controller.takePicture(
192+
cameraExecutor,
193+
object : ImageCapture.OnImageCapturedCallback() {
194+
override fun onCaptureSuccess(image: ImageProxy) {
195+
Log.d(
196+
TAG,
197+
"Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
198+
)
199+
handleCaptureSuccess(image, quality, imageRotationDegrees, callback)
200+
}
201+
202+
override fun onError(exception: ImageCaptureException) {
203+
Log.e(TAG, "Error capturing image", exception)
204+
callback(null, exception)
205+
}
162206
}
163-
}
164-
)
207+
)
208+
}
165209
} catch (e: Exception) {
166210
Log.e(TAG, "Error setting up image capture", e)
167211
callback(null, e)
@@ -170,20 +214,22 @@ class CameraView(plugin: Plugin) {
170214
}
171215

172216
/**
173-
* Handles the successful capture of an image, converting it to a Base64 string
217+
* Handles the successful capture of an image for base64 conversion
174218
*/
175219
fun handleCaptureSuccess(
176220
image: ImageProxy,
177221
quality: Int,
178222
rotationDegrees: Int,
179-
callback: (String?, Exception?) -> Unit
223+
callback: (JSObject?, Exception?) -> Unit
180224
) {
181225
val startTime = System.currentTimeMillis()
182226
try {
183-
// Turn the image into a Base64 encoded string and apply rotation if necessary
184227
val base64String = imageProxyToBase64(image, quality, rotationDegrees)
228+
val result = JSObject().apply {
229+
put("photo", base64String)
230+
}
185231
Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
186-
callback(base64String, null)
232+
callback(result, null)
187233
} catch (e: Exception) {
188234
Log.e(TAG, "Error processing captured image", e)
189235
callback(null, e)
@@ -196,7 +242,11 @@ class CameraView(plugin: Plugin) {
196242
* Capture a frame directly from the preview without using the full photo pipeline which is
197243
* faster but has lower quality.
198244
*/
199-
fun captureSampleFromPreview(quality: Int, callback: (String?, Exception?) -> Unit) {
245+
fun captureSampleFromPreview(
246+
quality: Int,
247+
saveToFile: Boolean = false,
248+
callback: (JSObject?, Exception?) -> Unit
249+
) {
200250
val previewView =
201251
this.previewView
202252
?: run {
@@ -205,7 +255,6 @@ class CameraView(plugin: Plugin) {
205255
}
206256

207257
mainHandler.post {
208-
val outputStream = ByteArrayOutputStream()
209258
try {
210259
val bitmap =
211260
previewView.bitmap
@@ -214,27 +263,48 @@ class CameraView(plugin: Plugin) {
214263
return@post
215264
}
216265

217-
// Convert bitmap to Base64
218-
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
219-
val byteArray = outputStream.toByteArray()
220-
val base64String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
266+
val result = JSObject()
267+
268+
if (saveToFile) {
269+
val tempFile =
270+
File.createTempFile("camera_capture_sample", ".jpg", context.cacheDir)
271+
272+
FileOutputStream(tempFile).use { outputStream ->
273+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
274+
}
275+
276+
val capacitorFilePath = FileUtils.getPortablePath(
277+
context,
278+
pluginDelegate.bridge.localUrl,
279+
Uri.fromFile(tempFile)
280+
)
281+
282+
result.put("webPath", capacitorFilePath)
283+
} else {
284+
// Convert bitmap to Base64
285+
val outputStream = ByteArrayOutputStream()
286+
outputStream.use { stream ->
287+
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream)
288+
val byteArray = stream.toByteArray()
289+
val base64String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
290+
result.put("photo", base64String)
291+
}
292+
}
221293

222-
callback(base64String, null)
294+
callback(result, null)
223295
} catch (e: Exception) {
224296
Log.e(TAG, "Error capturing preview frame", e)
225297
callback(null, e)
226-
} finally {
227-
outputStream.close()
228298
}
229299
}
230300
}
231301

232302
/** Flip between front and back cameras */
233303
fun flipCamera(callback: (Exception?) -> Unit) {
234304
currentCameraSelector = when (currentCameraSelector) {
235-
CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
236-
else -> CameraSelector.DEFAULT_FRONT_CAMERA
237-
}
305+
CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA
306+
else -> CameraSelector.DEFAULT_FRONT_CAMERA
307+
}
238308

239309
val controller =
240310
this.cameraController
@@ -420,21 +490,21 @@ class CameraView(plugin: Plugin) {
420490
setupPreviewView(context)
421491

422492
currentCameraSelector = if (config.position == "front") {
423-
CameraSelector.DEFAULT_FRONT_CAMERA
424-
} else {
425-
CameraSelector.DEFAULT_BACK_CAMERA
426-
}
493+
CameraSelector.DEFAULT_FRONT_CAMERA
494+
} else {
495+
CameraSelector.DEFAULT_BACK_CAMERA
496+
}
427497

428498
if (config.deviceId != null) {
429499
// Prefer specific device id over position
430500
currentCameraSelector = CameraSelector.Builder()
431-
.addCameraFilter { cameraInfos ->
432-
cameraInfos.filter { info ->
433-
val cameraId = Camera2CameraInfo.from(info).cameraId
434-
cameraId == config.deviceId
435-
}
501+
.addCameraFilter { cameraInfos ->
502+
cameraInfos.filter { info ->
503+
val cameraId = Camera2CameraInfo.from(info).cameraId
504+
cameraId == config.deviceId
436505
}
437-
.build()
506+
}
507+
.build()
438508
}
439509

440510
// Initialize camera controller

android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,18 @@ class CameraViewPlugin : Plugin() {
7676
fun capture(call: PluginCall) {
7777
val timeStart = System.currentTimeMillis();
7878
val quality = call.getInt("quality") ?: 90
79+
val saveToFile = call.getBoolean("saveToFile") ?: false
7980

8081
if (quality !in 0..100) {
8182
call.reject("Quality must be between 0 and 100")
8283
return
8384
}
8485

85-
implementation.capturePhoto(quality) { photo, error ->
86+
implementation.capturePhoto(quality, saveToFile) { result, error ->
8687
when {
8788
error != null -> call.reject("Failed to capture image: ${error.message}", error)
88-
photo == null -> call.reject("No image data")
89-
else -> call.resolve(JSObject().apply { put("photo", photo) })
89+
result == null -> call.reject("No image data")
90+
else -> call.resolve(result)
9091
}
9192
Log.d(TAG, "capture took ${System.currentTimeMillis() - timeStart}ms")
9293
}
@@ -96,17 +97,18 @@ class CameraViewPlugin : Plugin() {
9697
fun captureSample(call: PluginCall) {
9798
val timeStart = System.currentTimeMillis();
9899
val quality = call.getInt("quality") ?: 90
100+
val saveToFile = call.getBoolean("saveToFile") ?: false
99101

100102
if (quality !in 0..100) {
101103
call.reject("Quality must be between 0 and 100")
102104
return
103105
}
104106

105-
implementation.captureSampleFromPreview(quality) { photo, error ->
107+
implementation.captureSampleFromPreview(quality, saveToFile) { result, error ->
106108
when {
107109
error != null -> call.reject("Failed to capture frame: ${error.message}", error)
108-
photo == null -> call.reject("No frame data")
109-
else -> call.resolve(JSObject().apply { put("photo", photo) })
110+
result == null -> call.reject("No frame data")
111+
else -> call.resolve(result)
110112
}
111113
Log.d(TAG, "captureSample took ${System.currentTimeMillis() - timeStart}ms")
112114
}

0 commit comments

Comments
 (0)