@octane-xplat/camera 0.0.0-nightly.g2e22171
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +104 -0
- package/platforms/android/AndroidManifest.xml +6 -0
- package/platforms/android/include.gradle +14 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +10 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +639 -0
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +150 -0
- package/src/CameraView.ios.tsrx +102 -0
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.mobile.ts +6 -0
- package/src/CameraView.web.tsrx +132 -0
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +91 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.ios.ts +65 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +126 -0
- package/src/host-movie.web.ts +295 -0
- package/src/index.ts +36 -0
- package/src/index.web.ts +34 -0
- package/src/layout-child.ts +81 -0
- package/src/layout-child.web.ts +66 -0
- package/src/layout-child.windows.ts +79 -0
- package/src/movie-metadata.web.ts +84 -0
- package/src/movie-storage.web.ts +164 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +77 -0
- package/src/session-backend.android.ts +439 -0
- package/src/session-backend.ios.ts +1258 -0
- package/src/session-backend.linux.ts +887 -0
- package/src/session-backend.macos.ts +707 -0
- package/src/session-backend.ts +59 -0
- package/src/session-backend.web.ts +891 -0
- package/src/session-backend.windows.ts +1599 -0
- package/src/session-core.ts +723 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +377 -0
- package/src/types.ts +357 -0
- package/types/index.d.ts +48 -0
- package/types/props.d.ts +61 -0
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- package/types/types.d.ts +290 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import CoreMedia
|
|
4
|
+
|
|
5
|
+
private func fourCharCode(_ value: FourCharCode) -> String {
|
|
6
|
+
String(
|
|
7
|
+
format: "%c%c%c%c",
|
|
8
|
+
Int((value >> 24) & 0xff),
|
|
9
|
+
Int((value >> 16) & 0xff),
|
|
10
|
+
Int((value >> 8) & 0xff),
|
|
11
|
+
Int(value & 0xff)
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Finalized-movie metadata read inside the delegate callback: the file is
|
|
16
|
+
// complete at that point, so AVFoundation reports real dimensions, duration,
|
|
17
|
+
// audio presence, and rotation instead of estimated state.
|
|
18
|
+
private func movieClipInfo(_ url: URL) -> [String: Any] {
|
|
19
|
+
let asset = AVURLAsset(url: url)
|
|
20
|
+
var width = 0.0
|
|
21
|
+
var height = 0.0
|
|
22
|
+
var rotation = 0.0
|
|
23
|
+
var videoCodec: String?
|
|
24
|
+
|
|
25
|
+
if let track = asset.tracks(withMediaType: .video).first {
|
|
26
|
+
let transformed = track.naturalSize.applying(track.preferredTransform)
|
|
27
|
+
width = abs(Double(transformed.width))
|
|
28
|
+
height = abs(Double(transformed.height))
|
|
29
|
+
rotation = atan2(Double(track.preferredTransform.b), Double(track.preferredTransform.a))
|
|
30
|
+
* 180.0 / .pi
|
|
31
|
+
if rotation < 0 {
|
|
32
|
+
rotation += 360
|
|
33
|
+
}
|
|
34
|
+
if let format = track.formatDescriptions.first as! CMFormatDescription? {
|
|
35
|
+
let subtype = fourCharCode(CMFormatDescriptionGetMediaSubType(format))
|
|
36
|
+
if subtype == "avc1" {
|
|
37
|
+
videoCodec = "avc1"
|
|
38
|
+
} else if subtype == "hvc1" || subtype == "hev1" {
|
|
39
|
+
videoCodec = "hvc1"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let duration = CMTimeGetSeconds(asset.duration)
|
|
45
|
+
let size = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int ?? 0
|
|
46
|
+
var info: [String: Any] = [
|
|
47
|
+
"fileUrl": url.absoluteString,
|
|
48
|
+
"durationMs": duration.isFinite ? duration * 1000 : 0,
|
|
49
|
+
"width": width,
|
|
50
|
+
"height": height,
|
|
51
|
+
"rotationAngle": (Int((rotation / 90).rounded()) * 90) % 360,
|
|
52
|
+
"fileSize": size,
|
|
53
|
+
"hasAudio": !asset.tracks(withMediaType: .audio).isEmpty,
|
|
54
|
+
"mimeType": "video/quicktime",
|
|
55
|
+
"container": "mov",
|
|
56
|
+
]
|
|
57
|
+
if let videoCodec {
|
|
58
|
+
info["videoCodec"] = videoCodec
|
|
59
|
+
}
|
|
60
|
+
return info
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private final class XplatRecordingDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|
64
|
+
var onStart: (() -> Void)?
|
|
65
|
+
var onFinish: ((URL, Bool, Bool, NSError?) -> Void)?
|
|
66
|
+
|
|
67
|
+
func fileOutput(
|
|
68
|
+
_ output: AVCaptureFileOutput,
|
|
69
|
+
didStartRecordingTo fileURL: URL,
|
|
70
|
+
from connections: [AVCaptureConnection]
|
|
71
|
+
) {
|
|
72
|
+
DispatchQueue.main.async { self.onStart?() }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func fileOutput(
|
|
76
|
+
_ output: AVCaptureFileOutput,
|
|
77
|
+
didFinishRecordingTo outputFileURL: URL,
|
|
78
|
+
from connections: [AVCaptureConnection],
|
|
79
|
+
error: Error?
|
|
80
|
+
) {
|
|
81
|
+
let failure = error as NSError?
|
|
82
|
+
// A requested stop finalizes cleanly; the maximum-duration limit and
|
|
83
|
+
// interruption-driven endings still produce usable files.
|
|
84
|
+
let succeeded =
|
|
85
|
+
failure == nil ||
|
|
86
|
+
(failure!.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool == true)
|
|
87
|
+
let limitReached =
|
|
88
|
+
failure != nil && failure!.code == AVError.Code.maximumDurationReached.rawValue
|
|
89
|
+
DispatchQueue.main.async { self.onFinish?(outputFileURL, succeeded, limitReached, failure) }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private final class XplatPhotoDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
|
94
|
+
var reply: ((Data?) -> Void)?
|
|
95
|
+
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
|
96
|
+
let data = error == nil ? photo.fileDataRepresentation() : nil
|
|
97
|
+
DispatchQueue.main.async { [self] in
|
|
98
|
+
reply?(data)
|
|
99
|
+
reply = nil
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@objc(XplatCameraHost)
|
|
105
|
+
public final class XplatCameraHost: NSObject {
|
|
106
|
+
private var session: AVCaptureSession?
|
|
107
|
+
private var videoInput: AVCaptureDeviceInput?
|
|
108
|
+
private var audioInput: AVCaptureDeviceInput?
|
|
109
|
+
private var movieOutput: AVCaptureMovieFileOutput?
|
|
110
|
+
private var photoOutput: AVCapturePhotoOutput?
|
|
111
|
+
private var photoDelegate: XplatPhotoDelegate?
|
|
112
|
+
private var claimedCameraIds: Set<String> = []
|
|
113
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
114
|
+
private weak var previewView: NSView?
|
|
115
|
+
private var configuredCameraId: String?
|
|
116
|
+
private var configuredAudio = false
|
|
117
|
+
private var rotationAngle: Double?
|
|
118
|
+
private var emit: ((String) -> Void)?
|
|
119
|
+
private var observers: [NSObjectProtocol] = []
|
|
120
|
+
private var disposed = false
|
|
121
|
+
private var recordingPath: String?
|
|
122
|
+
private let queue = DispatchQueue(label: "org.octane.xplat.camera")
|
|
123
|
+
|
|
124
|
+
public override init() {
|
|
125
|
+
super.init()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private func send(_ payload: [String: Any]) {
|
|
129
|
+
guard
|
|
130
|
+
let emit,
|
|
131
|
+
let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
132
|
+
let text = String(data: data, encoding: .utf8)
|
|
133
|
+
else {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
emit(text)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@objc public func installObserver(_ listener: @escaping (String) -> Void) {
|
|
141
|
+
emit = listener
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private func status(for mediaType: AVMediaType) -> String {
|
|
145
|
+
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
|
146
|
+
case .authorized:
|
|
147
|
+
return "granted"
|
|
148
|
+
case .denied:
|
|
149
|
+
return "denied"
|
|
150
|
+
case .restricted:
|
|
151
|
+
return "restricted"
|
|
152
|
+
case .notDetermined:
|
|
153
|
+
return "notDetermined"
|
|
154
|
+
@unknown default:
|
|
155
|
+
return "unknown"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private func mediaKind(_ kind: String) -> AVMediaType {
|
|
160
|
+
kind == "microphone" ? .audio : .video
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private func declared(_ mediaType: AVMediaType) -> Bool {
|
|
164
|
+
let key = mediaType == .audio ? "NSMicrophoneUsageDescription" : "NSCameraUsageDescription"
|
|
165
|
+
return Bundle.main.object(forInfoDictionaryKey: key) != nil
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@objc public func permissionStatus(_ kind: String) -> NSString {
|
|
169
|
+
let mediaType = mediaKind(kind)
|
|
170
|
+
guard declared(mediaType) else { return "undeclared" }
|
|
171
|
+
return status(for: mediaType) as NSString
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@objc public func requestPermission(_ kind: String, reply: @escaping (String) -> Void) {
|
|
175
|
+
let mediaType = mediaKind(kind)
|
|
176
|
+
guard declared(mediaType) else {
|
|
177
|
+
reply("undeclared")
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
let current = status(for: mediaType)
|
|
181
|
+
guard current == "notDetermined" else {
|
|
182
|
+
reply(current)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
AVCaptureDevice.requestAccess(for: mediaType) { _ in
|
|
186
|
+
DispatchQueue.main.async { reply(self.status(for: mediaType)) }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func cameraDevices() -> [AVCaptureDevice] {
|
|
191
|
+
let types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera, .externalUnknown]
|
|
192
|
+
var found = AVCaptureDevice.DiscoverySession(
|
|
193
|
+
deviceTypes: types,
|
|
194
|
+
mediaType: .video,
|
|
195
|
+
position: .unspecified
|
|
196
|
+
).devices
|
|
197
|
+
// The discovery session is authoritative; keep the legacy enumerator as
|
|
198
|
+
// a merge source for devices on older system builds.
|
|
199
|
+
for device in AVCaptureDevice.devices(for: .video) where !found.contains(device) {
|
|
200
|
+
found.append(device)
|
|
201
|
+
}
|
|
202
|
+
return found
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@objc public func devices() -> NSString {
|
|
206
|
+
let list: [[String: Any]] = cameraDevices().map {
|
|
207
|
+
[
|
|
208
|
+
"id": $0.uniqueID,
|
|
209
|
+
"name": $0.localizedName,
|
|
210
|
+
"facing": NSNull(),
|
|
211
|
+
"focusPointOfInterest": $0.isFocusPointOfInterestSupported,
|
|
212
|
+
"exposurePointOfInterest": $0.isExposurePointOfInterestSupported,
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
guard
|
|
216
|
+
let data = try? JSONSerialization.data(withJSONObject: list),
|
|
217
|
+
let text = String(data: data, encoding: .utf8)
|
|
218
|
+
else {
|
|
219
|
+
return "[]"
|
|
220
|
+
}
|
|
221
|
+
return text as NSString
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@objc public func audioDeviceAvailable() -> Bool {
|
|
225
|
+
!AVCaptureDevice.devices(for: .audio).isEmpty
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func ensureSession() -> AVCaptureSession? {
|
|
229
|
+
if let session {
|
|
230
|
+
return session
|
|
231
|
+
}
|
|
232
|
+
guard status(for: .video) == "granted" else {
|
|
233
|
+
return nil
|
|
234
|
+
}
|
|
235
|
+
let session = AVCaptureSession()
|
|
236
|
+
if session.canSetSessionPreset(.hd1280x720) {
|
|
237
|
+
session.sessionPreset = .hd1280x720
|
|
238
|
+
}
|
|
239
|
+
self.session = session
|
|
240
|
+
let names: [Notification.Name] = [
|
|
241
|
+
.AVCaptureSessionWasInterrupted,
|
|
242
|
+
.AVCaptureSessionInterruptionEnded,
|
|
243
|
+
.AVCaptureSessionRuntimeError,
|
|
244
|
+
.AVCaptureSessionDidStartRunning,
|
|
245
|
+
.AVCaptureSessionDidStopRunning,
|
|
246
|
+
]
|
|
247
|
+
for name in names {
|
|
248
|
+
observers.append(
|
|
249
|
+
NotificationCenter.default.addObserver(
|
|
250
|
+
forName: name,
|
|
251
|
+
object: session,
|
|
252
|
+
queue: .main
|
|
253
|
+
) { [weak self] note in
|
|
254
|
+
self?.handleSessionNotification(note)
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
for (name, available) in [
|
|
259
|
+
(NSNotification.Name.AVCaptureDeviceWasDisconnected, false),
|
|
260
|
+
(NSNotification.Name.AVCaptureDeviceWasConnected, true),
|
|
261
|
+
] {
|
|
262
|
+
observers.append(
|
|
263
|
+
NotificationCenter.default.addObserver(
|
|
264
|
+
forName: name,
|
|
265
|
+
object: nil,
|
|
266
|
+
queue: .main
|
|
267
|
+
) { [weak self] _ in
|
|
268
|
+
self?.send(["type": "availability", "available": available])
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
return session
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private func handleSessionNotification(_ note: Notification) {
|
|
276
|
+
switch note.name {
|
|
277
|
+
case .AVCaptureSessionWasInterrupted:
|
|
278
|
+
// macOS publishes no interruption-reason keys — the cause stays
|
|
279
|
+
// honest 'unknown' rather than a guessed code.
|
|
280
|
+
send(["type": "interrupted", "cause": "unknown"])
|
|
281
|
+
case .AVCaptureSessionInterruptionEnded:
|
|
282
|
+
send(["type": "interruptionEnded"])
|
|
283
|
+
case .AVCaptureSessionRuntimeError:
|
|
284
|
+
send(["type": "availability", "available": false])
|
|
285
|
+
send(["type": "interrupted", "cause": "cameraInUse"])
|
|
286
|
+
case .AVCaptureSessionDidStartRunning:
|
|
287
|
+
send(["type": "previewReady", "ready": true])
|
|
288
|
+
case .AVCaptureSessionDidStopRunning:
|
|
289
|
+
send(["type": "previewReady", "ready": false])
|
|
290
|
+
default:
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Rotation-angle connections exist on macOS 14+; earlier hosts cannot
|
|
296
|
+
* lock a take's orientation. */
|
|
297
|
+
@objc public func supportsRotationAngles() -> Bool {
|
|
298
|
+
if #available(macOS 14.0, *) {
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private func applyRotation(_ connection: AVCaptureConnection?) {
|
|
305
|
+
guard let connection, let rotationAngle else { return }
|
|
306
|
+
if #available(macOS 14.0, *),
|
|
307
|
+
connection.isVideoRotationAngleSupported(rotationAngle)
|
|
308
|
+
{
|
|
309
|
+
connection.videoRotationAngle = rotationAngle
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
@objc public func configure(_ options: NSDictionary) -> NSString? {
|
|
314
|
+
guard !disposed else { return "unavailable" }
|
|
315
|
+
guard let session = ensureSession() else { return "permissionDenied" }
|
|
316
|
+
var failure: String?
|
|
317
|
+
queue.sync {
|
|
318
|
+
guard !disposed else { failure = "unavailable"; return }
|
|
319
|
+
let cameraId = options["cameraId"] as? String
|
|
320
|
+
let photo = options["photo"] as? Bool ?? false
|
|
321
|
+
let audio = !photo && (options["audio"] as? Bool ?? false)
|
|
322
|
+
rotationAngle = (options["rotationAngle"] as? NSNumber)?.doubleValue
|
|
323
|
+
let devices = cameraDevices()
|
|
324
|
+
guard let device = cameraId != nil
|
|
325
|
+
? devices.first(where: { $0.uniqueID == cameraId })
|
|
326
|
+
: devices.first ?? AVCaptureDevice.default(for: .video)
|
|
327
|
+
else {
|
|
328
|
+
failure = "unavailable"
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
guard CameraOwnership.claim(device.uniqueID, owner: self) else {
|
|
333
|
+
failure = "busy"
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
claimedCameraIds.insert(device.uniqueID)
|
|
337
|
+
defer {
|
|
338
|
+
// Failed reconfiguration may retain either input. Hold both claims
|
|
339
|
+
// until disposal rather than admitting an overlapping session.
|
|
340
|
+
if failure == nil {
|
|
341
|
+
for old in claimedCameraIds where old != device.uniqueID {
|
|
342
|
+
CameraOwnership.release(old, owner: self)
|
|
343
|
+
}
|
|
344
|
+
claimedCameraIds = [device.uniqueID]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
session.beginConfiguration()
|
|
348
|
+
if videoInput == nil || videoInput?.device.uniqueID != device.uniqueID {
|
|
349
|
+
do {
|
|
350
|
+
let input = try AVCaptureDeviceInput(device: device)
|
|
351
|
+
if videoInput != nil {
|
|
352
|
+
session.removeInput(videoInput!)
|
|
353
|
+
}
|
|
354
|
+
videoInput = input
|
|
355
|
+
if session.canAddInput(input) {
|
|
356
|
+
session.addInput(input)
|
|
357
|
+
} else {
|
|
358
|
+
failure = "unavailable"
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
session.commitConfiguration()
|
|
362
|
+
failure = "unavailable"
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if audio && audioInput == nil {
|
|
368
|
+
if let microphone = AVCaptureDevice.default(for: .audio),
|
|
369
|
+
let input = try? AVCaptureDeviceInput(device: microphone),
|
|
370
|
+
session.canAddInput(input)
|
|
371
|
+
{
|
|
372
|
+
audioInput = input
|
|
373
|
+
session.addInput(input)
|
|
374
|
+
}
|
|
375
|
+
} else if !audio, let audioInput {
|
|
376
|
+
session.removeInput(audioInput)
|
|
377
|
+
self.audioInput = nil
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if photo && photoOutput == nil {
|
|
381
|
+
let output = AVCapturePhotoOutput()
|
|
382
|
+
if session.canAddOutput(output) {
|
|
383
|
+
session.addOutput(output)
|
|
384
|
+
photoOutput = output
|
|
385
|
+
} else {
|
|
386
|
+
failure = "unavailable"
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if !photo && movieOutput == nil {
|
|
390
|
+
let output = AVCaptureMovieFileOutput()
|
|
391
|
+
if session.canAddOutput(output) {
|
|
392
|
+
session.addOutput(output)
|
|
393
|
+
movieOutput = output
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
session.commitConfiguration()
|
|
397
|
+
if let rotationAngle {
|
|
398
|
+
let connection = movieOutput?.connection(with: .video)
|
|
399
|
+
let supported: Bool
|
|
400
|
+
if #available(macOS 14.0, *) {
|
|
401
|
+
supported = connection?.isVideoRotationAngleSupported(rotationAngle) ?? false
|
|
402
|
+
} else {
|
|
403
|
+
supported = false
|
|
404
|
+
}
|
|
405
|
+
if !supported {
|
|
406
|
+
failure = "unsupportedConfiguration"
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
configuredCameraId = device.uniqueID
|
|
411
|
+
configuredAudio = audio
|
|
412
|
+
applyRotation(movieOutput?.connection(with: .video))
|
|
413
|
+
applyRotation(previewLayer?.connection)
|
|
414
|
+
}
|
|
415
|
+
return failure.map { $0 as NSString }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@objc public func activeDeviceId() -> NSString? {
|
|
419
|
+
videoInput?.device.uniqueID as NSString?
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@objc public func audioInputAttached() -> Bool {
|
|
423
|
+
audioInput != nil
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
@objc public func recordedMs() -> Double {
|
|
427
|
+
guard let duration = movieOutput?.recordedDuration, duration.isValid else {
|
|
428
|
+
return 0
|
|
429
|
+
}
|
|
430
|
+
return CMTimeGetSeconds(duration) * 1000
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
@objc public func attachPreview(_ view: NSView) -> NSString? {
|
|
434
|
+
guard let session = ensureSession() else { return "permissionDenied" }
|
|
435
|
+
previewView = view
|
|
436
|
+
previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
437
|
+
previewLayer?.videoGravity = .resizeAspectFill
|
|
438
|
+
view.wantsLayer = true
|
|
439
|
+
if let layer = previewLayer {
|
|
440
|
+
layer.frame = view.bounds
|
|
441
|
+
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
|
|
442
|
+
view.layer?.addSublayer(layer)
|
|
443
|
+
applyRotation(layer.connection)
|
|
444
|
+
}
|
|
445
|
+
queue.async {
|
|
446
|
+
if !self.disposed && !session.isRunning {
|
|
447
|
+
session.startRunning()
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return nil
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/// Stills use AVCapturePhotoOutput, never movie recording or an audio input.
|
|
454
|
+
@objc public func capturePhoto(_ reply: @escaping (NSData?) -> Void) -> NSString? {
|
|
455
|
+
guard !disposed, photoDelegate == nil, let photoOutput, session?.isRunning == true else {
|
|
456
|
+
return "unavailable"
|
|
457
|
+
}
|
|
458
|
+
guard photoOutput.availablePhotoCodecTypes.contains(.jpeg) else { return "unavailable" }
|
|
459
|
+
let delegate = XplatPhotoDelegate()
|
|
460
|
+
delegate.reply = { [weak self] data in
|
|
461
|
+
self?.photoDelegate = nil
|
|
462
|
+
reply(data as NSData?)
|
|
463
|
+
}
|
|
464
|
+
photoDelegate = delegate
|
|
465
|
+
let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
|
466
|
+
queue.async { [self] in
|
|
467
|
+
guard !disposed else { return }
|
|
468
|
+
photoOutput.capturePhoto(with: settings, delegate: delegate)
|
|
469
|
+
}
|
|
470
|
+
return nil
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/// Meters one autofocus/auto-exposure pass at a normalized preview
|
|
474
|
+
/// point (0-1, top-left origin as the user sees it). Returns whether
|
|
475
|
+
/// any metering dimension was applied.
|
|
476
|
+
@objc public func focusAtPoint(_ options: NSDictionary) -> Bool {
|
|
477
|
+
guard !disposed, let layer = previewLayer else { return false }
|
|
478
|
+
let x = (options["x"] as? NSNumber)?.doubleValue ?? 0
|
|
479
|
+
let y = (options["y"] as? NSNumber)?.doubleValue ?? 0
|
|
480
|
+
var point = CGPoint(
|
|
481
|
+
x: min(max(x, 0), 1) * layer.bounds.width,
|
|
482
|
+
y: min(max(y, 0), 1) * layer.bounds.height
|
|
483
|
+
)
|
|
484
|
+
// Unflipped AppKit views are bottom-left origin; the preview
|
|
485
|
+
// layer's conversion expects the layer's top-left space.
|
|
486
|
+
if let view = previewView, !view.isFlipped {
|
|
487
|
+
point.y = layer.bounds.height - point.y
|
|
488
|
+
}
|
|
489
|
+
let devicePoint = layer.captureDevicePointConverted(fromLayerPoint: point)
|
|
490
|
+
var applied = false
|
|
491
|
+
queue.sync {
|
|
492
|
+
guard !disposed, let device = videoInput?.device else { return }
|
|
493
|
+
let focus = device.isFocusPointOfInterestSupported && device.isFocusModeSupported(.autoFocus)
|
|
494
|
+
let expose = device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose)
|
|
495
|
+
guard focus || expose else { return }
|
|
496
|
+
do {
|
|
497
|
+
try device.lockForConfiguration()
|
|
498
|
+
// Single-shot AF/AE at the point — the AVCam tap-to-focus
|
|
499
|
+
// convention; the device locks after metering.
|
|
500
|
+
if focus {
|
|
501
|
+
device.focusPointOfInterest = devicePoint
|
|
502
|
+
device.focusMode = .autoFocus
|
|
503
|
+
}
|
|
504
|
+
if expose {
|
|
505
|
+
device.exposurePointOfInterest = devicePoint
|
|
506
|
+
device.exposureMode = .autoExpose
|
|
507
|
+
}
|
|
508
|
+
device.unlockForConfiguration()
|
|
509
|
+
applied = true
|
|
510
|
+
} catch {}
|
|
511
|
+
}
|
|
512
|
+
return applied
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
@objc public func detachPreview() {
|
|
516
|
+
previewLayer?.removeFromSuperlayer()
|
|
517
|
+
previewLayer = nil
|
|
518
|
+
previewView = nil
|
|
519
|
+
queue.async { [weak self] in
|
|
520
|
+
self?.session?.stopRunning()
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** App-private recordings root: ~/Library/Application Support/<bundle>/octane-camera */
|
|
525
|
+
@objc public func movieDirectory() -> NSString? {
|
|
526
|
+
guard let base = FileManager.default.urls(
|
|
527
|
+
for: .applicationSupportDirectory,
|
|
528
|
+
in: .userDomainMask
|
|
529
|
+
).first else {
|
|
530
|
+
return nil
|
|
531
|
+
}
|
|
532
|
+
let identifier = Bundle.main.bundleIdentifier ?? "org.octane.xplat"
|
|
533
|
+
let directory = base.appendingPathComponent(identifier).appendingPathComponent("octane-camera")
|
|
534
|
+
guard (try? FileManager.default.createDirectory(
|
|
535
|
+
at: directory,
|
|
536
|
+
withIntermediateDirectories: true
|
|
537
|
+
)) != nil else {
|
|
538
|
+
return nil
|
|
539
|
+
}
|
|
540
|
+
return directory.absoluteString as NSString
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
@objc public func startRecording(_ path: String, maximumDurationMs: Double) -> NSString? {
|
|
544
|
+
guard session != nil, let movieOutput else { return "unavailable" }
|
|
545
|
+
queue.sync {
|
|
546
|
+
let url = URL(fileURLWithPath: path)
|
|
547
|
+
recordingPath = path
|
|
548
|
+
movieOutput.maxRecordedDuration =
|
|
549
|
+
maximumDurationMs > 0
|
|
550
|
+
? CMTime(seconds: maximumDurationMs / 1000.0, preferredTimescale: 1000)
|
|
551
|
+
: .invalid
|
|
552
|
+
recordingDelegate.onStart = { [weak self] in
|
|
553
|
+
self?.send([
|
|
554
|
+
"type": "recordingStarted",
|
|
555
|
+
"fileUrl": url.absoluteString,
|
|
556
|
+
"audio": self?.audioInput != nil,
|
|
557
|
+
])
|
|
558
|
+
}
|
|
559
|
+
recordingDelegate.onFinish = { [weak self] fileUrl, succeeded, limitReached, failure in
|
|
560
|
+
var payload: [String: Any] = [
|
|
561
|
+
"type": "recordingFinished",
|
|
562
|
+
"fileUrl": fileUrl.absoluteString,
|
|
563
|
+
"succeeded": succeeded,
|
|
564
|
+
"limitReached": limitReached,
|
|
565
|
+
]
|
|
566
|
+
if succeeded, FileManager.default.fileExists(atPath: fileUrl.path) {
|
|
567
|
+
payload["clip"] = movieClipInfo(fileUrl)
|
|
568
|
+
} else {
|
|
569
|
+
// Reclaim only the file this attempt created — admission
|
|
570
|
+
// never allows a pre-existing destination.
|
|
571
|
+
try? FileManager.default.removeItem(at: fileUrl)
|
|
572
|
+
payload["error"] = [
|
|
573
|
+
"domain": failure?.domain ?? "XplatCamera",
|
|
574
|
+
"code": failure?.code ?? -1,
|
|
575
|
+
"message": failure?.localizedDescription ?? "Movie recording did not complete",
|
|
576
|
+
]
|
|
577
|
+
}
|
|
578
|
+
self?.send(payload)
|
|
579
|
+
}
|
|
580
|
+
movieOutput.startRecording(to: url, recordingDelegate: recordingDelegate)
|
|
581
|
+
}
|
|
582
|
+
return nil
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
@objc public func stopRecording() {
|
|
586
|
+
movieOutput?.stopRecording()
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@objc public func recording() -> Bool {
|
|
590
|
+
movieOutput?.isRecording ?? false
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private let recordingDelegate = XplatRecordingDelegate()
|
|
594
|
+
|
|
595
|
+
/** JSON {width,height} of the configured device's active format. */
|
|
596
|
+
@objc public func previewSize() -> String {
|
|
597
|
+
guard let device = videoInput?.device else {
|
|
598
|
+
return "{}"
|
|
599
|
+
}
|
|
600
|
+
let dimensions = CMVideoFormatDescriptionGetDimensions(
|
|
601
|
+
device.activeFormat.formatDescription
|
|
602
|
+
)
|
|
603
|
+
return "{\"width\":\(Int(dimensions.width)),\"height\":\(Int(dimensions.height))}"
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
@objc public func dispose() {
|
|
607
|
+
guard !disposed else { return }
|
|
608
|
+
disposed = true
|
|
609
|
+
for observer in observers { NotificationCenter.default.removeObserver(observer) }
|
|
610
|
+
observers = []
|
|
611
|
+
previewLayer?.removeFromSuperlayer()
|
|
612
|
+
previewLayer = nil
|
|
613
|
+
previewView = nil
|
|
614
|
+
photoDelegate?.reply = nil
|
|
615
|
+
photoDelegate = nil
|
|
616
|
+
// Keep the session alive until stop completes. Releasing the claim sooner
|
|
617
|
+
// could admit another host while AVFoundation still holds the hardware.
|
|
618
|
+
queue.async { [self] in
|
|
619
|
+
session?.stopRunning()
|
|
620
|
+
for id in claimedCameraIds { CameraOwnership.release(id, owner: self) }
|
|
621
|
+
claimedCameraIds = []
|
|
622
|
+
session = nil
|
|
623
|
+
videoInput = nil
|
|
624
|
+
audioInput = nil
|
|
625
|
+
movieOutput = nil
|
|
626
|
+
photoOutput = nil
|
|
627
|
+
}
|
|
628
|
+
emit = nil
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
deinit {
|
|
632
|
+
for observer in observers { NotificationCenter.default.removeObserver(observer) }
|
|
633
|
+
let stoppedSession = session
|
|
634
|
+
// Explicit disposal handles claims. A live claim keeps no strong owner;
|
|
635
|
+
// synchronous fallback only runs if consumers omit disposal.
|
|
636
|
+
stoppedSession?.stopRunning()
|
|
637
|
+
for id in claimedCameraIds { CameraOwnership.release(id, owner: self) }
|
|
638
|
+
}
|
|
639
|
+
}
|