-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: implement capture method #3577
Changes from all commits
4207d5e
9e04b2e
46f26cc
2e16f95
d876811
6aa09ad
a168dbf
b35d550
604fc6f
4f7f989
f20b6d0
99754dc
9e30893
a7feb7e
ef38b31
c76fbdf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
package com.brentvatne.common.toolbox | ||
|
||
import android.content.ContentValues | ||
import android.graphics.Bitmap | ||
import android.net.Uri | ||
import android.os.Build | ||
import android.os.Handler | ||
import android.os.Looper | ||
import android.provider.MediaStore | ||
import android.view.PixelCopy | ||
import android.view.SurfaceView | ||
import android.view.TextureView | ||
import android.view.View | ||
import androidx.media3.common.util.Util | ||
import com.facebook.react.bridge.ReactApplicationContext | ||
import java.io.IOException | ||
import java.io.OutputStream | ||
|
||
object CaptureUtil { | ||
@JvmStatic | ||
fun capture(reactContext: ReactApplicationContext, view: View) { | ||
val bitmap: Bitmap? | ||
if (view is TextureView) { | ||
bitmap = view.bitmap | ||
try { | ||
saveImageToStream(bitmap, reactContext) | ||
} catch (e: IOException) { | ||
throw e | ||
} | ||
} else if (Util.SDK_INT >= 24 && view is SurfaceView) { | ||
bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888) | ||
PixelCopy.request(view, bitmap, { copyResult: Int -> | ||
if (copyResult == PixelCopy.SUCCESS) { | ||
try { | ||
saveImageToStream(bitmap, reactContext) | ||
} catch (e: IOException) { | ||
e.printStackTrace() | ||
} | ||
} | ||
}, Handler(Looper.getMainLooper())) | ||
} else { | ||
// https://stackoverflow.com/questions/27817577/android-take-screenshot-of-surface-view-shows-black-screen/27824250#27824250 | ||
throw RuntimeException("SurfaceView couldn't support capture under SDK 24") | ||
} | ||
} | ||
|
||
private fun saveImageToStream(bitmap: Bitmap?, reactContext: ReactApplicationContext) { | ||
val isUnderQ = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q | ||
val values = contentValues() | ||
val resolver = reactContext.contentResolver | ||
var stream: OutputStream? = null | ||
var uri: Uri? = null | ||
try { | ||
if (!isUnderQ) { | ||
values.put(MediaStore.MediaColumns.IS_PENDING, 1) | ||
} | ||
val imageCollection = MediaStore.Images.Media.getContentUri(if (isUnderQ) MediaStore.VOLUME_EXTERNAL else MediaStore.VOLUME_EXTERNAL_PRIMARY) | ||
uri = resolver.insert(imageCollection, values) | ||
if (uri == null) { | ||
throw IOException("Failed to create new MediaStore record.") | ||
} | ||
stream = resolver.openOutputStream(uri) | ||
if (stream == null) { | ||
throw IOException("Failed to get output stream.") | ||
} | ||
if (!bitmap!!.compress(Bitmap.CompressFormat.PNG, 100, stream)) { | ||
throw IOException("Failed to save bitmap.") | ||
} | ||
if (!isUnderQ) { | ||
values.clear() | ||
values.put(MediaStore.MediaColumns.IS_PENDING, 0) | ||
resolver.update(uri, values, null, null) | ||
} | ||
} catch (e: IOException) { | ||
if (uri != null) { | ||
// Don't leave an orphan entry in the MediaStore | ||
resolver.delete(uri, null, null) | ||
} | ||
throw e | ||
} finally { | ||
stream?.close() | ||
} | ||
} | ||
|
||
private fun contentValues(): ContentValues { | ||
val values = ContentValues() | ||
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png") | ||
values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000) | ||
values.put(MediaStore.MediaColumns.DATE_TAKEN, System.currentTimeMillis()) | ||
return values | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -64,6 +64,31 @@ Future: | |
- Will support more formats in the future through options | ||
- Will support custom directory and file name through options | ||
|
||
### `capture` | ||
|
||
<PlatformsList types={['iOS', 'Android']} /> | ||
|
||
`capture(): Promise<void>` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to manage the generate file here ?
Because, here, I cannot know where the file is generated |
||
|
||
Save current frame as a PNG file. Returns promise. | ||
|
||
Notes: | ||
|
||
- this method can not be used with encrypted video contents (with DRM) | ||
- On Android API level 23 and below capture couldn't support Android `SurfaceView`. if you use SurfaceView, method will be throw an error (`useSurfaceView`, `useSecureView` and `drm` props are internally use SurfaceView). | ||
- On Android API level 28 and below you will need to request the [WRITE_EXTERNAL_STORAGE permission](https://developer.android.com/reference/android/Manifest.permission#WRITE_EXTERNAL_STORAGE) manually using either the built-in react-native `PermissionsAndroid` APIs or a related module such as `react-native-permissions`. also you have to define below code within your `AndroidManifest.xml` file | ||
|
||
```xml | ||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> | ||
``` | ||
|
||
- In order to save photos on iOS, user must accept permission to save photos. To enable this permission, you need to add the following to your `Info.plist` file. | ||
|
||
```xml | ||
<key>NSPhotoLibraryAddUsageDescription</key> | ||
<string>YOUR TEXT</string> | ||
``` | ||
|
||
### `restoreUserInterfaceForPictureInPictureStopCompleted` | ||
|
||
<PlatformsList types={['iOS']} /> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import AVFoundation | ||
import Photos | ||
|
||
// MARK: - CaptureError | ||
|
||
enum CaptureError: Error { | ||
case permissionDenied | ||
case emptyPlayerItem | ||
case emptyPlayerItemOutput | ||
case emptyBuffer | ||
case emptyImg | ||
case emtpyPngData | ||
case emptyTmpDir | ||
} | ||
|
||
// MARK: - RCTVideoCapture | ||
|
||
enum RCTVideoCapture { | ||
static func capture( | ||
resolve: @escaping RCTPromiseResolveBlock, | ||
reject: @escaping RCTPromiseRejectBlock, | ||
playerItem: AVPlayerItem?, | ||
playerOutput: AVPlayerItemVideoOutput? | ||
) { | ||
DispatchQueue.global(qos: .userInitiated).async { | ||
do { | ||
try RCTVideoCapture.checkPhotoAddPermission() | ||
let playerItem = try playerItem ?? { throw CaptureError.emptyPlayerItem }() | ||
let playerOutput = try playerOutput ?? { throw CaptureError.emptyPlayerItemOutput }() | ||
|
||
let currentTime = playerItem.currentTime() | ||
let buffer = try playerOutput.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil) ?? { throw CaptureError.emptyBuffer }() | ||
|
||
let ciImage = CIImage(cvPixelBuffer: buffer) | ||
let ctx = CIContext(options: nil) | ||
let width = CVPixelBufferGetWidth(buffer) | ||
let height = CVPixelBufferGetHeight(buffer) | ||
let rect = CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)) | ||
let videoImage = try ctx.createCGImage(ciImage, from: rect) ?? { throw CaptureError.emptyImg }() | ||
|
||
let image = UIImage(cgImage: videoImage) | ||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) | ||
let data = try image.pngData() ?? { throw CaptureError.emtpyPngData }() | ||
|
||
let tmpDir = try RCTTempFilePath("png", nil) ?? { throw CaptureError.emptyTmpDir }() | ||
|
||
try data.write(to: URL(fileURLWithPath: tmpDir)) | ||
resolve(nil) | ||
} catch { | ||
reject("RCTVideoCapture Error", "Capture failed: \(error)", nil) | ||
} | ||
} | ||
} | ||
|
||
private static func checkPhotoAddPermission() throws { | ||
var status: PHAuthorizationStatus? | ||
if #available(iOS 14, *) { | ||
status = PHPhotoLibrary.authorizationStatus(for: .addOnly) | ||
} else { | ||
status = PHPhotoLibrary.authorizationStatus() | ||
} | ||
switch status { | ||
case .restricted, .denied: | ||
throw CaptureError.permissionDenied | ||
default: | ||
return | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -67,6 +67,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH | |
private var _presentingViewController: UIViewController? | ||
private var _pictureInPictureEnabled = false | ||
private var _startPosition: Float64 = -1 | ||
private var _playerOutput: AVPlayerItemVideoOutput? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it increase memory usage significantly ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As I know, it depends on resolution or bitrate |
||
|
||
/* IMA Ads */ | ||
private var _adTagUrl: String? | ||
|
@@ -396,6 +397,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH | |
} | ||
self._player?.pause() | ||
self._playerItem = playerItem | ||
|
||
// for capture | ||
let settings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] | ||
self._playerOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: settings) | ||
|
||
self._playerObserver.playerItem = self._playerItem | ||
self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration) | ||
self.setPlaybackRange(playerItem, withVideoStart: self._source?.cropStart, withVideoEnd: self._source?.cropEnd) | ||
|
@@ -1168,6 +1174,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH | |
onReadyForDisplay?([ | ||
"target": reactTag, | ||
]) | ||
// for capture | ||
if let playerOutput = self._playerOutput, self._drm == nil { | ||
self._playerItem?.add(playerOutput) | ||
} | ||
} | ||
|
||
// When timeMetadata is read the event onTimedMetadata is triggered | ||
|
@@ -1421,6 +1431,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH | |
} | ||
} | ||
|
||
@objc | ||
func capture(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { | ||
RCTVideoCapture.capture(resolve: resolve, reject: reject, playerItem: _playerItem, playerOutput: _playerOutput) | ||
} | ||
|
||
@objc | ||
func handleAVPlayerAccess(notification: NSNotification!) { | ||
guard let accessLog = (notification.object as? AVPlayerItem)?.accessLog() else { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -55,6 +55,7 @@ export interface VideoRef { | |
restore: boolean, | ||
) => void; | ||
save: (options: object) => Promise<VideoSaveData>; | ||
capture: () => Promise<void>; | ||
} | ||
|
||
const Video = forwardRef<VideoRef, ReactVideoProps>( | ||
|
@@ -265,6 +266,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>( | |
return VideoManager.setPlayerPauseState(false, getReactTag(nativeRef)); | ||
}, []); | ||
|
||
const capture = useCallback(() => { | ||
if (drm) { | ||
throw Error('"capture" method can not be called with "drm" prop'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. throwing an exception is a bit violent ? console.warn should be enough ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And BTW, you may defined an empty object in drm... I think you can just remove the test and handle it correctly in native code ! |
||
} | ||
return VideoManager.capture?.(getReactTag(nativeRef)); | ||
}, [drm]); | ||
|
||
const restoreUserInterfaceForPictureInPictureStopCompleted = useCallback( | ||
(restored: boolean) => { | ||
setRestoreUserInterfaceForPIPStopCompletionHandler(restored); | ||
|
@@ -486,6 +494,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>( | |
presentFullscreenPlayer, | ||
dismissFullscreenPlayer, | ||
save, | ||
capture, | ||
pause, | ||
resume, | ||
restoreUserInterfaceForPictureInPictureStopCompleted, | ||
|
@@ -495,6 +504,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>( | |
presentFullscreenPlayer, | ||
dismissFullscreenPlayer, | ||
save, | ||
capture, | ||
pause, | ||
resume, | ||
restoreUserInterfaceForPictureInPictureStopCompleted, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This import should not be in common folder : import androidx.media3.common.util.Util
Any way to remove it ?