@octane-xplat/camera 0.14.0 → 0.16.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.
Files changed (83) hide show
  1. package/README.md +26 -0
  2. package/package.json +22 -16
  3. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
  4. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
  5. package/platforms/macos/src/CameraOwnership.swift +22 -0
  6. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  7. package/platforms/macos/src/XplatCameraHost.swift +139 -13
  8. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  9. package/src/CameraView.android.tsrx +8 -18
  10. package/src/CameraView.ios.tsrx +4 -17
  11. package/src/CameraView.macos.tsrx +4 -17
  12. package/src/CameraView.web.tsrx +11 -0
  13. package/src/CameraView.windows.tsrx +6 -18
  14. package/src/camera-permission.mobile.ts +18 -4
  15. package/src/camera-session.windows.ts +4 -1
  16. package/src/host-movie.web.ts +141 -26
  17. package/src/index.ts +1 -0
  18. package/src/index.web.ts +1 -0
  19. package/src/layout-child.ts +39 -18
  20. package/src/layout-child.windows.ts +5 -2
  21. package/src/preview-tap.ts +34 -0
  22. package/src/props.ts +8 -1
  23. package/src/session-backend.android.ts +42 -0
  24. package/src/session-backend.ios.ts +183 -17
  25. package/src/session-backend.linux.ts +22 -4
  26. package/src/session-backend.macos.ts +100 -17
  27. package/src/session-backend.ts +2 -0
  28. package/src/session-backend.web.ts +17 -0
  29. package/src/session-backend.windows.ts +88 -6
  30. package/src/session-core.ts +73 -9
  31. package/src/synthetic-session.ts +10 -0
  32. package/src/types.ts +28 -0
  33. package/types/props.d.ts +8 -1
  34. package/types/session-core.d.ts +11 -1
  35. package/types/synthetic-session.d.ts +4 -1
  36. package/types/types.d.ts +27 -0
  37. package/dist/linux/CameraView.web.tsrx.js +0 -337
  38. package/dist/linux/host-movie.web.js +0 -100
  39. package/dist/linux/index.js +0 -5
  40. package/dist/linux/layout-child.web.js +0 -29
  41. package/dist/linux/movie-metadata.web.js +0 -65
  42. package/dist/linux/octane-client-build.json +0 -8
  43. package/dist/linux/session-backend.linux.js +0 -479
  44. package/dist/linux/session-core.js +0 -368
  45. package/dist/linux/session.js +0 -29
  46. package/dist/linux/synthetic-session.js +0 -280
  47. package/dist/linux/types.js +0 -19
  48. package/dist/native/CameraView.android.js +0 -190
  49. package/dist/native/CameraView.ios.js +0 -154
  50. package/dist/native/CameraView.windows.js +0 -173
  51. package/dist/native/camera-permission.mobile.js +0 -45
  52. package/dist/native/camera-permission.windows.js +0 -43
  53. package/dist/native/camera-session.ios.js +0 -45
  54. package/dist/native/camera-session.windows.js +0 -78
  55. package/dist/native/cx.mobile.js +0 -9
  56. package/dist/native/cx.windows.js +0 -9
  57. package/dist/native/index.js +0 -5
  58. package/dist/native/layout-child.js +0 -53
  59. package/dist/native/layout-child.windows.js +0 -53
  60. package/dist/native/octane-client-build.json +0 -8
  61. package/dist/native/session-backend.android.js +0 -234
  62. package/dist/native/session-backend.ios.js +0 -682
  63. package/dist/native/session-backend.js +0 -55
  64. package/dist/native/session-backend.windows.js +0 -905
  65. package/dist/native/session-core.js +0 -368
  66. package/dist/native/session.js +0 -29
  67. package/dist/native/synthetic-session.js +0 -280
  68. package/dist/native/types.js +0 -19
  69. package/dist/web/CameraView.web.tsrx.js +0 -337
  70. package/dist/web/host-movie.web.js +0 -100
  71. package/dist/web/index.js +0 -5
  72. package/dist/web/layout-child.web.js +0 -29
  73. package/dist/web/movie-metadata.web.js +0 -65
  74. package/dist/web/movie-storage.web.js +0 -119
  75. package/dist/web/octane-client-build.json +0 -8
  76. package/dist/web/session-backend.web.js +0 -490
  77. package/dist/web/session-core.js +0 -368
  78. package/dist/web/session.js +0 -29
  79. package/dist/web/synthetic-session.js +0 -280
  80. package/dist/web/types.js +0 -19
  81. package/src/cx.macos.ts +0 -5
  82. package/src/cx.mobile.ts +0 -6
  83. 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.14.0",
3
+ "version": "0.16.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": "./dist/linux/index.js",
36
- "web": "./dist/web/index.js",
37
- "native": "./dist/native/index.js",
38
- "default": "./dist/native/index.js"
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.16.0",
47
+ "mediabunny": "1.61.3"
48
+ },
44
49
  "devDependencies": {
45
- "@nativescript-community/octane": "0.2.4",
50
+ "@nativescript-community/octane": "0.2.5",
46
51
  "@nativescript/core": "9.1.3",
52
+ "@octane-xplat/macos-renderer": "0.16.0",
47
53
  "@octanejs/vite-plugin": "0.1.61",
48
54
  "octane": "0.11.0",
49
- "tsrx-typegen": "0.2.2",
55
+ "tsrx-typegen": "0.3.1",
50
56
  "typescript": "5.9.3",
51
57
  "vite": "8.3.0",
52
- "vitest": "5.0.1"
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
- ["id": $0.uniqueID, "name": $0.localizedName, "facing": NSNull()]
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 audio = options["audio"] as? Bool ?? false
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 movieOutput == nil {
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
- detachPreview()
503
- session = nil
504
- videoInput = nil
505
- audioInput = nil
506
- movieOutput = nil
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
- dispose()
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
+ }