Skip to content
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

Closed
Closed
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
92 changes: 92 additions & 0 deletions android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt
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
Copy link
Collaborator

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 ?

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
Expand Up @@ -1828,6 +1828,10 @@ private int getGroupIndexForDefaultLocale(TrackGroupArray groups) {
return groupIndex;
}

public ExoPlayerView getExoPlayerView() {
return this.exoPlayerView;
}

public void setSelectedVideoTrack(String type, String value) {
videoTrackType = type;
videoTrackValue = value;
Expand Down
23 changes: 23 additions & 0 deletions android/src/main/java/com/brentvatne/react/VideoManagerModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

import androidx.annotation.NonNull;

import com.brentvatne.common.toolbox.CaptureUtil;
import com.brentvatne.exoplayer.ExoPlayerView;
import com.brentvatne.exoplayer.ReactExoplayerView;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
Expand Down Expand Up @@ -35,4 +38,24 @@ public void setPlayerPauseState(Boolean paused, int reactTag) {
}
});
}

@ReactMethod
public void capture(int reactTag, Promise promise) {
final ReactApplicationContext context = getReactApplicationContext();
final UIManagerModule uiManager = context.getNativeModule(UIManagerModule.class);
uiManager.prependUIBlock(manager -> {
View view = manager.resolveView(reactTag);
if (view instanceof ReactExoplayerView) {
try {
ReactExoplayerView videoView = (ReactExoplayerView) view;
ExoPlayerView exoPlayerView = videoView.getExoPlayerView();
CaptureUtil.capture(context, exoPlayerView.getVideoSurfaceView());
promise.resolve(null);
} catch (Exception e) {
promise.reject("CAPTURE_ERROR", e);
}
}
}
);
}
}
25 changes: 25 additions & 0 deletions docs/pages/component/methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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>`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to manage the generate file here ?
2 solutions:

  • allows to give a path / file path in parameters
  • give the generated file path in promise result

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']} />
Expand Down
69 changes: 69 additions & 0 deletions ios/Video/Features/RCTVideoCapture.swift
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
}
}
}
15 changes: 15 additions & 0 deletions ios/Video/RCTVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it increase memory usage significantly ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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?
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions ios/Video/RCTVideoManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,6 @@ @interface RCT_EXTERN_MODULE (RCTVideoManager, RCTViewManager)

RCT_EXTERN_METHOD(dismissFullscreenPlayer : (nonnull NSNumber*)reactTag)

RCT_EXTERN_METHOD(capture : (nonnull NSNumber*)reactTag resolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)

@end
12 changes: 12 additions & 0 deletions ios/Video/RCTVideoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ class RCTVideoManager: RCTViewManager {
}
}

@objc(capture:resolver:rejecter:)
func capture(_ reactTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
bridge.uiManager.prependUIBlock { _, viewRegistry in
let view = viewRegistry?[reactTag]
if !(view is RCTVideo) {
RCTLogError("Invalid view returned from registry, expecting RCTVideo, got: %@", String(describing: view))
} else if let view = view as? RCTVideo {
view.capture(resolve: resolve, reject: reject)
}
}
}

override class func requiresMainQueueSetup() -> Bool {
return true
}
Expand Down
10 changes: 10 additions & 0 deletions src/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface VideoRef {
restore: boolean,
) => void;
save: (options: object) => Promise<VideoSaveData>;
capture: () => Promise<void>;
}

const Video = forwardRef<VideoRef, ReactVideoProps>(
Expand Down Expand Up @@ -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');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throwing an exception is a bit violent ? console.warn should be enough ?

Copy link
Collaborator

Choose a reason for hiding this comment

The 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);
Expand Down Expand Up @@ -486,6 +494,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
presentFullscreenPlayer,
dismissFullscreenPlayer,
save,
capture,
pause,
resume,
restoreUserInterfaceForPictureInPictureStopCompleted,
Expand All @@ -495,6 +504,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
presentFullscreenPlayer,
dismissFullscreenPlayer,
save,
capture,
pause,
resume,
restoreUserInterfaceForPictureInPictureStopCompleted,
Expand Down
1 change: 1 addition & 0 deletions src/specs/VideoNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ export type VideoSaveData = {

export interface VideoManagerType {
save: (option: object, reactTag: number) => Promise<VideoSaveData>;
capture: (reactTag: number) => Promise<void>;
setPlayerPauseState: (paused: boolean, reactTag: number) => Promise<void>;
setLicenseResult: (
result: string,
Expand Down
Loading