Start version 2

This commit is contained in:
Christoph Hagen
2022-06-10 21:20:49 +02:00
parent c119885743
commit 093d82893b
93 changed files with 2604 additions and 6509 deletions

View File

@@ -0,0 +1,32 @@
import Foundation
enum CameraError: Error {
case cameraUnavailable
case cannotAddInput
case cannotAddOutput
case createCaptureInput(Error)
case deniedAuthorization
case restrictedAuthorization
case unknownAuthorization
}
extension CameraError: LocalizedError {
var errorDescription: String? {
switch self {
case .cameraUnavailable:
return "Camera unavailable"
case .cannotAddInput:
return "Cannot add capture input to session"
case .cannotAddOutput:
return "Cannot add video output to session"
case .createCaptureInput(let error):
return "Creating capture input for camera: \(error.localizedDescription)"
case .deniedAuthorization:
return "Camera access denied"
case .restrictedAuthorization:
return "Attempting to access a restricted capture device"
case .unknownAuthorization:
return "Unknown authorization status for capture device"
}
}
}

View File

@@ -0,0 +1,172 @@
import Foundation
import AVFoundation
class CameraManager: ObservableObject {
enum Status {
case unconfigured
case configured
case unauthorized
case failed
}
static let shared = CameraManager()
@Published var error: CameraError?
let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "de.christophhagen.cam")
private let videoOutput = AVCaptureVideoDataOutput()
private let photoOutput = AVCapturePhotoOutput()
private var status = Status.unconfigured
private init() {
configure()
}
private func set(error: CameraError?) {
DispatchQueue.main.async {
self.error = error
}
}
private func checkPermissions() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .notDetermined:
sessionQueue.suspend()
AVCaptureDevice.requestAccess(for: .video) { authorized in
if !authorized {
self.status = .unauthorized
self.set(error: .deniedAuthorization)
}
self.sessionQueue.resume()
}
case .restricted:
status = .unauthorized
set(error: .restrictedAuthorization)
case .denied:
status = .unauthorized
set(error: .deniedAuthorization)
case .authorized:
break
@unknown default:
status = .unauthorized
set(error: .unknownAuthorization)
}
}
private func configureCaptureSession() {
guard status == .unconfigured else {
return
}
session.beginConfiguration()
session.sessionPreset = .photo
defer {
session.commitConfiguration()
}
let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back)
guard let camera = device else {
set(error: .cameraUnavailable)
status = .failed
return
}
let cameraInput: AVCaptureDeviceInput
do {
cameraInput = try AVCaptureDeviceInput(device: camera)
} catch {
set(error: .createCaptureInput(error))
status = .failed
return
}
guard session.canAddInput(cameraInput) else {
set(error: .cannotAddInput)
status = .failed
return
}
session.addInput(cameraInput)
if session.canAddOutput(videoOutput) {
session.addOutput(videoOutput)
videoOutput.videoSettings =
[kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
let videoConnection = videoOutput.connection(with: .video)
videoConnection?.videoOrientation = .portrait
} else {
set(error: .cannotAddOutput)
status = .failed
return
}
guard session.canAddOutput(photoOutput) else {
set(error: .cannotAddOutput)
status = .failed
return
}
session.addOutput(photoOutput)
photoOutput.isHighResolutionCaptureEnabled = true
photoOutput.isDepthDataDeliveryEnabled = false
photoOutput.isLivePhotoCaptureEnabled = false
status = .configured
}
private func configure() {
checkPermissions()
sessionQueue.async {
self.configureCaptureSession()
self.session.startRunning()
}
}
func setVideoDelegate(_ delegate: AVCaptureVideoDataOutputSampleBufferDelegate,
queue: DispatchQueue) {
sessionQueue.async {
self.videoOutput.setSampleBufferDelegate(delegate, queue: queue)
}
}
func stopVideoCaptureSession() {
sessionQueue.async {
guard self.status == .configured else {
return
}
guard self.session.isRunning else {
return
}
self.session.stopRunning()
}
}
func startVideoCapture() {
guard status == .configured else {
return
}
sessionQueue.async {
guard !self.session.isRunning else {
return
}
self.session.startRunning()
}
}
// MARK: Photo Capture
func capturePhoto(delegate: AVCapturePhotoCaptureDelegate) {
sessionQueue.async {
let photoSettings = AVCapturePhotoSettings()
photoSettings.flashMode = .off
self.photoOutput.capturePhoto(with: photoSettings, delegate: delegate)
}
}
}

View File

