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

fix: support video recording on iOS #308

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Starts the camera preview instance.
| lockAndroidOrientation | boolean | (optional) Locks device orientation when camera is showing, default false. (applicable to Android only) |
| enableOpacity | boolean | (optional) Make the camera preview see-through. Ideal for augmented reality uses. Default false (applicable to Android and web only)
| enableZoom | boolean | (optional) Set if you can pinch to zoom. Default false (applicable to the android and ios platforms only)
| mirrorVideo | boolean | (optional) Set if the video should be mirrored to match the preview. Defaults to false (applicable to the iOS platform only)

<!-- <strong>Options:</strong>
All options stated are optional and will default to values here
Expand Down Expand Up @@ -168,6 +169,13 @@ Ex: VueJS >> App.vue component
CameraPreview.stop();
```

### resume() ---- iOS only

<info>Resumes the camera preview without having to reinitialize. (in case it was interrupted)</info>
```javascript
CameraPreview.resume()
```

### flip()
<info>Switch between rear and front camera only for android and ios, web is not supported</info>
```javascript
Expand Down Expand Up @@ -284,6 +292,7 @@ CameraPreview.startRecordVideo(cameraPreviewOptions);

```javascript
const resultRecordVideo = await CameraPreview.stopRecordVideo();
const videoPath = resultRecordVideo.videoFilePath;
```

