Start version 2
This commit is contained in:
32
Caps/Camera/CameraError.swift
Normal file
32
Caps/Camera/CameraError.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
172
Caps/Camera/CameraManager.swift
Normal file
172
Caps/Camera/CameraManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
127
Caps/Camera/CameraView.swift
Normal file
127
Caps/Camera/CameraView.swift
Normal 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))
|
||||
}
|
||||
}
|
57
Caps/Camera/ContentViewModel.swift
Normal file
57
Caps/Camera/ContentViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
27
Caps/Camera/ErrorView.swift
Normal file
27
Caps/Camera/ErrorView.swift
Normal 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)
|
||||
}
|
||||
}
|
70
Caps/Camera/FrameManager.swift
Normal file
70
Caps/Camera/FrameManager.swift
Normal 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
|
||||
}
|
||||
}
|
30
Caps/Camera/FrameView.swift
Normal file
30
Caps/Camera/FrameView.swift
Normal 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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user