Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
magesteve committed Aug 16, 2020
1 parent 4cab5ab commit 059152a
Show file tree
Hide file tree
Showing 4 changed files with 340 additions and 15 deletions.
8 changes: 3 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@ import PackageDescription

let package = Package(
name: "MSCaptureView",
platforms: [
.macOS(.v10_14),
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "MSCaptureView",
targets: ["MSCaptureView"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "MSCaptureView",
dependencies: []),
Expand Down
55 changes: 53 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,54 @@
# MSCaptureView
# MSCaptureView - NSView subclass to capture video/audio from a Mac internal camera & microphone.

A description of this package.
AVFoundation provides all the tools a programmer needs to capture video & audio generated by the Camera and Microphone of a Mac or IOS program. AVCaptureView, part of AVKit, provides a View for displaying the video input and capturing the output to a file. The developer still has to write considerable code to connect the two parts. On the iOS side, Apple provides the often used UIImagePickerController class to combine all the components required to capture video and audio. Surprisingly, there is no such View or Controller on the Mac platform.

Now there is.

MSCaptureView can be used to give simple movie capture ability to any Mac program. Only a few calls are needed to set up the capture. All the functionality is encapsulated with the View, with simple calls to turn the capture on or off.

## Installation

Since MSCaptureView is a Swift Package, the IDE or Make file of a project must reference MSCaptureView's repository:

https://github.com/magesteve/MSCaptureView

To clone MSCaptureView, the following Terminal command should be used:

% git clone https://github.com/magesteve/MSCaptureView.git

## Platform Specific Usage

MSCaptureView is a NSVIew class; thus it must be added to a programs NSVIewController or NSWindowController. Use Interface Builder to add a generic NSView to the Interface of the App (XIB or Storyboard files). Then change the Views type to MSCaptureView. Create an IBOutlet reference between the view and its controller.

When the program starts, invoke the requestCaptureAuthorization() function so that app asks the user for permission to use the Camera & Microphone. When the view appears, call the showPreview() function to start the video preview. At some point, use the use(url) function to set the output location for the movie to be created. The startCapture() function starts recording to the movie file, while the stopCapture() function halts it. Swift closures can be used to inform the app of state changes by the view (ex: Authorization succeeded, start Movie recording, stop Movie recording).

## Documentation

All public classes, protocols, properties & functions have inline documentation (DOxygen style). Further explanation of the Framework, refer to the MSCaptureView-Demo repository or any example projects.

https://github.com/magesteve/MSCaptureView-Demo

## Requirements

MSCaptureView requires specific changes to the MacOS program it is running within.

1. Add NSCameraUsageDescription & NSMicrophoneUsageDescription to Info.plist. (ex: "This app requires the Camera for video capture.")
2. Add AVFoundation Framework.
3. Add Sandbox access to Hardware Camera and Audio Input,
4. Add Sandbox access File Access for the Movie Folder set to Read/Write.

## Versions

1.0.0 Initial Release

## Future

I would like to add an optional HUD so that start and stop can be done directly within the preview.

### Steve Sheets, [email protected]

Originally from Silicon Valley, Steve has been embedded in the software industry for over 35 years. As an expert in user interface and design, he started developer desktop applications for companies like Apple and AOL, moved into mobile development, and is now working in the virtual reality and Augment Reality space. He has taught Objective-C & Swift development classes (MoDev, Learning Tree), as well as given talk on variety of developer topics (DC Mac Dev group, Capital One Swift Conference). He is an avid game player, swordsman and an occasional game designer.

## License

MSCaptureView is available under the MIT license. The intent of the project is to be always Open Source and freely available. Please keep me informed of any interesting uses!
281 changes: 279 additions & 2 deletions Sources/MSCaptureView/MSCaptureView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,280 @@
struct MSCaptureView {
var text = "Hello, World!"
//
// MSCaptureView.swift
// MSCaptureView
//
// Created by Steve Sheets on 8/13/20.
// Copyright © 2020 Steve Sheets. All rights reserved.
//

import Cocoa
import AVFoundation

// MARK: MSCaptureView Class

/// NSView subclass to provied video capture and preview from Macintosh Cameria/Microphone
public class MSCaptureView: NSView, AVCaptureFileOutputRecordingDelegate {

// MARK: Static Properties

/// Version information (major, minor, patch)
public static let version = (1, 0, 0)

// MARK: Type Alias

/// Closure type that is passed the current Capture View and returns nothing
public typealias StatusChangedEventClosure = (MSCaptureView) -> Void

/// Closure type that is passed nothing View and returns nothing
public typealias AuthorizedEventClosure = () -> Void

// MARK: Private Property

private var captureSession: AVCaptureSession?
private var capturePreviewLayer: AVCaptureVideoPreviewLayer?
private var captureDeviceInputCamera: AVCaptureDeviceInput?
private var captureDeviceInputMicrophone: AVCaptureDeviceInput?
private var captureMovieFileOutput: AVCaptureMovieFileOutput?
private var captureURL: URL?

// MARK: Public Properties

/// Closure evoked when the recording starts
public var captureRecordingStartedEvent: StatusChangedEventClosure?

/// Closure evoked when the recording stops (either by call or by error).
public var captureRecordingStoppedEvent: StatusChangedEventClosure?

// MARK: Public Read-Only Properties

/// Calculated property showing if the app has been authorized to use camera and microphone
public var hasCaptureAuthorization: Bool {
get {
let videoStatus = AVCaptureDevice.authorizationStatus(for: .video)
let microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio)

if case .authorized = videoStatus, case .authorized = microphoneStatus {
return true
}

return false
}
}

/// Calculated property showing if the app has preview turned on.
public var hasPreview: Bool {
get {
guard let session = captureSession else { return false }

return session.isRunning
}
}

/// Calculated property showing if the app has output url set.
public var hasURL: Bool {
get {
return captureURL != nil
}
}

/// Calculated property showing if the app is currently capturing
public var isCapturing: Bool {
get {
guard let output = captureMovieFileOutput else { return false }

return output.isRecording
}
}

// MARK: Private Static Functions

private static func requestAudioAuthorization(success: @escaping AuthorizedEventClosure) {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
success()
return

case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) {granted in
guard granted else { return }

success()
}

case .denied,
.restricted:
return

@unknown default:
return
}
}

// MARK: Public Static Functions

/// Check the audio & video authorization. If not determined, makes requrest for them (displaying UI). If users has authorized both video and audio, then closure is invoked.
/// - Parameter success: AuthorizedEventClosure to invoke if both video and audio is authorized.
public static func requestCaptureAuthorization(success: @escaping AuthorizedEventClosure) {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
requestAudioAuthorization(success: success)
return

case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
guard granted else { return }

MSCaptureView.requestAudioAuthorization(success: success)
}

case .denied,
.restricted:
return

@unknown default:
return
}
}

