@octane-xplat/camera 0.12.0 → 0.14.0
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/README.md +63 -3
- package/dist/linux/CameraView.web.tsrx.js +337 -0
- package/dist/linux/host-movie.web.js +100 -0
- package/dist/linux/index.js +5 -0
- package/dist/linux/layout-child.web.js +29 -0
- package/dist/linux/movie-metadata.web.js +65 -0
- package/dist/linux/octane-client-build.json +8 -0
- package/dist/linux/session-backend.linux.js +479 -0
- package/dist/linux/session-core.js +368 -0
- package/dist/linux/session.js +29 -0
- package/dist/linux/synthetic-session.js +280 -0
- package/dist/linux/types.js +19 -0
- package/dist/native/CameraView.android.js +25 -1
- package/dist/native/CameraView.ios.js +24 -1
- package/dist/native/CameraView.windows.js +173 -0
- package/dist/native/camera-permission.windows.js +43 -0
- package/dist/native/camera-session.windows.js +78 -0
- package/dist/native/cx.mobile.js +3 -2
- package/dist/native/cx.windows.js +9 -0
- package/dist/native/index.js +4 -1
- package/dist/native/layout-child.windows.js +53 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/session-backend.android.js +234 -0
- package/dist/native/session-backend.ios.js +682 -0
- package/dist/native/session-backend.js +55 -0
- package/dist/native/session-backend.windows.js +905 -0
- package/dist/native/session-core.js +368 -0
- package/dist/native/session.js +29 -0
- package/dist/native/synthetic-session.js +280 -0
- package/dist/native/types.js +19 -0
- package/dist/web/CameraView.web.tsrx.js +28 -8
- package/dist/web/host-movie.web.js +100 -0
- package/dist/web/index.js +4 -1
- package/dist/web/movie-metadata.web.js +65 -0
- package/dist/web/movie-storage.web.js +119 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/session-backend.web.js +490 -0
- package/dist/web/session-core.js +368 -0
- package/dist/web/session.js +29 -0
- package/dist/web/synthetic-session.js +280 -0
- package/dist/web/types.js +19 -0
- package/package.json +23 -5
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
- package/platforms/ios/Info.plist +2 -0
- package/platforms/macos/src/XplatCameraHost.swift +513 -0
- package/src/CameraView.android.tsrx +21 -1
- package/src/CameraView.ios.tsrx +28 -2
- package/src/CameraView.macos.tsrx +93 -0
- package/src/CameraView.web.tsrx +20 -2
- package/src/CameraView.windows.tsrx +124 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +123 -0
- package/src/cx.macos.ts +5 -0
- package/src/cx.mobile.ts +3 -2
- package/src/cx.windows.ts +6 -0
- package/src/host-movie.web.ts +180 -0
- package/src/index.ts +31 -0
- package/src/index.web.ts +31 -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/props.ts +7 -1
- package/src/session-backend.android.ts +397 -0
- package/src/session-backend.ios.ts +1092 -0
- package/src/session-backend.linux.ts +869 -0
- package/src/session-backend.macos.ts +624 -0
- package/src/session-backend.ts +57 -0
- package/src/session-backend.web.ts +874 -0
- package/src/session-backend.windows.ts +1517 -0
- package/src/session-core.ts +659 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +367 -0
- package/src/types.ts +329 -0
- package/types/index.d.ts +43 -0
- package/types/props.d.ts +7 -1
- package/types/session-core.d.ts +129 -0
- package/types/synthetic-session.d.ts +48 -0
- package/types/types.d.ts +263 -0
- package/src/CameraView.windows.ts +0 -5
|
@@ -0,0 +1,513 @@
|
|
|
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
|
+
@objc(XplatCameraHost)
|
|
94
|
+
public final class XplatCameraHost: NSObject {
|
|
95
|
+
private var session: AVCaptureSession?
|
|
96
|
+
private var videoInput: AVCaptureDeviceInput?
|
|
97
|
+
private var audioInput: AVCaptureDeviceInput?
|
|
98
|
+
private var movieOutput: AVCaptureMovieFileOutput?
|
|
99
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
100
|
+
private weak var previewView: NSView?
|
|
101
|
+
private var configuredCameraId: String?
|
|
102
|
+
private var configuredAudio = false
|
|
103
|
+
private var rotationAngle: Double?
|
|
104
|
+
private var emit: ((String) -> Void)?
|
|
105
|
+
private var observers: [NSObjectProtocol] = []
|
|
106
|
+
private var disposed = false
|
|
107
|
+
private var recordingPath: String?
|
|
108
|
+
private let queue = DispatchQueue(label: "org.octane.xplat.camera")
|
|
109
|
+
|
|
110
|
+
public override init() {
|
|
111
|
+
super.init()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func send(_ payload: [String: Any]) {
|
|
115
|
+
guard
|
|
116
|
+
let emit,
|
|
117
|
+
let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
118
|
+
let text = String(data: data, encoding: .utf8)
|
|
119
|
+
else {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
emit(text)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@objc public func installObserver(_ listener: @escaping (String) -> Void) {
|
|
127
|
+
emit = listener
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private func status(for mediaType: AVMediaType) -> String {
|
|
131
|
+
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
|
132
|
+
case .authorized:
|
|
133
|
+
return "granted"
|
|
134
|
+
case .denied:
|
|
135
|
+
return "denied"
|
|
136
|
+
case .restricted:
|
|
137
|
+
return "restricted"
|
|
138
|
+
case .notDetermined:
|
|
139
|
+
return "notDetermined"
|
|
140
|
+
@unknown default:
|
|
141
|
+
return "unknown"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func mediaKind(_ kind: String) -> AVMediaType {
|
|
146
|
+
kind == "microphone" ? .audio : .video
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private func declared(_ mediaType: AVMediaType) -> Bool {
|
|
150
|
+
let key = mediaType == .audio ? "NSMicrophoneUsageDescription" : "NSCameraUsageDescription"
|
|
151
|
+
return Bundle.main.object(forInfoDictionaryKey: key) != nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@objc public func permissionStatus(_ kind: String) -> NSString {
|
|
155
|
+
let mediaType = mediaKind(kind)
|
|
156
|
+
guard declared(mediaType) else { return "undeclared" }
|
|
157
|
+
return status(for: mediaType) as NSString
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@objc public func requestPermission(_ kind: String, reply: @escaping (String) -> Void) {
|
|
161
|
+
let mediaType = mediaKind(kind)
|
|
162
|
+
guard declared(mediaType) else {
|
|
163
|
+
reply("undeclared")
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
let current = status(for: mediaType)
|
|
167
|
+
guard current == "notDetermined" else {
|
|
168
|
+
reply(current)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
AVCaptureDevice.requestAccess(for: mediaType) { _ in
|
|
172
|
+
DispatchQueue.main.async { reply(self.status(for: mediaType)) }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private func cameraDevices() -> [AVCaptureDevice] {
|
|
177
|
+
let types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera, .externalUnknown]
|
|
178
|
+
var found = AVCaptureDevice.DiscoverySession(
|
|
179
|
+
deviceTypes: types,
|
|
180
|
+
mediaType: .video,
|
|
181
|
+
position: .unspecified
|
|
182
|
+
).devices
|
|
183
|
+
// The discovery session is authoritative; keep the legacy enumerator as
|
|
184
|
+
// a merge source for devices on older system builds.
|
|
185
|
+
for device in AVCaptureDevice.devices(for: .video) where !found.contains(device) {
|
|
186
|
+
found.append(device)
|
|
187
|
+
}
|
|
188
|
+
return found
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@objc public func devices() -> NSString {
|
|
192
|
+
let list: [[String: Any]] = cameraDevices().map {
|
|
193
|
+
["id": $0.uniqueID, "name": $0.localizedName, "facing": NSNull()]
|
|
194
|
+
}
|
|
195
|
+
guard
|
|
196
|
+
let data = try? JSONSerialization.data(withJSONObject: list),
|
|
197
|
+
let text = String(data: data, encoding: .utf8)
|
|
198
|
+
else {
|
|
199
|
+
return "[]"
|
|
200
|
+
}
|
|
201
|
+
return text as NSString
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@objc public func audioDeviceAvailable() -> Bool {
|
|
205
|
+
!AVCaptureDevice.devices(for: .audio).isEmpty
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private func ensureSession() -> AVCaptureSession? {
|
|
209
|
+
if let session {
|
|
210
|
+
return session
|
|
211
|
+
}
|
|
212
|
+
guard status(for: .video) == "granted" else {
|
|
213
|
+
return nil
|
|
214
|
+
}
|
|
215
|
+
let session = AVCaptureSession()
|
|
216
|
+
if session.canSetSessionPreset(.hd1280x720) {
|
|
217
|
+
session.sessionPreset = .hd1280x720
|
|
218
|
+
}
|
|
219
|
+
self.session = session
|
|
220
|
+
let names: [Notification.Name] = [
|
|
221
|
+
.AVCaptureSessionWasInterrupted,
|
|
222
|
+
.AVCaptureSessionInterruptionEnded,
|
|
223
|
+
.AVCaptureSessionRuntimeError,
|
|
224
|
+
.AVCaptureSessionDidStartRunning,
|
|
225
|
+
.AVCaptureSessionDidStopRunning,
|
|
226
|
+
]
|
|
227
|
+
for name in names {
|
|
228
|
+
observers.append(
|
|
229
|
+
NotificationCenter.default.addObserver(
|
|
230
|
+
forName: name,
|
|
231
|
+
object: session,
|
|
232
|
+
queue: .main
|
|
233
|
+
) { [weak self] note in
|
|
234
|
+
self?.handleSessionNotification(note)
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
for (name, available) in [
|
|
239
|
+
(NSNotification.Name.AVCaptureDeviceWasDisconnected, false),
|
|
240
|
+
(NSNotification.Name.AVCaptureDeviceWasConnected, true),
|
|
241
|
+
] {
|
|
242
|
+
observers.append(
|
|
243
|
+
NotificationCenter.default.addObserver(
|
|
244
|
+
forName: name,
|
|
245
|
+
object: nil,
|
|
246
|
+
queue: .main
|
|
247
|
+
) { [weak self] _ in
|
|
248
|
+
self?.send(["type": "availability", "available": available])
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
return session
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func handleSessionNotification(_ note: Notification) {
|
|
256
|
+
switch note.name {
|
|
257
|
+
case .AVCaptureSessionWasInterrupted:
|
|
258
|
+
// macOS publishes no interruption-reason keys — the cause stays
|
|
259
|
+
// honest 'unknown' rather than a guessed code.
|
|
260
|
+
send(["type": "interrupted", "cause": "unknown"])
|
|
261
|
+
case .AVCaptureSessionInterruptionEnded:
|
|
262
|
+
send(["type": "interruptionEnded"])
|
|
263
|
+
case .AVCaptureSessionRuntimeError:
|
|
264
|
+
send(["type": "availability", "available": false])
|
|
265
|
+
send(["type": "interrupted", "cause": "cameraInUse"])
|
|
266
|
+
case .AVCaptureSessionDidStartRunning:
|
|
267
|
+
send(["type": "previewReady", "ready": true])
|
|
268
|
+
case .AVCaptureSessionDidStopRunning:
|
|
269
|
+
send(["type": "previewReady", "ready": false])
|
|
270
|
+
default:
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Rotation-angle connections exist on macOS 14+; earlier hosts cannot
|
|
276
|
+
* lock a take's orientation. */
|
|
277
|
+
@objc public func supportsRotationAngles() -> Bool {
|
|
278
|
+
if #available(macOS 14.0, *) {
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func applyRotation(_ connection: AVCaptureConnection?) {
|
|
285
|
+
guard let connection, let rotationAngle else { return }
|
|
286
|
+
if #available(macOS 14.0, *),
|
|
287
|
+
connection.isVideoRotationAngleSupported(rotationAngle)
|
|
288
|
+
{
|
|
289
|
+
connection.videoRotationAngle = rotationAngle
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@objc public func configure(_ options: NSDictionary) -> NSString? {
|
|
294
|
+
guard let session = ensureSession() else { return "permissionDenied" }
|
|
295
|
+
var failure: String?
|
|
296
|
+
queue.sync {
|
|
297
|
+
let cameraId = options["cameraId"] as? String
|
|
298
|
+
let audio = options["audio"] as? Bool ?? false
|
|
299
|
+
rotationAngle = (options["rotationAngle"] as? NSNumber)?.doubleValue
|
|
300
|
+
let devices = cameraDevices()
|
|
301
|
+
guard let device = cameraId != nil
|
|
302
|
+
? devices.first(where: { $0.uniqueID == cameraId })
|
|
303
|
+
: devices.first ?? AVCaptureDevice.default(for: .video)
|
|
304
|
+
else {
|
|
305
|
+
failure = "unavailable"
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
session.beginConfiguration()
|
|
310
|
+
if videoInput == nil || videoInput?.device.uniqueID != device.uniqueID {
|
|
311
|
+
do {
|
|
312
|
+
let input = try AVCaptureDeviceInput(device: device)
|
|
313
|
+
if videoInput != nil {
|
|
314
|
+
session.removeInput(videoInput!)
|
|
315
|
+
}
|
|
316
|
+
videoInput = input
|
|
317
|
+
if session.canAddInput(input) {
|
|
318
|
+
session.addInput(input)
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
session.commitConfiguration()
|
|
322
|
+
failure = "unavailable"
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if audio && audioInput == nil {
|
|
328
|
+
if let microphone = AVCaptureDevice.default(for: .audio),
|
|
329
|
+
let input = try? AVCaptureDeviceInput(device: microphone),
|
|
330
|
+
session.canAddInput(input)
|
|
331
|
+
{
|
|
332
|
+
audioInput = input
|
|
333
|
+
session.addInput(input)
|
|
334
|
+
}
|
|
335
|
+
} else if !audio, let audioInput {
|
|
336
|
+
session.removeInput(audioInput)
|
|
337
|
+
self.audioInput = nil
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if movieOutput == nil {
|
|
341
|
+
let output = AVCaptureMovieFileOutput()
|
|
342
|
+
if session.canAddOutput(output) {
|
|
343
|
+
session.addOutput(output)
|
|
344
|
+
movieOutput = output
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
session.commitConfiguration()
|
|
348
|
+
if let rotationAngle {
|
|
349
|
+
let connection = movieOutput?.connection(with: .video)
|
|
350
|
+
let supported: Bool
|
|
351
|
+
if #available(macOS 14.0, *) {
|
|
352
|
+
supported = connection?.isVideoRotationAngleSupported(rotationAngle) ?? false
|
|
353
|
+
} else {
|
|
354
|
+
supported = false
|
|
355
|
+
}
|
|
356
|
+
if !supported {
|
|
357
|
+
failure = "unsupportedConfiguration"
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
configuredCameraId = device.uniqueID
|
|
362
|
+
configuredAudio = audio
|
|
363
|
+
applyRotation(movieOutput?.connection(with: .video))
|
|
364
|
+
applyRotation(previewLayer?.connection)
|
|
365
|
+
}
|
|
366
|
+
return failure.map { $0 as NSString }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@objc public func activeDeviceId() -> NSString? {
|
|
370
|
+
videoInput?.device.uniqueID as NSString?
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@objc public func audioInputAttached() -> Bool {
|
|
374
|
+
audioInput != nil
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
@objc public func recordedMs() -> Double {
|
|
378
|
+
guard let duration = movieOutput?.recordedDuration, duration.isValid else {
|
|
379
|
+
return 0
|
|
380
|
+
}
|
|
381
|
+
return CMTimeGetSeconds(duration) * 1000
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@objc public func attachPreview(_ view: NSView) -> NSString? {
|
|
385
|
+
guard let session = ensureSession() else { return "permissionDenied" }
|
|
386
|
+
previewView = view
|
|
387
|
+
previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
388
|
+
previewLayer?.videoGravity = .resizeAspectFill
|
|
389
|
+
view.wantsLayer = true
|
|
390
|
+
if let layer = previewLayer {
|
|
391
|
+
layer.frame = view.bounds
|
|
392
|
+
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
|
|
393
|
+
view.layer?.addSublayer(layer)
|
|
394
|
+
applyRotation(layer.connection)
|
|
395
|
+
}
|
|
396
|
+
queue.async {
|
|
397
|
+
if !session.isRunning {
|
|
398
|
+
session.startRunning()
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return nil
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
@objc public func detachPreview() {
|
|
405
|
+
previewLayer?.removeFromSuperlayer()
|
|
406
|
+
previewLayer = nil
|
|
407
|
+
previewView = nil
|
|
408
|
+
queue.async { [weak self] in
|
|
409
|
+
self?.session?.stopRunning()
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** App-private recordings root: ~/Library/Application Support/<bundle>/octane-camera */
|
|
414
|
+
@objc public func movieDirectory() -> NSString? {
|
|
415
|
+
guard let base = FileManager.default.urls(
|
|
416
|
+
for: .applicationSupportDirectory,
|
|
417
|
+
in: .userDomainMask
|
|
418
|
+
).first else {
|
|
419
|
+
return nil
|
|
420
|
+
}
|
|
421
|
+
let identifier = Bundle.main.bundleIdentifier ?? "org.octane.xplat"
|
|
422
|
+
let directory = base.appendingPathComponent(identifier).appendingPathComponent("octane-camera")
|
|
423
|
+
guard (try? FileManager.default.createDirectory(
|
|
424
|
+
at: directory,
|
|
425
|
+
withIntermediateDirectories: true
|
|
426
|
+
)) != nil else {
|
|
427
|
+
return nil
|
|
428
|
+
}
|
|
429
|
+
return directory.absoluteString as NSString
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@objc public func startRecording(_ path: String, maximumDurationMs: Double) -> NSString? {
|
|
433
|
+
guard session != nil, let movieOutput else { return "unavailable" }
|
|
434
|
+
queue.sync {
|
|
435
|
+
let url = URL(fileURLWithPath: path)
|
|
436
|
+
recordingPath = path
|
|
437
|
+
movieOutput.maxRecordedDuration =
|
|
438
|
+
maximumDurationMs > 0
|
|
439
|
+
? CMTime(seconds: maximumDurationMs / 1000.0, preferredTimescale: 1000)
|
|
440
|
+
: .invalid
|
|
441
|
+
recordingDelegate.onStart = { [weak self] in
|
|
442
|
+
self?.send([
|
|
443
|
+
"type": "recordingStarted",
|
|
444
|
+
"fileUrl": url.absoluteString,
|
|
445
|
+
"audio": self?.audioInput != nil,
|
|
446
|
+
])
|
|
447
|
+
}
|
|
448
|
+
recordingDelegate.onFinish = { [weak self] fileUrl, succeeded, limitReached, failure in
|
|
449
|
+
var payload: [String: Any] = [
|
|
450
|
+
"type": "recordingFinished",
|
|
451
|
+
"fileUrl": fileUrl.absoluteString,
|
|
452
|
+
"succeeded": succeeded,
|
|
453
|
+
"limitReached": limitReached,
|
|
454
|
+
]
|
|
455
|
+
if succeeded, FileManager.default.fileExists(atPath: fileUrl.path) {
|
|
456
|
+
payload["clip"] = movieClipInfo(fileUrl)
|
|
457
|
+
} else {
|
|
458
|
+
// Reclaim only the file this attempt created — admission
|
|
459
|
+
// never allows a pre-existing destination.
|
|
460
|
+
try? FileManager.default.removeItem(at: fileUrl)
|
|
461
|
+
payload["error"] = [
|
|
462
|
+
"domain": failure?.domain ?? "XplatCamera",
|
|
463
|
+
"code": failure?.code ?? -1,
|
|
464
|
+
"message": failure?.localizedDescription ?? "Movie recording did not complete",
|
|
465
|
+
]
|
|
466
|
+
}
|
|
467
|
+
self?.send(payload)
|
|
468
|
+
}
|
|
469
|
+
movieOutput.startRecording(to: url, recordingDelegate: recordingDelegate)
|
|
470
|
+
}
|
|
471
|
+
return nil
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
@objc public func stopRecording() {
|
|
475
|
+
movieOutput?.stopRecording()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
@objc public func recording() -> Bool {
|
|
479
|
+
movieOutput?.isRecording ?? false
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private let recordingDelegate = XplatRecordingDelegate()
|
|
483
|
+
|
|
484
|
+
/** JSON {width,height} of the configured device's active format. */
|
|
485
|
+
@objc public func previewSize() -> String {
|
|
486
|
+
guard let device = videoInput?.device else {
|
|
487
|
+
return "{}"
|
|
488
|
+
}
|
|
489
|
+
let dimensions = CMVideoFormatDescriptionGetDimensions(
|
|
490
|
+
device.activeFormat.formatDescription
|
|
491
|
+
)
|
|
492
|
+
return "{\"width\":\(Int(dimensions.width)),\"height\":\(Int(dimensions.height))}"
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
@objc public func dispose() {
|
|
496
|
+
guard !disposed else { return }
|
|
497
|
+
disposed = true
|
|
498
|
+
for observer in observers {
|
|
499
|
+
NotificationCenter.default.removeObserver(observer)
|
|
500
|
+
}
|
|
501
|
+
observers = []
|
|
502
|
+
detachPreview()
|
|
503
|
+
session = nil
|
|
504
|
+
videoInput = nil
|
|
505
|
+
audioInput = nil
|
|
506
|
+
movieOutput = nil
|
|
507
|
+
emit = nil
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
deinit {
|
|
511
|
+
dispose()
|
|
512
|
+
}
|
|
513
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Application, View } from '@nativescript/core'
|
|
|
4
4
|
import { registerElement } from '@nativescript-community/octane'
|
|
5
5
|
import type { Attributes } from '@nativescript-community/octane/intrinsics'
|
|
6
6
|
import type { CameraViewProps } from './props'
|
|
7
|
+
import { attachSessionPreview } from './session'
|
|
7
8
|
import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
|
|
8
9
|
import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
|
|
9
10
|
import { cx } from './cx.mobile'
|
|
@@ -44,6 +45,25 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
44
45
|
if (!active) return () => nativeView.setCameraActive(false)
|
|
45
46
|
|
|
46
47
|
const fail = (message: string) => props.onError?.({ message })
|
|
48
|
+
if (props.session) {
|
|
49
|
+
if (props.facing !== undefined) {
|
|
50
|
+
fail('camera session conflicts with facing')
|
|
51
|
+
return () => nativeView.setCameraActive(false)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const detach = attachSessionPreview(props.session, nativeView, {
|
|
56
|
+
onReady: props.onReady,
|
|
57
|
+
onError: (issue) => fail(issue.message),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return () => { detach(); nativeView.setCameraActive(false) }
|
|
61
|
+
} catch (error: any) {
|
|
62
|
+
fail(error?.message ?? 'camera session error')
|
|
63
|
+
return () => nativeView.setCameraActive(false)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
if (!cameraHardwarePresent()) {
|
|
48
68
|
fail('camera unsupported')
|
|
49
69
|
return () => nativeView.setCameraActive(false)
|
|
@@ -98,7 +118,7 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
98
118
|
} catch {}
|
|
99
119
|
}
|
|
100
120
|
}
|
|
101
|
-
}, [active, facing])
|
|
121
|
+
}, [active, facing, props.session])
|
|
102
122
|
|
|
103
123
|
useEffect(() => {
|
|
104
124
|
const view = host.current
|
package/src/CameraView.ios.tsrx
CHANGED
|
@@ -3,10 +3,13 @@ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
|
|
|
3
3
|
import type { CameraViewProps } from './props'
|
|
4
4
|
import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
|
|
5
5
|
import { startCameraPreview } from './camera-session.ios'
|
|
6
|
+
import { attachSessionPreview } from './session'
|
|
6
7
|
import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
|
|
7
8
|
import { cx } from './cx.mobile'
|
|
8
9
|
|
|
9
|
-
/** Live iOS preview backed by AVFoundation.
|
|
10
|
+
/** Live iOS preview backed by AVFoundation. With `session`, the preview
|
|
11
|
+
* attaches to the session-owned AVCaptureSession so movie recording can
|
|
12
|
+
* share it; without one the view owns its session as before. */
|
|
10
13
|
export function CameraView(props: CameraViewProps) @{
|
|
11
14
|
const host = useRef<any>(null)
|
|
12
15
|
const active = props.active !== false
|
|
@@ -24,6 +27,29 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
24
27
|
let stopPreview: (() => void) | undefined
|
|
25
28
|
const fail = (message: string) => props.onError?.({ message })
|
|
26
29
|
|
|
30
|
+
if (props.session) {
|
|
31
|
+
if (props.facing !== undefined) {
|
|
32
|
+
fail('camera session conflicts with facing')
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const detach = attachSessionPreview(props.session, view, {
|
|
38
|
+
onReady: () => {
|
|
39
|
+
if (!disposed) props.onReady?.()
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
disposed = true
|
|
45
|
+
detach()
|
|
46
|
+
}
|
|
47
|
+
} catch (error: any) {
|
|
48
|
+
fail(error?.message ?? 'camera session error')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
if (!cameraHardwarePresent()) {
|
|
28
54
|
fail('camera unsupported')
|
|
29
55
|
return
|
|
@@ -47,7 +73,7 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
47
73
|
disposed = true
|
|
48
74
|
stopPreview?.()
|
|
49
75
|
}
|
|
50
|
-
}, [active, facing])
|
|
76
|
+
}, [active, facing, props.session])
|
|
51
77
|
|
|
52
78
|
useEffect(() => {
|
|
53
79
|
const view = host.current
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
|
|
3
|
+
import type { CameraViewProps } from './props'
|
|
4
|
+
import { cameraHardwarePresent, ensureCameraPermission, startCameraPreview } from './camera-session.macos'
|
|
5
|
+
import { attachSessionPreview } from './session'
|
|
6
|
+
import { layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
|
|
7
|
+
import { cx } from './cx.macos'
|
|
8
|
+
|
|
9
|
+
/** Live AppKit preview backed by AVFoundation. With `session`, the preview
|
|
10
|
+
* attaches to the session-owned AVCaptureSession so movie recording can
|
|
11
|
+
* share it; without one the view owns its session as before. */
|
|
12
|
+
export function CameraView(props: CameraViewProps) @{
|
|
13
|
+
const host = useRef<any>(null)
|
|
14
|
+
const active = props.active !== false
|
|
15
|
+
const facing = props.facing ?? 'back'
|
|
16
|
+
useImperativeHandle(props.ref, () => ({ native: host.current }), [])
|
|
17
|
+
|
|
18
|
+
const bindHost = useCallback((view: any) => {
|
|
19
|
+
host.current = view
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const view = host.current
|
|
24
|
+
if (!view || !active) return
|
|
25
|
+
let disposed = false
|
|
26
|
+
let stopPreview: (() => void) | undefined
|
|
27
|
+
const fail = (message: string) => props.onError?.({ message })
|
|
28
|
+
|
|
29
|
+
if (props.session) {
|
|
30
|
+
if (props.facing !== undefined) {
|
|
31
|
+
fail('camera session conflicts with facing')
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const detach = attachSessionPreview(props.session, view, {
|
|
37
|
+
onReady: () => {
|
|
38
|
+
if (!disposed) props.onReady?.()
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
disposed = true
|
|
44
|
+
detach()
|
|
45
|
+
}
|
|
46
|
+
} catch (error: any) {
|
|
47
|
+
fail(error?.message ?? 'camera session error')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!cameraHardwarePresent()) {
|
|
53
|
+
fail('camera unsupported')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ensureCameraPermission().then((granted) => {
|
|
58
|
+
if (disposed) return
|
|
59
|
+
if (!granted) {
|
|
60
|
+
fail('camera permission denied')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
stopPreview = startCameraPreview(view, facing, props.onReady)
|
|
66
|
+
} catch (error: any) {
|
|
67
|
+
fail(error?.message ?? 'camera error')
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
disposed = true
|
|
73
|
+
stopPreview?.()
|
|
74
|
+
}
|
|
75
|
+
}, [active, facing, props.session])
|
|
76
|
+
|
|
77
|
+
<gridlayout
|
|
78
|
+
ref={bindHost}
|
|
79
|
+
id={props.id}
|
|
80
|
+
rows="*"
|
|
81
|
+
columns="*"
|
|
82
|
+
className={cx('vx-layers', 'vx-cameraview', props.className)}
|
|
83
|
+
style={{ backgroundColor: '#000000', ...props.style }}
|
|
84
|
+
{...layoutChildProps(props)}
|
|
85
|
+
accessible={props.accessible}
|
|
86
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
87
|
+
accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
|
|
88
|
+
accessibilityHint={props.accessibilityHint}
|
|
89
|
+
accessibilityValue={props.accessibilityValue}
|
|
90
|
+
accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
|
|
91
|
+
accessibilityLiveRegion={props.accessibilityLiveRegion as any}
|
|
92
|
+
/>
|
|
93
|
+
}
|