### setOpacity(options: CameraOpacityOptions): Promise<{}>; ---- ANDROID only
Expand Down
129 changes: 56 additions & 73 deletions ios/Plugin/CameraController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class CameraController: NSObject {
var frontCamera: AVCaptureDevice?
var frontCameraInput: AVCaptureDeviceInput?

var dataOutput: AVCaptureVideoDataOutput?
var videoOutput: AVCaptureMovieFileOutput?
var photoOutput: AVCapturePhotoOutput?

var rearCamera: AVCaptureDevice?
Expand All @@ -27,6 +27,7 @@ class CameraController: NSObject {

var flashMode = AVCaptureDevice.FlashMode.off
var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
var videoCaptureCompletionBlock: ((URL?, Error?) -> Void)?

var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?

Expand All @@ -42,6 +43,7 @@ extension CameraController {
func prepare(cameraPosition: String, disableAudio: Bool, completionHandler: @escaping (Error?) -> Void) {
func createCaptureSession() {
self.captureSession = AVCaptureSession()
self.captureSession?.beginConfiguration()
}

func configureCaptureDevices() throws {
Expand Down Expand Up @@ -110,25 +112,19 @@ extension CameraController {
self.photoOutput!.setPreparedPhotoSettingsArray([AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])], completionHandler: nil)
self.photoOutput?.isHighResolutionCaptureEnabled = self.highResolutionOutput
if captureSession.canAddOutput(self.photoOutput!) { captureSession.addOutput(self.photoOutput!) }
captureSession.startRunning()
}

func configureDataOutput() throws {
func configureVideoOutput() throws {
guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }

self.dataOutput = AVCaptureVideoDataOutput()
self.dataOutput?.videoSettings = [
(kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
]
self.dataOutput?.alwaysDiscardsLateVideoFrames = true
if captureSession.canAddOutput(self.dataOutput!) {
captureSession.addOutput(self.dataOutput!)

self.videoOutput = AVCaptureMovieFileOutput()

if captureSession.canAddOutput(self.videoOutput!) {
captureSession.addOutput(self.videoOutput!)
} else {
throw CameraControllerError.invalidOperation
}

captureSession.commitConfiguration()

let queue = DispatchQueue(label: "DataOutput", attributes: [])
self.dataOutput?.setSampleBufferDelegate(self, queue: queue)

}

DispatchQueue(label: "prepare").async {
Expand All @@ -137,8 +133,9 @@ extension CameraController {
try configureCaptureDevices()
try configureDeviceInputs()
try configurePhotoOutput()
try configureDataOutput()
// try configureVideoOutput()
try configureVideoOutput()
self.captureSession?.commitConfiguration()
self.captureSession?.startRunning()
} catch {
DispatchQueue.main.async {
completionHandler(error)
Expand All @@ -153,6 +150,21 @@ extension CameraController {
}
}

func resume(completionHandler: @escaping (Error?) -> Void) {
guard let captureSession = self.captureSession else {
completionHandler(CameraControllerError.captureSessionIsMissing)
return
}
DispatchQueue(label: "prepare").async {
if(!captureSession.isRunning){
captureSession.startRunning()
}
DispatchQueue.main.async {
completionHandler(nil)
}
}
}

func displayPreview(on view: UIView) throws {
guard let captureSession = self.captureSession, captureSession.isRunning else { throw CameraControllerError.captureSessionIsMissing }

Expand Down Expand Up @@ -204,7 +216,8 @@ extension CameraController {
}

previewLayer?.connection?.videoOrientation = videoOrientation
dataOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
//Orientation is not supported for video connections
//videoOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
photoOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
}

Expand Down Expand Up @@ -396,9 +409,9 @@ extension CameraController {

}

func captureVideo(completion: @escaping (URL?, Error?) -> Void) {
func captureVideo(mirror: Bool = false, completion: @escaping (Error?) -> Void) {
guard let captureSession = self.captureSession, captureSession.isRunning else {
completion(nil, CameraControllerError.captureSessionIsMissing)
completion(CameraControllerError.captureSessionIsMissing)
return
}
let path = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
Expand All @@ -409,16 +422,28 @@ extension CameraController {

let fileUrl = path.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileUrl)
/*videoOutput!.startRecording(to: fileUrl, recordingDelegate: self)
self.videoRecordCompletionBlock = completion*/

if mirror {
if let connection = videoOutput?.connection(with: AVMediaType.video), connection.isVideoOrientationSupported {
connection.isVideoMirrored = true
} else {
completion(CameraControllerError.invalidOperation)
return
}
}

videoOutput?.movieFragmentInterval = CMTime.invalid
videoOutput?.startRecording(to: fileUrl, recordingDelegate: self)
completion(nil)
}

func stopRecording(completion: @escaping (Error?) -> Void) {
func stopRecording(completion: @escaping (URL?, Error?) -> Void) {
guard let captureSession = self.captureSession, captureSession.isRunning else {
completion(CameraControllerError.captureSessionIsMissing)
completion(nil, CameraControllerError.captureSessionIsMissing)
return
}
// self.videoOutput?.stopRecording()
self.videoCaptureCompletionBlock = completion
self.videoOutput?.stopRecording()
}
}

Expand Down Expand Up @@ -495,48 +520,6 @@ extension CameraController: AVCapturePhotoCaptureDelegate {
}
}

extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let completion = sampleBufferCaptureCompletionBlock else { return }

guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
completion(nil, CameraControllerError.unknown)
return
}

CVPixelBufferLockBaseAddress(imageBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly) }

let baseAddress = CVPixelBufferGetBaseAddress(imageBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer)
let width = CVPixelBufferGetWidth(imageBuffer)
let height = CVPixelBufferGetHeight(imageBuffer)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue |
CGImageAlphaInfo.premultipliedFirst.rawValue

let context = CGContext(
data: baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: bitmapInfo
)

guard let cgImage = context?.makeImage() else {
completion(nil, CameraControllerError.unknown)
return
}

let image = UIImage(cgImage: cgImage)
completion(image.fixedOrientation(), nil)

sampleBufferCaptureCompletionBlock = nil
}
}

enum CameraControllerError: Swift.Error {
case captureSessionAlreadyRunning
case captureSessionIsMissing
Expand Down Expand Up @@ -639,10 +622,10 @@ extension UIImage {

extension CameraController: AVCaptureFileOutputRecordingDelegate {
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
/*if error == nil {
self.videoRecordCompletionBlock?(outputFileURL, nil)
} else {
self.videoRecordCompletionBlock?(nil, error)
}*/
if error == nil {
self.videoCaptureCompletionBlock?(outputFileURL, nil)
} else {
self.videoCaptureCompletionBlock?(nil, error)
}
}
}
1 change: 1 addition & 0 deletions ios/Plugin/Plugin.m
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CAP_PLUGIN(CameraPreview, "CameraPreview",
CAP_PLUGIN_METHOD(start, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(stop, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(resume, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(capture, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(captureSample, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(flip, CAPPluginReturnPromise);
Expand Down
46 changes: 32 additions & 14 deletions ios/Plugin/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class CameraPreview: CAPPlugin {
var enableZoom: Bool?
var highResolutionOutput: Bool = false
var disableAudio: Bool = false
var mirrorVideo: Bool = false

@objc func rotated() {
let height = self.paddingBottom != nil ? self.height! - self.paddingBottom!: self.height!
Expand Down Expand Up @@ -67,6 +68,7 @@ public class CameraPreview: CAPPlugin {
self.storeToFile = call.getBool("storeToFile") ?? false
self.enableZoom = call.getBool("enableZoom") ?? false
self.disableAudio = call.getBool("disableAudio") ?? false
self.mirrorVideo = call.getBool("mirrorVideo") ?? false

AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) in
guard granted else {
Expand Down Expand Up @@ -217,6 +219,17 @@ public class CameraPreview: CAPPlugin {
}
}
}

@objc func resume(_ call: CAPPluginCall) {
self.cameraController.resume() { error in
if let error = error {
print(error)
call.reject(error.localizedDescription)
return
}
call.resolve()
}
}

@objc func getSupportedFlashModes(_ call: CAPPluginCall) {
do {
Expand Down Expand Up @@ -261,30 +274,35 @@ public class CameraPreview: CAPPlugin {
DispatchQueue.main.async {

let quality: Int? = call.getInt("quality", 85)
self.mirrorVideo = call.getBool("mirrorVideo") ?? self.mirrorVideo

self.cameraController.captureVideo { (image, error) in

guard let image = image else {
print(error ?? "Image capture error")
guard let error = error else {
call.reject("Image capture error")
return
}
call.reject(error.localizedDescription)
self.cameraController.captureVideo(mirror: self.mirrorVideo) { (error) in
guard let error = error else {
call.resolve()
return
}

// self.videoUrl = image

call.resolve(["value": image.absoluteString])
call.reject(error.localizedDescription)
return
}
}
}

@objc func stopRecordVideo(_ call: CAPPluginCall) {

self.cameraController.stopRecording { (_) in
self.cameraController.stopRecording { (video, error) in
guard let video = video else {
print(error ?? "Video capture error")
guard let error = error else {
call.reject("Video capture error")
return
}
call.reject(error.localizedDescription)
return
}

// self.videoUrl = image

call.resolve(["videoFilePath": video.absoluteString])
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface CameraPreviewOptions {
enableOpacity?: boolean;
/** Defaults to false - Android only. Set if camera preview will support pinch to zoom. */
enableZoom?: boolean;
/** Defaults to false - iOS only. Set if video recording will look the same as the preview. */
mirrorVideo?: boolean;
}
export interface CameraPreviewPictureOptions {
/** The picture height, optional, default 0 (Device default) */
Expand Down Expand Up @@ -62,7 +64,8 @@ export interface CameraPreviewPlugin {
start(options: CameraPreviewOptions): Promise<{}>;
startRecordVideo(options: CameraPreviewOptions): Promise<{}>;
stop(): Promise<{}>;
stopRecordVideo(): Promise<{}>;
resume(): Promise<{} | never>;
stopRecordVideo(): Promise<{ videoFilePath: string } | never>;
capture(options: CameraPreviewPictureOptions): Promise<{ value: string }>;
captureSample(options: CameraSampleOptions): Promise<{ value: string }>;
getSupportedFlashModes(): Promise<{
Expand Down
8 changes: 6 additions & 2 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,15 @@ export class CameraPreviewWeb extends WebPlugin implements CameraPreviewPlugin {
});
}

async startRecordVideo(): Promise<{}> {
async resume(): Promise<never> {
throw this.unimplemented('Not implemented on web.');
}

async stopRecordVideo(): Promise<{}> {
async startRecordVideo(): Promise<never> {
throw this.unimplemented('Not implemented on web.');
}

async stopRecordVideo(): Promise<never> {
throw this.unimplemented('Not implemented on web.');
}

Expand Down