// MARK: Public Functions

/// Set URL to output captured video to.
///
/// If file already exists at this location, the file will be deleted as start of capture, not at setting the Capture URL.
/// - Parameter url: URL to output.
public func use(url: URL) {
captureURL = url
}

/// Clears the capture URL (the URL used to save the captured file to).
public func clearURL() {
captureURL = nil
}

/// Attempts to turn on Preview layer on view. Return success if this occurs.
///
/// The actual video may take a second or two before it appears. This call creates the majority of the internal settings.
/// If the app is not authorized, or if the Preview is already turned on, this call does nothing.
/// - Returns: BOOL if video is turned on, returns True.
@discardableResult public func showPreview() -> Bool {
guard hasCaptureAuthorization, captureSession==nil else { return true }

self.wantsLayer = true

let session = AVCaptureSession()
session.sessionPreset = .high

let layer = AVCaptureVideoPreviewLayer(session: session)

guard let camera = AVCaptureDevice.default(for: AVMediaType.video), let cameraInput = try? AVCaptureDeviceInput(device: camera) else { return false }

if session.canAddInput(cameraInput) {
session.addInput(cameraInput)
}

guard let microphone = AVCaptureDevice.default(for: AVMediaType.audio), let microphoneInput = try? AVCaptureDeviceInput(device: microphone) else { return false }

if session.canAddInput(microphoneInput) {
session.addInput(microphoneInput)
}

let movieOutput = AVCaptureMovieFileOutput()
if session.canAddOutput(movieOutput) {
session.addOutput(movieOutput)
}

layer.frame = self.bounds
layer.videoGravity = .resizeAspectFill
self.layer = layer
self.layerContentsPlacement = .scaleAxesIndependently

captureSession = session
capturePreviewLayer = layer
captureDeviceInputCamera = cameraInput
captureDeviceInputMicrophone = microphoneInput
captureMovieFileOutput = movieOutput

session.startRunning()

return true
}

