Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

CKeyCameraScanner.swift

v0.1.0-build.85 Latest Commit ede38f5

Size
9.6 KB
Lines
244
Language
swift
SHA-256
73bf9402ddbc…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1#if canImport(AVFoundation) && (os(iOS) || os(macOS))
2import AVFoundation
3import CoreVideo
4import Foundation
5import QuartzCore
6#if canImport(SwiftUI)
7import SwiftUI
8#endif
9#if os(iOS)
10import UIKit
11#elseif os(macOS)
12import AppKit
13#endif
14
15/// A continuous AVFoundation camera scanner. It processes live frames on a
16/// background queue (at most `maxFramesPerSecond`), never needs a still
17/// capture, and emits the first valid decode of a session through
18/// `onResult` on the main queue. Frames are then ignored until `scanAgain()`.
19///
20/// Add `NSCameraUsageDescription` to the app's Info.plist. On macOS, sandboxed
21/// apps also need the camera entitlement.
22///
23/// The `CKey` product's `CKeyScanner` is this class with telemetry attached;
24/// apps that link only the scan-only product use this class directly.
25open class CKeyCameraScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
26 /// The capture session; show it with `CKeyCameraPreview` or an
27 /// `AVCaptureVideoPreviewLayer`.
28 public let session = AVCaptureSession()
29 /// Called on the main queue with the first valid decode of a session.
30 public var onResult: ((CKeyResult) -> Void)?
31 /// Called on the main queue when the camera cannot be used.
32 public var onError: ((CKeyError) -> Void)?
33 /// Frame-rate cap for decoding; stale frames are discarded.
34 public var maxFramesPerSecond: Double = 20
35
36 private let observer: CKeyScanObserver?
37 private let sessionQueue = DispatchQueue(label: "org.openckey.scanner.session")
38 private let frameQueue = DispatchQueue(label: "org.openckey.scanner.frames", qos: .userInitiated)
39 private let output = AVCaptureVideoDataOutput()
40 private var configured = false
41 // Frame-queue state.
42 private var didDecode = false
43 private var lastFrameTime: CFTimeInterval = 0
44 // Main-thread state.
45 private var sessionOpen = false
46
47 /// Capture resolution. `.uhd4K` reads the densest symbols; `.hd1080` uses
48 /// far less memory (camera extensions, older devices).
49 public enum Resolution: Sendable { case uhd4K, hd1080 }
50 private let resolution: Resolution
51
52 /// - Parameters:
53 /// - observer: optional session observer (for example your own metrics);
54 /// held strongly. `nil` for none.
55 /// - resolution: preferred capture resolution; falls back to the best
56 /// the device supports.
57 public init(observer: CKeyScanObserver?, resolution: Resolution = .uhd4K) {
58 self.observer = observer
59 self.resolution = resolution
60 super.init()
61 }
62
63 deinit {
64 if sessionOpen { observer?.scanSessionDidEnd() }
65 let session = self.session
66 sessionQueue.async { if session.isRunning { session.stopRunning() } }
67 }
68
69 /// Requests camera access if needed, then starts the camera and a scan session.
70 public func start() {
71 switch AVCaptureDevice.authorizationStatus(for: .video) {
72 case .authorized:
73 run()
74 case .notDetermined:
75 AVCaptureDevice.requestAccess(for: .video) { [weak self] allowed in
76 DispatchQueue.main.async {
77 guard let self else { return }
78 if allowed { self.run() } else { self.fail("Camera access was denied.", .cameraAccessDenied) }
79 }
80 }
81 default:
82 fail("Camera access is not authorized.", .cameraAccessDenied)
83 }
84 }
85
86 /// Stops the camera and ends the scan session (a session without a decode fails).
87 public func stop() {
88 endSession()
89 let session = self.session
90 sessionQueue.async { if session.isRunning { session.stopRunning() } }
91 }
92
93 /// Starts a new scan session after a decode, keeping the camera running.
94 public func scanAgain() {
95 endSession()
96 beginSession()
97 frameQueue.async { self.didDecode = false }
98 }
99
100 private func beginSession() {
101 if !Thread.isMainThread { DispatchQueue.main.async { self.beginSession() }; return }
102 sessionOpen = true
103 observer?.scanSessionDidBegin()
104 }
105
106 private func endSession() {
107 if !Thread.isMainThread { DispatchQueue.main.async { self.endSession() }; return }
108 guard sessionOpen else { return }
109 sessionOpen = false
110 observer?.scanSessionDidEnd()
111 }
112
113 private func fail(_ message: String, _ code: CKeyError.Code = .cameraUnavailable) {
114 DispatchQueue.main.async { self.onError?(CKeyError(code, message)) }
115 }
116
117 private func run() {
118 if !sessionOpen { beginSession() }
119 frameQueue.async { self.didDecode = false }
120 sessionQueue.async { [weak self] in
121 guard let self else { return }
122 if !self.configured {
123 guard self.configure() else {
124 self.fail("Could not configure the camera.")
125 return
126 }
127 self.configured = true
128 }
129 if !self.session.isRunning { self.session.startRunning() }
130 }
131 }
132
133 private func configure() -> Bool {
134 session.beginConfiguration()
135 defer { session.commitConfiguration() }
136 // Dense symbols need many camera pixels per unit: prefer 4K unless asked otherwise.
137 if resolution == .uhd4K, session.canSetSessionPreset(.hd4K3840x2160) {
138 session.sessionPreset = .hd4K3840x2160
139 } else if session.canSetSessionPreset(.hd1920x1080) {
140 session.sessionPreset = .hd1920x1080
141 } else {
142 session.sessionPreset = .high
143 }
144 #if os(iOS)
145 // The physical wide camera, so iOS cannot swap lenses mid-measurement.
146 let camera = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera],
147 mediaType: .video, position: .back).devices.first
148 ?? AVCaptureDevice.default(for: .video)
149 #else
150 let camera = AVCaptureDevice.default(for: .video)
151 #endif
152 guard let camera, let input = try? AVCaptureDeviceInput(device: camera), session.canAddInput(input) else {
153 return false
154 }
155 session.addInput(input)
156 if (try? camera.lockForConfiguration()) != nil {
157 if camera.isFocusModeSupported(.continuousAutoFocus) { camera.focusMode = .continuousAutoFocus }
158 if camera.isExposureModeSupported(.continuousAutoExposure) { camera.exposureMode = .continuousAutoExposure }
159 #if os(iOS)
160 if camera.isAutoFocusRangeRestrictionSupported { camera.autoFocusRangeRestriction = .none }
161 camera.videoZoomFactor = 1.0
162 camera.isSubjectAreaChangeMonitoringEnabled = false
163 #endif
164 camera.unlockForConfiguration()
165 }
166 output.videoSettings = [
167 kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
168 ]
169 output.alwaysDiscardsLateVideoFrames = true
170 output.setSampleBufferDelegate(self, queue: frameQueue)
171 guard session.canAddOutput(output) else { return false }
172 session.addOutput(output)
173 return true
174 }
175
176 // MARK: AVCaptureVideoDataOutputSampleBufferDelegate
177
178 open func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
179 from connection: AVCaptureConnection) {
180 if didDecode { return }
181 let now = CACurrentMediaTime()
182 if maxFramesPerSecond > 0, now - lastFrameTime < 1 / maxFramesPerSecond { return }
183 lastFrameTime = now
184 guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
185 let result = CKeyDecoder.decode(pixelBuffer: pixelBuffer)
186 observer?.scanFrameProcessed(milliseconds: (CACurrentMediaTime() - now) * 1000)
187 guard let result else { return }
188 didDecode = true
189 observer?.scanSessionDidDecode(result)
190 DispatchQueue.main.async { self.onResult?(result) }
191 }
192}
193
194#if canImport(SwiftUI)
195#if os(iOS)
196/// A SwiftUI camera preview for a `CKeyCameraScanner`'s session.
197public struct CKeyCameraPreview: UIViewRepresentable {
198 public let session: AVCaptureSession
199 public init(session: AVCaptureSession) { self.session = session }
200
201 public func makeUIView(context: Context) -> CKeyPreviewView {
202 let view = CKeyPreviewView()
203 view.previewLayer.session = session
204 view.previewLayer.videoGravity = .resizeAspectFill
205 return view
206 }
207
208 public func updateUIView(_ view: CKeyPreviewView, context: Context) {
209 if view.previewLayer.session !== session { view.previewLayer.session = session }
210 }
211}
212
213/// A UIView backed by an `AVCaptureVideoPreviewLayer`.
214public final class CKeyPreviewView: UIView {
215 public override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
216 public var previewLayer: AVCaptureVideoPreviewLayer {
217 // layerClass guarantees the type.
218 layer as! AVCaptureVideoPreviewLayer // swiftlint:disable:this force_cast
219 }
220}
221#elseif os(macOS)
222/// A SwiftUI camera preview for a `CKeyCameraScanner`'s session.
223public struct CKeyCameraPreview: NSViewRepresentable {
224 public let session: AVCaptureSession
225 public init(session: AVCaptureSession) { self.session = session }
226
227 public func makeNSView(context: Context) -> NSView {
228 let view = NSView()
229 let layer = AVCaptureVideoPreviewLayer(session: session)
230 layer.videoGravity = .resizeAspectFill
231 view.layer = layer
232 view.wantsLayer = true
233 return view
234 }
235
236 public func updateNSView(_ view: NSView, context: Context) {
237 if let layer = view.layer as? AVCaptureVideoPreviewLayer, layer.session !== session {
238 layer.session = session
239 }
240 }
241}
242#endif
243#endif
244#endif