@@ -0,0 +1,127 @@
import SwiftUI
import SFSafeSymbols
struct CameraView: View {
static let cameraImagePadding: CGFloat = 300
private static let circleSize: CGFloat = 180
private var circleSize: CGFloat {
CameraView.circleSize
}
static var circleCropFactor: CGFloat {
let fullWidth = UIScreen.main.bounds.width + 2 * cameraImagePadding
return circleSize / fullWidth
}
private let circleStrength: CGFloat = 3
private let circleColor: Color = .green
private let captureButtonSize: CGFloat = 110
private let captureButtonHeight: CGFloat = 40
private let captureButtonWidth: CGFloat = 50
private let cancelButtonSize: CGFloat = 75
private let cancelIconSize: CGFloat = 25
@Binding
var isPresented: Bool
@Binding
var image: UIImage?
@Binding
var capId: Int?
@StateObject
private var model = ContentViewModel()
@EnvironmentObject
var database: Database
var body: some View {
ZStack {
FrameView(image: model.frame)
.edgesIgnoringSafeArea(.all)
.padding(-CameraView.cameraImagePadding)
ErrorView(error: model.error)
VStack {
Spacer()
HStack {
Spacer()
Button(action: dismiss) {
Image(systemSymbol: .xmark)
.resizable()
.frame(width: cancelIconSize, height: cancelIconSize)
.padding((cancelButtonSize-cancelIconSize)/2)
.background(.thinMaterial)
.cornerRadius(cancelButtonSize/2)
}.padding()
}
}
VStack {
Spacer()
HStack {
Spacer()
Button(action: capture) {
Image(systemSymbol: .camera)
.resizable()
.frame(width: captureButtonWidth, height: captureButtonHeight)
.padding(.horizontal, (captureButtonSize - captureButtonWidth)/2)
.padding(.vertical, (captureButtonSize - captureButtonHeight)/2)
.background(.thinMaterial)
.cornerRadius(captureButtonSize / 2)
}.padding()
Spacer()
}
}
VStack {
Spacer()
HStack {
Spacer()
Text("")
.frame(width: circleSize, height: circleSize, alignment: .center)
.overlay(RoundedRectangle(cornerRadius: circleSize/2)
.stroke(lineWidth: circleStrength)
.foregroundColor(circleColor))
Spacer()
}
Spacer()
}.ignoresSafeArea()
}
.onAppear() {
model.startCapture()
}
.onDisappear {
model.endCapture()
}.onChange(of: model.image) { image in
if let capId = capId, let image = image {
database.save(image, for: capId)
} else {
database.image = image
}
dismiss()
}
}
private func dismiss() {
isPresented = false
}
private func capture() {
model.captureImage()
}
}
struct CameraView_Previews: PreviewProvider {
static var previews: some View {
CameraView(isPresented: .constant(true),
image: .constant(nil),
capId: .constant(nil))
}
}

View File

@@ -0,0 +1,57 @@
import CoreImage
import AVFoundation
import UIKit
class ContentViewModel: ObservableObject {
@Published var error: Error?
@Published var frame: CGImage?
@Published var image: UIImage?
private let context = CIContext()
private let cameraManager = CameraManager.shared
private let frameManager = FrameManager.shared
init() {
setupSubscriptions()
}
func setupSubscriptions() {
frameManager.image = nil
frameManager.current = nil
cameraManager.$error
.receive(on: RunLoop.main)
.map { $0 }
.assign(to: &$error)
frameManager.$current
.receive(on: RunLoop.main)
.compactMap { buffer in
guard let image = CGImage.create(from: buffer) else {
return nil
}
let ciImage = CIImage(cgImage: image)
return self.context.createCGImage(ciImage, from: ciImage.extent)
}
.assign(to: &$frame)
frameManager.$image
.receive(on: RunLoop.main)
.assign(to: &$image)
}
func endCapture() {
cameraManager.stopVideoCaptureSession()
}
func startCapture() {
cameraManager.startVideoCapture()
}
func captureImage() {
cameraManager.capturePhoto(delegate: frameManager)
}
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
struct ErrorView: View {
var error: Error?
var body: some View {
VStack {
Text(error?.localizedDescription ?? "")
.bold()
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(8)
.foregroundColor(.white)
.background(Color.red.edgesIgnoringSafeArea(.top))
.opacity(error == nil ? 0.0 : 1.0)
.animation(.easeInOut, value: 0.25)
Spacer()
}
}
}
struct ErrorView_Previews: PreviewProvider {
static var previews: some View {
ErrorView(error: CameraError.cannotAddInput)
}
}

View File

@@ -0,0 +1,70 @@
import AVFoundation
import CoreGraphics
import UIKit
class FrameManager: NSObject, ObservableObject {
static let shared = FrameManager()
@Published var current: CVPixelBuffer?
@Published var image: UIImage?
let videoOutputQueue = DispatchQueue(
label: "de.christophhagen.videoout",
qos: .userInitiated,
attributes: [],
autoreleaseFrequency: .workItem)
private override init() {
super.init()
CameraManager.shared.setVideoDelegate(self, queue: videoOutputQueue)
}
}
extension FrameManager: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
if let buffer = sampleBuffer.imageBuffer {
DispatchQueue.main.async {
self.current = buffer
}
}
}
}
extension FrameManager: AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
let image = convert(photo, error: error)
DispatchQueue.main.async {
self.image = image
}
}
private func convert(_ photo: AVCapturePhoto, error: Error?) -> UIImage? {
guard error == nil else {
log("PhotoCaptureHandler: \(error!)")
return nil
}
guard let cgImage = photo.cgImageRepresentation() else {
log("PhotoCaptureHandler: No image captured")
return nil
}
let image = UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .right)
guard let masked = image.crop(factor: CameraView.circleCropFactor).circleMasked else {
log("Could not mask image")
return nil
}
print(image.size)
print(masked.size)
print(masked.scale)
return masked
}
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
struct FrameView: View {
var image: CGImage?
private let label = Text("Video feed")
var body: some View {
if let image = image {
GeometryReader { geometry in
Image(image, scale: 1.0, orientation: .up, label: label)
.resizable()
.scaledToFill()
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .center)
.clipped()
}
} else {
EmptyView()
}
}
}
struct FrameView_Previews: PreviewProvider {
static var previews: some View {
FrameView(image: nil)
}
}