/// Turns off the Preview layer on view.
///
/// If the app is not authorized, or if the Preview is not turned on, this call does nothing.
public func hidePreview() {
guard hasCaptureAuthorization, let session = captureSession, let layer = capturePreviewLayer, let cameraInput = captureDeviceInputCamera, let microphoneInput = captureDeviceInputMicrophone, let movieOutput = captureMovieFileOutput else { return }

session.stopRunning()

self.layer = CALayer()
self.wantsLayer = true

layer.session = nil

if session.canAddInput(cameraInput) {
session.removeInput(cameraInput)
}

if session.canAddInput(microphoneInput) {
session.removeInput(microphoneInput)
}

if session.canAddOutput(movieOutput) {
session.removeOutput(movieOutput)
}

captureSession = nil
capturePreviewLayer = nil
captureDeviceInputCamera = nil
captureDeviceInputMicrophone = nil
captureMovieFileOutput = nil
}

/// Starts capturing the video to the output URL.
///
/// If the app is not authroized, or if the Capture URL is not set, or if the capturing is already started, this call does nothing.
/// If successful, the captureRecordingStartedEvent will be invoked.
/// If the file has permission issues, the captureRecordingStoppedEvent will be invoked after captureRecordingStartedEvent.
/// If the Capture URL points to a file that exists, this call will delete it before starting recording.
public func startCapture() {
guard hasCaptureAuthorization, let url = captureURL, let output = captureMovieFileOutput else { return }

do {
try FileManager.default.removeItem(at: url)
}
catch {
}

output.startRecording(to: url, recordingDelegate: self)
}

/// Stops capturing the video to the output URL.
///
/// If the app is not authroized, or if the Capture URL is not set, or if the capturing is already started, this call does nothing.
/// If succesfull, the captureRecordingStoppedEvent closure will be invoked.
public func stopCapture() {
guard hasCaptureAuthorization, let output = captureMovieFileOutput else { return }

output.stopRecording()
}

// MARK: Delegate Functions

public func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo: URL, from: [AVCaptureConnection]) {
if let block = captureRecordingStartedEvent {
block(self)
}
}

public func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
if let block = captureRecordingStoppedEvent {
block(self)
}

if let error = error {
print("AV File Capture Error: \(error.localizedDescription)")
}
}

}

11 changes: 5 additions & 6 deletions Tests/MSCaptureViewTests/MSCaptureViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import XCTest
@testable import MSCaptureView

final class MSCaptureViewTests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(MSCaptureView().text, "Hello, World!")
func testVesion() {
let (major, minor, _) = MSCaptureView.version
XCTAssertEqual(major, 1)
XCTAssertEqual(minor, 0)
}

static var allTests = [
("testExample", testExample),
("testVesion", testVesion),
]
}

0 comments on commit 059152a

Please sign in to comment.