@octane-xplat/camera 0.14.0 → 0.15.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 +26 -0
- package/package.json +22 -16
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +139 -13
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +6 -2
- package/src/CameraView.ios.tsrx +4 -2
- package/src/CameraView.macos.tsrx +4 -2
- package/src/CameraView.web.tsrx +11 -0
- package/src/CameraView.windows.tsrx +4 -2
- package/src/camera-permission.mobile.ts +18 -4
- package/src/camera-session.windows.ts +4 -1
- package/src/host-movie.web.ts +141 -26
- package/src/index.ts +1 -0
- package/src/index.web.ts +1 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +8 -1
- package/src/session-backend.android.ts +42 -0
- package/src/session-backend.ios.ts +183 -17
- package/src/session-backend.linux.ts +22 -4
- package/src/session-backend.macos.ts +100 -17
- package/src/session-backend.ts +2 -0
- package/src/session-backend.web.ts +17 -0
- package/src/session-backend.windows.ts +88 -6
- package/src/session-core.ts +73 -9
- package/src/synthetic-session.ts +10 -0
- package/src/types.ts +28 -0
- package/types/props.d.ts +8 -1
- package/types/session-core.d.ts +11 -1
- package/types/synthetic-session.d.ts +4 -1
- package/types/types.d.ts +27 -0
- package/dist/linux/CameraView.web.tsrx.js +0 -337
- package/dist/linux/host-movie.web.js +0 -100
- package/dist/linux/index.js +0 -5
- package/dist/linux/layout-child.web.js +0 -29
- package/dist/linux/movie-metadata.web.js +0 -65
- package/dist/linux/octane-client-build.json +0 -8
- package/dist/linux/session-backend.linux.js +0 -479
- package/dist/linux/session-core.js +0 -368
- package/dist/linux/session.js +0 -29
- package/dist/linux/synthetic-session.js +0 -280
- package/dist/linux/types.js +0 -19
- package/dist/native/CameraView.android.js +0 -190
- package/dist/native/CameraView.ios.js +0 -154
- package/dist/native/CameraView.windows.js +0 -173
- package/dist/native/camera-permission.mobile.js +0 -45
- package/dist/native/camera-permission.windows.js +0 -43
- package/dist/native/camera-session.ios.js +0 -45
- package/dist/native/camera-session.windows.js +0 -78
- package/dist/native/cx.mobile.js +0 -9
- package/dist/native/cx.windows.js +0 -9
- package/dist/native/index.js +0 -5
- package/dist/native/layout-child.js +0 -53
- package/dist/native/layout-child.windows.js +0 -53
- package/dist/native/octane-client-build.json +0 -8
- package/dist/native/session-backend.android.js +0 -234
- package/dist/native/session-backend.ios.js +0 -682
- package/dist/native/session-backend.js +0 -55
- package/dist/native/session-backend.windows.js +0 -905
- package/dist/native/session-core.js +0 -368
- package/dist/native/session.js +0 -29
- package/dist/native/synthetic-session.js +0 -280
- package/dist/native/types.js +0 -19
- package/dist/web/CameraView.web.tsrx.js +0 -337
- package/dist/web/host-movie.web.js +0 -100
- package/dist/web/index.js +0 -5
- package/dist/web/layout-child.web.js +0 -29
- package/dist/web/movie-metadata.web.js +0 -65
- package/dist/web/movie-storage.web.js +0 -119
- package/dist/web/octane-client-build.json +0 -8
- package/dist/web/session-backend.web.js +0 -490
- package/dist/web/session-core.js +0 -368
- package/dist/web/session.js +0 -29
- package/dist/web/synthetic-session.js +0 -280
- package/dist/web/types.js +0 -19
- package/src/cx.macos.ts +0 -5
- package/src/cx.mobile.ts +0 -6
- package/src/cx.windows.ts +0 -6
package/README.md
CHANGED
|
@@ -49,6 +49,32 @@ export function Preview() {
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
## Tap-to-focus
|
|
53
|
+
|
|
54
|
+
`CameraView` reports where the user tapped as a normalized point —
|
|
55
|
+
`{x: 0, y: 0}` is the top-left corner of the preview, `{x: 1, y: 1}` the
|
|
56
|
+
bottom-right. Hand the point to a session's `focusAtPoint` to meter one
|
|
57
|
+
autofocus and auto-exposure pass there:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { CameraView, createCameraSession } from '@octane-xplat/camera'
|
|
61
|
+
|
|
62
|
+
const session = createCameraSession()
|
|
63
|
+
|
|
64
|
+
export function FocusablePreview() {
|
|
65
|
+
return <CameraView session={session} onTap={(point) => session.focusAtPoint(point)} />
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`onTap` is a pure notification — it never takes over the gesture, so
|
|
70
|
+
double-tap and pan recognizers layered over the preview keep working.
|
|
71
|
+
`focusAtPoint` needs a session (it drives the camera the preview
|
|
72
|
+
attaches) and quietly does nothing on cameras that cannot meter at a
|
|
73
|
+
point — front cameras are often fixed-focus. Check
|
|
74
|
+
`capabilities().focusPointOfInterest` and `exposurePointOfInterest`
|
|
75
|
+
before showing focus UI. Metering lands on iOS, Android, and macOS; the
|
|
76
|
+
tap event fires on iOS, Android, and web.
|
|
77
|
+
|
|
52
78
|
## Movie recording
|
|
53
79
|
|
|
54
80
|
`createCameraSession` creates a shared camera owner: the same session can
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/camera",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Live camera preview for Octane xplat — web getUserMedia + native capture (AVCapture / CameraX)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -23,33 +23,39 @@
|
|
|
23
23
|
"directory": "packages/camera"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"dist",
|
|
27
26
|
"types",
|
|
28
27
|
"src",
|
|
29
|
-
"platforms"
|
|
28
|
+
"platforms",
|
|
29
|
+
"!src/**/*.test.*",
|
|
30
|
+
"!src/**/*.spec.*"
|
|
30
31
|
],
|
|
31
32
|
"type": "module",
|
|
32
33
|
"exports": {
|
|
33
34
|
".": {
|
|
34
35
|
"types": "./types/index.d.ts",
|
|
35
|
-
"linux": "./
|
|
36
|
-
"web": "./
|
|
37
|
-
"native": "./
|
|
38
|
-
"default": "./
|
|
36
|
+
"linux": "./src/index.web.ts",
|
|
37
|
+
"web": "./src/index.web.ts",
|
|
38
|
+
"native": "./src/index.ts",
|
|
39
|
+
"default": "./src/index.ts"
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
43
44
|
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@octane-xplat/platform": "0.15.0",
|
|
47
|
+
"mediabunny": "1.61.3"
|
|
48
|
+
},
|
|
44
49
|
"devDependencies": {
|
|
45
|
-
"@nativescript-community/octane": "0.2.
|
|
50
|
+
"@nativescript-community/octane": "0.2.5",
|
|
46
51
|
"@nativescript/core": "9.1.3",
|
|
52
|
+
"@octane-xplat/macos-renderer": "0.15.0",
|
|
47
53
|
"@octanejs/vite-plugin": "0.1.61",
|
|
48
54
|
"octane": "0.11.0",
|
|
49
|
-
"tsrx-typegen": "0.
|
|
55
|
+
"tsrx-typegen": "0.3.0",
|
|
50
56
|
"typescript": "5.9.3",
|
|
51
57
|
"vite": "8.3.0",
|
|
52
|
-
"vitest": "
|
|
58
|
+
"vitest": "4.1.11"
|
|
53
59
|
},
|
|
54
60
|
"peerDependencies": {
|
|
55
61
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
@@ -73,12 +79,11 @@
|
|
|
73
79
|
"android": "7.0.0"
|
|
74
80
|
}
|
|
75
81
|
},
|
|
76
|
-
"dependencies": {
|
|
77
|
-
"@octane-xplat/platform": "0.14.0",
|
|
78
|
-
"mediabunny": "1.61.3"
|
|
79
|
-
},
|
|
80
82
|
"xplat": {
|
|
81
83
|
"macos": {
|
|
84
|
+
"swiftHeaderImports": [
|
|
85
|
+
"AppKit/AppKit.h"
|
|
86
|
+
],
|
|
82
87
|
"frameworks": [
|
|
83
88
|
"AVFoundation",
|
|
84
89
|
"CoreMedia",
|
|
@@ -87,11 +92,12 @@
|
|
|
87
92
|
}
|
|
88
93
|
},
|
|
89
94
|
"scripts": {
|
|
90
|
-
"build": "vite build && vite build --mode native && vite build --mode linux && pnpm typegen && node ../../scripts/check-native-dist.mjs",
|
|
91
95
|
"typegen": "tsc -p tsconfig.types.json",
|
|
92
96
|
"test:packed": "node tests/packed-consumer.mjs",
|
|
93
97
|
"pack:check": "tsrx-typegen --pack-check",
|
|
94
|
-
"test": "vitest run",
|
|
98
|
+
"test": "vitest run && node --test tests/*.test.mjs",
|
|
99
|
+
"test:macos-photo": "node tests/photo-native.macos.mjs",
|
|
100
|
+
"test:macos-photo-hardware": "node tests/photo-hardware.macos.mjs",
|
|
95
101
|
"test:web": "node tests/browser-conformance.web.mjs",
|
|
96
102
|
"test:webkitgtk": "node tests/webkitgtk-conformance.linux.mjs"
|
|
97
103
|
}
|
|
@@ -4,6 +4,7 @@ import android.content.Context;
|
|
|
4
4
|
import android.view.ViewGroup;
|
|
5
5
|
import android.widget.FrameLayout;
|
|
6
6
|
|
|
7
|
+
import androidx.camera.core.MeteringPoint;
|
|
7
8
|
import androidx.camera.core.Preview;
|
|
8
9
|
import androidx.camera.view.PreviewView;
|
|
9
10
|
import androidx.lifecycle.Lifecycle;
|
|
@@ -45,6 +46,14 @@ public final class CameraPreviewView extends FrameLayout implements LifecycleOwn
|
|
|
45
46
|
return previewView.getSurfaceProvider();
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** Converts a normalized view-space point (0-1, top-left origin) into a
|
|
50
|
+
* CameraX metering point. The PreviewView factory accounts for scale
|
|
51
|
+
* type, rotation, and front-camera mirroring. */
|
|
52
|
+
public MeteringPoint meteringPoint(float x, float y) {
|
|
53
|
+
return previewView.getMeteringPointFactory().createPoint(
|
|
54
|
+
x * previewView.getWidth(), y * previewView.getHeight());
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
public androidx.lifecycle.LiveData<PreviewView.StreamState> getStreamState() {
|
|
49
58
|
return previewView.getPreviewStreamState();
|
|
50
59
|
}
|
|
@@ -17,6 +17,8 @@ import androidx.camera.core.CameraInfo;
|
|
|
17
17
|
import androidx.camera.core.CameraSelector;
|
|
18
18
|
import androidx.camera.core.CameraState;
|
|
19
19
|
import androidx.camera.core.DynamicRange;
|
|
20
|
+
import androidx.camera.core.FocusMeteringAction;
|
|
21
|
+
import androidx.camera.core.MeteringPoint;
|
|
20
22
|
import androidx.camera.core.MirrorMode;
|
|
21
23
|
import androidx.camera.core.Preview;
|
|
22
24
|
import androidx.camera.lifecycle.ProcessCameraProvider;
|
|
@@ -38,6 +40,7 @@ import java.io.FileInputStream;
|
|
|
38
40
|
import java.io.FileOutputStream;
|
|
39
41
|
import java.util.Collections;
|
|
40
42
|
import java.util.UUID;
|
|
43
|
+
import java.util.concurrent.TimeUnit;
|
|
41
44
|
import org.json.JSONArray;
|
|
42
45
|
import org.json.JSONObject;
|
|
43
46
|
|
|
@@ -199,9 +202,37 @@ public final class CameraSessionBridge implements LifecycleOwner {
|
|
|
199
202
|
Size size = video.getResolutionInfo().getResolution();
|
|
200
203
|
profiles.put(new JSONObject().put("profile", "standard").put("width", size.getWidth()).put("height", size.getHeight()).put("frameRate", Math.round(sourceFrameRate * 100.0) / 100.0));
|
|
201
204
|
}
|
|
205
|
+
if (camera != null && host != null && host.getWidth() > 0) {
|
|
206
|
+
MeteringPoint center = host.meteringPoint(0.5f, 0.5f);
|
|
207
|
+
result.put("focusPointOfInterest", camera.getCameraInfo().isFocusMeteringSupported(
|
|
208
|
+
new FocusMeteringAction.Builder(center, FocusMeteringAction.FLAG_AF).build()));
|
|
209
|
+
result.put("exposurePointOfInterest", camera.getCameraInfo().isFocusMeteringSupported(
|
|
210
|
+
new FocusMeteringAction.Builder(center, FocusMeteringAction.FLAG_AE).build()));
|
|
211
|
+
}
|
|
202
212
|
return result.put("cameras", cameras).put("profiles", profiles).toString();
|
|
203
213
|
} catch (Exception cause) { return "{\"cameras\":[],\"profiles\":[]}"; }
|
|
204
214
|
}
|
|
215
|
+
/** Tap-to-focus: one AF+AE metering pass at a normalized preview point
|
|
216
|
+
* (0-1, top-left origin as the user sees it). Metering auto-cancels
|
|
217
|
+
* after three seconds rather than locking. Returns false when no
|
|
218
|
+
* camera is bound or the device cannot meter at a point. */
|
|
219
|
+
public boolean focusAtPoint(float x, float y) {
|
|
220
|
+
if (camera == null || host == null || detached || released || host.getWidth() <= 0) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
MeteringPoint point = host.meteringPoint(x, y);
|
|
225
|
+
FocusMeteringAction action = new FocusMeteringAction.Builder(
|
|
226
|
+
point, FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE)
|
|
227
|
+
.setAutoCancelDuration(3, TimeUnit.SECONDS)
|
|
228
|
+
.build();
|
|
229
|
+
if (!camera.getCameraInfo().isFocusMeteringSupported(action)) return false;
|
|
230
|
+
camera.getCameraControl().startFocusAndMetering(action);
|
|
231
|
+
return true;
|
|
232
|
+
} catch (Exception cause) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
205
236
|
private String orientation() {
|
|
206
237
|
// Display rotation is not a cardinal camera orientation on every device.
|
|
207
238
|
return "unspecified";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Shared by preview, movie and still-photo hosts. A busy source is never stolen.
|
|
4
|
+
enum CameraOwnership {
|
|
5
|
+
private static let lock = NSLock()
|
|
6
|
+
private static var owners: [String: ObjectIdentifier] = [:]
|
|
7
|
+
|
|
8
|
+
static func claim(_ id: String, owner: AnyObject) -> Bool {
|
|
9
|
+
lock.lock()
|
|
10
|
+
defer { lock.unlock() }
|
|
11
|
+
let token = ObjectIdentifier(owner)
|
|
12
|
+
guard owners[id] == nil || owners[id] == token else { return false }
|
|
13
|
+
owners[id] = token
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static func release(_ id: String, owner: AnyObject) {
|
|
18
|
+
lock.lock()
|
|
19
|
+
defer { lock.unlock() }
|
|
20
|
+
if owners[id] == ObjectIdentifier(owner) { owners.removeValue(forKey: id) }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Fences every asynchronous permission, preview and shutter result.
|
|
4
|
+
final class PhotoCaptureLifecycle {
|
|
5
|
+
enum Phase { case idle, preparing, preview, capturing, finished }
|
|
6
|
+
private(set) var phase = Phase.idle
|
|
7
|
+
private(set) var generation = 0
|
|
8
|
+
var finished: (() -> Void)?
|
|
9
|
+
|
|
10
|
+
func prepare() -> Int? {
|
|
11
|
+
guard phase == .idle else { return nil }
|
|
12
|
+
phase = .preparing
|
|
13
|
+
return generation
|
|
14
|
+
}
|
|
15
|
+
func ready(_ token: Int) -> Bool {
|
|
16
|
+
guard accepts(token), phase == .preparing else { return false }
|
|
17
|
+
phase = .preview
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
func capture() -> Int? {
|
|
21
|
+
guard phase == .preview else { return nil }
|
|
22
|
+
phase = .capturing
|
|
23
|
+
return generation
|
|
24
|
+
}
|
|
25
|
+
func accepts(_ token: Int) -> Bool { phase != .finished && token == generation }
|
|
26
|
+
@discardableResult func finish() -> Bool {
|
|
27
|
+
guard phase != .finished else { return false }
|
|
28
|
+
phase = .finished
|
|
29
|
+
generation += 1
|
|
30
|
+
finished?()
|
|
31
|
+
finished = nil
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -90,12 +90,26 @@ private final class XplatRecordingDelegate: NSObject, AVCaptureFileOutputRecordi
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
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
|
+
|
|
93
104
|
@objc(XplatCameraHost)
|
|
94
105
|
public final class XplatCameraHost: NSObject {
|
|
95
106
|
private var session: AVCaptureSession?
|
|
96
107
|
private var videoInput: AVCaptureDeviceInput?
|
|
97
108
|
private var audioInput: AVCaptureDeviceInput?
|
|
98
109
|
private var movieOutput: AVCaptureMovieFileOutput?
|
|
110
|
+
private var photoOutput: AVCapturePhotoOutput?
|
|
111
|
+
private var photoDelegate: XplatPhotoDelegate?
|
|
112
|
+
private var claimedCameraIds: Set<String> = []
|
|
99
113
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
100
114
|
private weak var previewView: NSView?
|
|
101
115
|
private var configuredCameraId: String?
|
|
@@ -190,7 +204,13 @@ public final class XplatCameraHost: NSObject {
|
|
|
190
204
|
|
|
191
205
|
@objc public func devices() -> NSString {
|
|
192
206
|
let list: [[String: Any]] = cameraDevices().map {
|
|
193
|
-
[
|
|
207
|
+
[
|
|
208
|
+
"id": $0.uniqueID,
|
|
209
|
+
"name": $0.localizedName,
|
|
210
|
+
"facing": NSNull(),
|
|
211
|
+
"focusPointOfInterest": $0.isFocusPointOfInterestSupported,
|
|
212
|
+
"exposurePointOfInterest": $0.isExposurePointOfInterestSupported,
|
|
213
|
+
]
|
|
194
214
|
}
|
|
195
215
|
guard
|
|
196
216
|
let data = try? JSONSerialization.data(withJSONObject: list),
|
|
@@ -291,11 +311,14 @@ public final class XplatCameraHost: NSObject {
|
|
|
291
311
|
}
|
|
292
312
|
|
|
293
313
|
@objc public func configure(_ options: NSDictionary) -> NSString? {
|
|
314
|
+
guard !disposed else { return "unavailable" }
|
|
294
315
|
guard let session = ensureSession() else { return "permissionDenied" }
|
|
295
316
|
var failure: String?
|
|
296
317
|
queue.sync {
|
|
318
|
+
guard !disposed else { failure = "unavailable"; return }
|
|
297
319
|
let cameraId = options["cameraId"] as? String
|
|
298
|
-
let
|
|
320
|
+
let photo = options["photo"] as? Bool ?? false
|
|
321
|
+
let audio = !photo && (options["audio"] as? Bool ?? false)
|
|
299
322
|
rotationAngle = (options["rotationAngle"] as? NSNumber)?.doubleValue
|
|
300
323
|
let devices = cameraDevices()
|
|
301
324
|
guard let device = cameraId != nil
|
|
@@ -306,6 +329,21 @@ public final class XplatCameraHost: NSObject {
|
|
|
306
329
|
return
|
|
307
330
|
}
|
|
308
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
|
+
}
|
|
309
347
|
session.beginConfiguration()
|
|
310
348
|
if videoInput == nil || videoInput?.device.uniqueID != device.uniqueID {
|
|
311
349
|
do {
|
|
@@ -316,6 +354,8 @@ public final class XplatCameraHost: NSObject {
|
|
|
316
354
|
videoInput = input
|
|
317
355
|
if session.canAddInput(input) {
|
|
318
356
|
session.addInput(input)
|
|
357
|
+
} else {
|
|
358
|
+
failure = "unavailable"
|
|
319
359
|
}
|
|
320
360
|
} catch {
|
|
321
361
|
session.commitConfiguration()
|
|
@@ -337,7 +377,16 @@ public final class XplatCameraHost: NSObject {
|
|
|
337
377
|
self.audioInput = nil
|
|
338
378
|
}
|
|
339
379
|
|
|
340
|
-
if
|
|
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 {
|
|
341
390
|
let output = AVCaptureMovieFileOutput()
|
|
342
391
|
if session.canAddOutput(output) {
|
|
343
392
|
session.addOutput(output)
|
|
@@ -394,13 +443,75 @@ public final class XplatCameraHost: NSObject {
|
|
|
394
443
|
applyRotation(layer.connection)
|
|
395
444
|
}
|
|
396
445
|
queue.async {
|
|
397
|
-
if !session.isRunning {
|
|
446
|
+
if !self.disposed && !session.isRunning {
|
|
398
447
|
session.startRunning()
|
|
399
448
|
}
|
|
400
449
|
}
|
|
401
450
|
return nil
|
|
402
451
|
}
|
|
403
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
|
+
|
|
404
515
|
@objc public func detachPreview() {
|
|
405
516
|
previewLayer?.removeFromSuperlayer()
|
|
406
517
|
previewLayer = nil
|
|
@@ -495,19 +606,34 @@ public final class XplatCameraHost: NSObject {
|
|
|
495
606
|
@objc public func dispose() {
|
|
496
607
|
guard !disposed else { return }
|
|
497
608
|
disposed = true
|
|
498
|
-
for observer in observers {
|
|
499
|
-
NotificationCenter.default.removeObserver(observer)
|
|
500
|
-
}
|
|
609
|
+
for observer in observers { NotificationCenter.default.removeObserver(observer) }
|
|
501
610
|
observers = []
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
}
|
|
507
628
|
emit = nil
|
|
508
629
|
}
|
|
509
630
|
|
|
510
631
|
deinit {
|
|
511
|
-
|
|
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) }
|
|
512
638
|
}
|
|
513
639
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// App-owned still-photo UI. No microphone input, movie output or Photos writes.
|
|
5
|
+
@objc(XplatPhotoCaptureSheet)
|
|
6
|
+
public final class XplatPhotoCaptureSheet: NSObject, NSWindowDelegate {
|
|
7
|
+
private let host = XplatCameraHost()
|
|
8
|
+
private let lifecycle = PhotoCaptureLifecycle()
|
|
9
|
+
private var sheet: NSWindow?
|
|
10
|
+
private weak var parent: NSWindow?
|
|
11
|
+
private var reply: ((String?) -> Void)?
|
|
12
|
+
private var timeout: DispatchWorkItem?
|
|
13
|
+
private var closeObserver: NSObjectProtocol?
|
|
14
|
+
private var preview: NSView?
|
|
15
|
+
private var captureButton: NSButton?
|
|
16
|
+
private var statusLabel: NSTextField?
|
|
17
|
+
private var options: NSDictionary = [:]
|
|
18
|
+
private var disposed = false
|
|
19
|
+
var operationTimeout: TimeInterval = 15
|
|
20
|
+
|
|
21
|
+
@objc public func permissionStatus() -> NSString { host.permissionStatus("camera") }
|
|
22
|
+
|
|
23
|
+
@objc public func present(_ options: NSDictionary, reply: @escaping (String?) -> Void) {
|
|
24
|
+
guard let parent = NSApp.keyWindow ?? NSApp.mainWindow else { reply(nil); return }
|
|
25
|
+
presentOnWindow(parent, options: options, reply: reply)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Explicit host window also allows deterministic AppKit lifecycle checks.
|
|
29
|
+
func presentOnWindow(_ parent: NSWindow, options: NSDictionary, reply: @escaping (String?) -> Void) {
|
|
30
|
+
guard !disposed, sheet == nil, parent.attachedSheet == nil,
|
|
31
|
+
!(options["saveToGallery"] as? Bool ?? false)
|
|
32
|
+
else { reply(nil); return }
|
|
33
|
+
self.options = options
|
|
34
|
+
self.reply = reply
|
|
35
|
+
self.parent = parent
|
|
36
|
+
let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
|
37
|
+
styleMask: [.titled, .closable], backing: .buffered, defer: false)
|
|
38
|
+
sheet.title = "Take a photo"
|
|
39
|
+
sheet.delegate = self
|
|
40
|
+
let content = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 480))
|
|
41
|
+
let preview = NSView(frame: NSRect(x: 16, y: 80, width: 608, height: 384))
|
|
42
|
+
preview.wantsLayer = true
|
|
43
|
+
preview.layer?.backgroundColor = NSColor.black.cgColor
|
|
44
|
+
content.addSubview(preview)
|
|
45
|
+
let status = NSTextField(labelWithString: "Choose Capture to start the camera.")
|
|
46
|
+
status.frame = NSRect(x: 16, y: 48, width: 440, height: 22)
|
|
47
|
+
content.addSubview(status)
|
|
48
|
+
let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPhoto))
|
|
49
|
+
cancel.keyEquivalent = "\u{1b}"
|
|
50
|
+
cancel.frame = NSRect(x: 432, y: 12, width: 92, height: 32)
|
|
51
|
+
content.addSubview(cancel)
|
|
52
|
+
let capture = NSButton(title: "Capture", target: self, action: #selector(capturePhoto))
|
|
53
|
+
capture.keyEquivalent = "\r"
|
|
54
|
+
capture.frame = NSRect(x: 528, y: 12, width: 96, height: 32)
|
|
55
|
+
content.addSubview(capture)
|
|
56
|
+
sheet.contentView = content
|
|
57
|
+
self.sheet = sheet
|
|
58
|
+
self.preview = preview
|
|
59
|
+
statusLabel = status
|
|
60
|
+
captureButton = capture
|
|
61
|
+
closeObserver = NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification,
|
|
62
|
+
object: parent, queue: .main) { [weak self] _ in self?.complete(nil) }
|
|
63
|
+
parent.beginSheet(sheet) { [weak self] _ in self?.complete(nil) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func deadline(_ token: Int) {
|
|
67
|
+
timeout?.cancel()
|
|
68
|
+
let work = DispatchWorkItem { [weak self] in
|
|
69
|
+
guard let self, lifecycle.accepts(token) else { return }
|
|
70
|
+
complete(nil)
|
|
71
|
+
}
|
|
72
|
+
timeout = work
|
|
73
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + operationTimeout, execute: work)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@objc private func capturePhoto() {
|
|
77
|
+
if let token = lifecycle.prepare() {
|
|
78
|
+
captureButton?.isEnabled = false
|
|
79
|
+
statusLabel?.stringValue = "Starting camera…"
|
|
80
|
+
deadline(token)
|
|
81
|
+
// Authorization happens only after the user presses Capture.
|
|
82
|
+
host.requestPermission("camera") { [weak self] status in
|
|
83
|
+
guard let self, lifecycle.accepts(token) else { return }
|
|
84
|
+
guard status == "granted" else { complete(nil); return }
|
|
85
|
+
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
86
|
+
let failure = self.host.configure(["photo": true, "audio": false])
|
|
87
|
+
DispatchQueue.main.async { [self] in
|
|
88
|
+
guard self.lifecycle.accepts(token) else { return }
|
|
89
|
+
guard failure == nil, let preview = self.preview else { self.complete(nil); return }
|
|
90
|
+
self.host.installObserver { [weak self] message in
|
|
91
|
+
guard let self, lifecycle.accepts(token),
|
|
92
|
+
let data = message.data(using: .utf8),
|
|
93
|
+
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
94
|
+
else { return }
|
|
95
|
+
if event["type"] as? String == "previewReady", event["ready"] as? Bool == true,
|
|
96
|
+
lifecycle.ready(token) {
|
|
97
|
+
timeout?.cancel()
|
|
98
|
+
captureButton?.isEnabled = true
|
|
99
|
+
statusLabel?.stringValue = "Frame your photo, then choose Capture."
|
|
100
|
+
} else if event["type"] as? String == "interrupted" ||
|
|
101
|
+
(event["type"] as? String == "availability" && event["available"] as? Bool == false) {
|
|
102
|
+
complete(nil)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if self.host.attachPreview(preview) != nil { self.complete(nil) }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
guard let token = lifecycle.capture() else { return }
|
|
112
|
+
captureButton?.isEnabled = false
|
|
113
|
+
statusLabel?.stringValue = "Taking photo…"
|
|
114
|
+
deadline(token)
|
|
115
|
+
if host.capturePhoto({ [weak self] data in
|
|
116
|
+
guard let self, lifecycle.accepts(token) else { return }
|
|
117
|
+
guard let data, let result = persist(data as Data) else { complete(nil); return }
|
|
118
|
+
complete(result)
|
|
119
|
+
}) != nil { complete(nil) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func persist(_ data: Data) -> String? {
|
|
123
|
+
guard let source = NSBitmapImageRep(data: data) else { return nil }
|
|
124
|
+
let width = (options["width"] as? NSNumber)?.doubleValue ?? Double(source.pixelsWide)
|
|
125
|
+
let height = (options["height"] as? NSNumber)?.doubleValue ?? Double(source.pixelsHigh)
|
|
126
|
+
guard width.isFinite, height.isFinite, width >= 1, height >= 1, width <= 16384, height <= 16384, width * height <= 64_000_000 else { return nil }
|
|
127
|
+
let keepAspectRatio = options["keepAspectRatio"] as? Bool ?? true
|
|
128
|
+
let scale = min(width / Double(source.pixelsWide), height / Double(source.pixelsHigh), 1)
|
|
129
|
+
let size = NSSize(width: keepAspectRatio ? Double(source.pixelsWide) * scale : width,
|
|
130
|
+
height: keepAspectRatio ? Double(source.pixelsHigh) * scale : height)
|
|
131
|
+
guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width),
|
|
132
|
+
pixelsHigh: Int(size.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
|
|
133
|
+
isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
|
|
134
|
+
let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return nil }
|
|
135
|
+
NSGraphicsContext.saveGraphicsState()
|
|
136
|
+
NSGraphicsContext.current = context
|
|
137
|
+
let image = NSImage(size: NSSize(width: source.pixelsWide, height: source.pixelsHigh))
|
|
138
|
+
image.addRepresentation(source)
|
|
139
|
+
image.draw(in: NSRect(origin: .zero, size: size))
|
|
140
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
141
|
+
guard let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else { return nil }
|
|
142
|
+
let name = "octane-capture-\(UUID().uuidString).jpg"
|
|
143
|
+
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
|
144
|
+
do {
|
|
145
|
+
let payload: [String: String] = ["name": name, "uri": url.path,
|
|
146
|
+
"dataUrl": "data:image/jpeg;base64," + jpeg.base64EncodedString()]
|
|
147
|
+
let message = try JSONSerialization.data(withJSONObject: payload)
|
|
148
|
+
try jpeg.write(to: url, options: .atomic)
|
|
149
|
+
return String(data: message, encoding: .utf8)
|
|
150
|
+
} catch {
|
|
151
|
+
try? FileManager.default.removeItem(at: url)
|
|
152
|
+
return nil
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@objc private func cancelPhoto() { complete(nil) }
|
|
157
|
+
public func windowShouldClose(_ sender: NSWindow) -> Bool { complete(nil); return false }
|
|
158
|
+
|
|
159
|
+
private func complete(_ result: String?) {
|
|
160
|
+
guard lifecycle.finish() else { return }
|
|
161
|
+
let callback = reply
|
|
162
|
+
reply = nil
|
|
163
|
+
dispose()
|
|
164
|
+
callback?(result)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@objc public func discard(_ path: String) {
|
|
168
|
+
let url = URL(fileURLWithPath: path).standardizedFileURL
|
|
169
|
+
guard url.deletingLastPathComponent() == FileManager.default.temporaryDirectory.standardizedFileURL,
|
|
170
|
+
url.lastPathComponent.hasPrefix("octane-capture-") else { return }
|
|
171
|
+
try? FileManager.default.removeItem(at: url)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@objc public func dispose() {
|
|
175
|
+
guard !disposed else { return }
|
|
176
|
+
disposed = true
|
|
177
|
+
timeout?.cancel()
|
|
178
|
+
timeout = nil
|
|
179
|
+
if let closeObserver { NotificationCenter.default.removeObserver(closeObserver) }
|
|
180
|
+
closeObserver = nil
|
|
181
|
+
host.dispose()
|
|
182
|
+
if let sheet { parent?.endSheet(sheet); sheet.orderOut(nil) }
|
|
183
|
+
sheet = nil
|
|
184
|
+
preview = nil
|
|
185
|
+
captureButton = nil
|
|
186
|
+
statusLabel = nil
|
|
187
|
+
// Disposal is also a cancellation, including an external owner teardown.
|
|
188
|
+
if lifecycle.finish() { let callback = reply; reply = nil; callback?(nil) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
deinit { dispose() }
|
|
192
|
+
}
|