@octane-xplat/camera 0.13.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.
Files changed (81) hide show
  1. package/README.md +63 -3
  2. package/dist/linux/CameraView.web.tsrx.js +337 -0
  3. package/dist/linux/host-movie.web.js +100 -0
  4. package/dist/linux/index.js +5 -0
  5. package/dist/linux/layout-child.web.js +29 -0
  6. package/dist/linux/movie-metadata.web.js +65 -0
  7. package/dist/linux/octane-client-build.json +8 -0
  8. package/dist/linux/session-backend.linux.js +479 -0
  9. package/dist/linux/session-core.js +368 -0
  10. package/dist/linux/session.js +29 -0
  11. package/dist/linux/synthetic-session.js +280 -0
  12. package/dist/linux/types.js +19 -0
  13. package/dist/native/CameraView.android.js +25 -1
  14. package/dist/native/CameraView.ios.js +24 -1
  15. package/dist/native/CameraView.windows.js +173 -0
  16. package/dist/native/camera-permission.windows.js +43 -0
  17. package/dist/native/camera-session.windows.js +78 -0
  18. package/dist/native/cx.windows.js +9 -0
  19. package/dist/native/index.js +4 -1
  20. package/dist/native/layout-child.windows.js +53 -0
  21. package/dist/native/octane-client-build.json +1 -1
  22. package/dist/native/session-backend.android.js +234 -0
  23. package/dist/native/session-backend.ios.js +682 -0
  24. package/dist/native/session-backend.js +55 -0
  25. package/dist/native/session-backend.windows.js +905 -0
  26. package/dist/native/session-core.js +368 -0
  27. package/dist/native/session.js +29 -0
  28. package/dist/native/synthetic-session.js +280 -0
  29. package/dist/native/types.js +19 -0
  30. package/dist/web/CameraView.web.tsrx.js +27 -7
  31. package/dist/web/host-movie.web.js +100 -0
  32. package/dist/web/index.js +4 -1
  33. package/dist/web/movie-metadata.web.js +65 -0
  34. package/dist/web/movie-storage.web.js +119 -0
  35. package/dist/web/octane-client-build.json +1 -1
  36. package/dist/web/session-backend.web.js +490 -0
  37. package/dist/web/session-core.js +368 -0
  38. package/dist/web/session.js +29 -0
  39. package/dist/web/synthetic-session.js +280 -0
  40. package/dist/web/types.js +19 -0
  41. package/package.json +22 -4
  42. package/platforms/android/AndroidManifest.xml +2 -0
  43. package/platforms/android/include.gradle +1 -0
  44. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
  45. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
  46. package/platforms/ios/Info.plist +2 -0
  47. package/platforms/macos/src/XplatCameraHost.swift +513 -0
  48. package/src/CameraView.android.tsrx +21 -1
  49. package/src/CameraView.ios.tsrx +28 -2
  50. package/src/CameraView.macos.tsrx +93 -0
  51. package/src/CameraView.web.tsrx +20 -2
  52. package/src/CameraView.windows.tsrx +124 -0
  53. package/src/camera-permission.windows.ts +76 -0
  54. package/src/camera-session.macos.ts +75 -0
  55. package/src/camera-session.windows.ts +123 -0
  56. package/src/cx.macos.ts +5 -0
  57. package/src/cx.windows.ts +6 -0
  58. package/src/host-movie.web.ts +180 -0
  59. package/src/index.ts +31 -0
  60. package/src/index.web.ts +31 -0
  61. package/src/layout-child.windows.ts +79 -0
  62. package/src/movie-metadata.web.ts +84 -0
  63. package/src/movie-storage.web.ts +164 -0
  64. package/src/props.ts +7 -1
  65. package/src/session-backend.android.ts +397 -0
  66. package/src/session-backend.ios.ts +1092 -0
  67. package/src/session-backend.linux.ts +869 -0
  68. package/src/session-backend.macos.ts +624 -0
  69. package/src/session-backend.ts +57 -0
  70. package/src/session-backend.web.ts +874 -0
  71. package/src/session-backend.windows.ts +1517 -0
  72. package/src/session-core.ts +659 -0
  73. package/src/session.ts +37 -0
  74. package/src/synthetic-session.ts +367 -0
  75. package/src/types.ts +329 -0
  76. package/types/index.d.ts +43 -0
  77. package/types/props.d.ts +7 -1
  78. package/types/session-core.d.ts +129 -0
  79. package/types/synthetic-session.d.ts +48 -0
  80. package/types/types.d.ts +263 -0
  81. package/src/CameraView.windows.ts +0 -5
@@ -0,0 +1,368 @@
1
+ import { CameraCaptureError } from "./types.js";
2
+ //#region src/session-core.ts
3
+ var attemptCounter = 0;
4
+ var issue = (kind, operation, message, extra) => new CameraCaptureError({
5
+ kind,
6
+ operation,
7
+ message,
8
+ ...extra
9
+ });
10
+ /** The concrete session object wrapping a platform backend. Internal to
11
+ * the package; `CameraSession` is the public shape. */
12
+ var SharedCameraSession = class {
13
+ backend;
14
+ config;
15
+ listeners = /* @__PURE__ */ new Set();
16
+ state = "idle";
17
+ permission = {
18
+ camera: "unknown",
19
+ microphone: "unknown"
20
+ };
21
+ cameraAvailable = true;
22
+ previewAttached = false;
23
+ interrupted = false;
24
+ disposeRequested = false;
25
+ disposed = false;
26
+ lastCapabilities;
27
+ requestChains = /* @__PURE__ */ new Map();
28
+ attempt;
29
+ constructor(backend, config) {
30
+ this.backend = backend;
31
+ this.config = { ...config };
32
+ backend.setObserver({
33
+ interruption: (phase, reason) => this.handleInterruption(phase, reason),
34
+ availability: (available) => this.handleAvailability(available),
35
+ previewReady: () => {
36
+ this.emit({
37
+ type: "stateChanged",
38
+ snapshot: this.snapshot()
39
+ });
40
+ }
41
+ });
42
+ }
43
+ /** Package-internal: attach a preview host to this session. */
44
+ attach(host, listener) {
45
+ if (this.disposed || this.disposeRequested) throw issue("invalidState", "attachPreview", "CameraSession is disposed");
46
+ if (this.previewAttached) throw issue("invalidState", "attachPreview", "CameraSession already has a preview");
47
+ const detach = this.backend.attachPreview(host, {
48
+ onReady: listener?.onReady,
49
+ onError: (captureIssue) => {
50
+ listener?.onError?.(captureIssue);
51
+ }
52
+ });
53
+ this.previewAttached = true;
54
+ this.emit({
55
+ type: "stateChanged",
56
+ snapshot: this.snapshot()
57
+ });
58
+ return () => {
59
+ this.detachInternal(detach);
60
+ };
61
+ }
62
+ get attached() {
63
+ return this.previewAttached;
64
+ }
65
+ detachInternal(detach) {
66
+ if (!this.previewAttached) return;
67
+ this.previewAttached = false;
68
+ const attempt = this.attempt;
69
+ if (attempt && (attempt.state === "starting" || attempt.state === "recording")) this.requestStop("previewReleased");
70
+ detach();
71
+ this.backend.onPreviewDetached?.();
72
+ this.emit({
73
+ type: "stateChanged",
74
+ snapshot: this.snapshot()
75
+ });
76
+ }
77
+ emit(event) {
78
+ for (const listener of this.listeners) try {
79
+ listener(event);
80
+ } catch {}
81
+ }
82
+ setState(state) {
83
+ if (this.state === state) return;
84
+ this.state = state;
85
+ this.emit({
86
+ type: "stateChanged",
87
+ snapshot: this.snapshot()
88
+ });
89
+ }
90
+ snapshot() {
91
+ const attempt = this.attempt;
92
+ return {
93
+ state: this.state,
94
+ permission: { ...this.permission },
95
+ cameraAvailable: this.cameraAvailable,
96
+ previewAttached: this.previewAttached,
97
+ interrupted: this.interrupted,
98
+ attempt: attempt && attempt.state !== "settled" ? {
99
+ id: attempt.id,
100
+ state: attempt.state,
101
+ elapsedMs: this.backend.elapsedMs(),
102
+ configuration: attempt.configuration
103
+ } : void 0
104
+ };
105
+ }
106
+ subscribe(listener) {
107
+ this.listeners.add(listener);
108
+ return () => {
109
+ this.listeners.delete(listener);
110
+ };
111
+ }
112
+ async permissions() {
113
+ const [camera, microphone] = await Promise.all([this.backend.checkPermission("camera"), this.backend.checkPermission("microphone")]);
114
+ this.permission = {
115
+ camera,
116
+ microphone
117
+ };
118
+ this.emit({
119
+ type: "stateChanged",
120
+ snapshot: this.snapshot()
121
+ });
122
+ return { ...this.permission };
123
+ }
124
+ requestPermission(kind) {
125
+ const chain = (this.requestChains.get(kind) ?? Promise.resolve("unknown")).then(async () => {
126
+ if (this.disposed) return this.permission[kind];
127
+ const status = await this.backend.requestPermission(kind);
128
+ this.permission = {
129
+ ...this.permission,
130
+ [kind]: status
131
+ };
132
+ this.emit({
133
+ type: "stateChanged",
134
+ snapshot: this.snapshot()
135
+ });
136
+ return status;
137
+ });
138
+ this.requestChains.set(kind, chain);
139
+ return chain;
140
+ }
141
+ async capabilities() {
142
+ const capabilities = await this.backend.getCapabilities();
143
+ this.lastCapabilities = capabilities;
144
+ return capabilities;
145
+ }
146
+ async configure(next) {
147
+ if (this.disposed || this.disposeRequested) throw issue("invalidState", "configure", "CameraSession is disposed");
148
+ if (this.state !== "idle") throw issue("invalidState", "configure", `CameraSession cannot configure while ${this.state}`);
149
+ const merged = {
150
+ ...this.config,
151
+ ...next
152
+ };
153
+ const capabilities = await this.capabilities();
154
+ this.validateCameraSelection(merged.camera, capabilities);
155
+ this.config = merged;
156
+ this.backend.configure?.(merged);
157
+ }
158
+ validateCameraSelection(camera, capabilities) {
159
+ if (camera && typeof camera === "object" && "deviceId" in camera) {
160
+ if (!capabilities.cameras.some((entry) => entry.id === camera.deviceId)) throw issue("unsupportedConfiguration", "configure", `Camera device '${camera.deviceId}' is not available`);
161
+ } else if (camera === "front" || camera === "back") {
162
+ if (!capabilities.cameras.some((entry) => entry.facing === camera) && capabilities.cameras.length > 0) throw issue("unsupportedConfiguration", "configure", `No ${camera}-facing camera is available`);
163
+ }
164
+ }
165
+ requestStop(cause) {
166
+ const attempt = this.attempt;
167
+ if (!attempt || attempt.state === "finalizing" || attempt.state === "settled") return;
168
+ attempt.stopRequested = true;
169
+ attempt.cause ??= cause;
170
+ if (attempt.state === "recording") {
171
+ this.setState("finalizing");
172
+ attempt.state = "finalizing";
173
+ try {
174
+ this.backend.stopCapture();
175
+ } catch {}
176
+ }
177
+ }
178
+ handleInterruption(phase, reason) {
179
+ this.interrupted = phase === "began";
180
+ this.emit({
181
+ type: "interruption",
182
+ phase,
183
+ reason
184
+ });
185
+ if (phase === "began") {
186
+ const cause = reason === "background" ? "backgrounded" : reason === "cameraInUse" ? "cameraUnavailable" : "interrupted";
187
+ this.requestStop(cause);
188
+ }
189
+ this.emit({
190
+ type: "stateChanged",
191
+ snapshot: this.snapshot()
192
+ });
193
+ }
194
+ handleAvailability(available) {
195
+ if (this.cameraAvailable === available) return;
196
+ this.cameraAvailable = available;
197
+ this.emit({
198
+ type: "availability",
199
+ available
200
+ });
201
+ if (!available) this.requestStop("cameraUnavailable");
202
+ this.emit({
203
+ type: "stateChanged",
204
+ snapshot: this.snapshot()
205
+ });
206
+ }
207
+ startRecording(options = {}) {
208
+ const operation = "startRecording";
209
+ if (this.disposed || this.disposeRequested) throw issue("invalidState", operation, "CameraSession is disposed");
210
+ if (!this.backend.supported) throw issue("unsupportedPlatform", operation, "Movie recording is unsupported on this platform");
211
+ if (this.state !== "idle" || this.attempt) throw issue("busy", operation, `CameraSession is ${this.state}`);
212
+ if (options.maximumDurationMs !== void 0) {
213
+ const value = options.maximumDurationMs;
214
+ if (!Number.isFinite(value) || value <= 0) throw issue("invalidArgument", operation, "maximumDurationMs must be a positive finite number of milliseconds");
215
+ }
216
+ if (options.destinationFileUrl !== void 0 && !options.destinationFileUrl.startsWith("file://")) throw issue("invalidArgument", operation, "destinationFileUrl must be a local file:// URL");
217
+ if (options.orientation !== void 0 && options.orientation !== "unspecified" && this.lastCapabilities && !this.lastCapabilities.orientations.includes(options.orientation)) throw issue("unsupportedConfiguration", "startRecording", `Orientation '${options.orientation}' is not advertised by this camera`);
218
+ if (this.interrupted || !this.cameraAvailable || !this.backend.previewReady()) throw issue("unavailable", operation, this.interrupted ? "Camera session is interrupted" : !this.cameraAvailable ? "Camera is unavailable" : "An active, ready preview is required before recording");
219
+ this.backend.validateStart?.(options);
220
+ attemptCounter += 1;
221
+ const id = `take-${Date.now()}-${attemptCounter}`;
222
+ let resolveCompletion;
223
+ const completion = new Promise((resolve) => {
224
+ resolveCompletion = resolve;
225
+ });
226
+ const attempt = {
227
+ id,
228
+ state: "starting",
229
+ stopRequested: false,
230
+ resolve: resolveCompletion,
231
+ completion
232
+ };
233
+ this.attempt = attempt;
234
+ this.setState("starting");
235
+ const capabilities = this.lastCapabilities;
236
+ const sink = {
237
+ started: (configuration) => {
238
+ const current = this.attempt;
239
+ if (!current || current.id !== id || current.state === "settled") return;
240
+ current.configuration = configuration;
241
+ current.startedAt = Date.now();
242
+ if (current.state === "starting") {
243
+ current.state = "recording";
244
+ this.setState("recording");
245
+ this.emit({
246
+ type: "started",
247
+ attemptId: id,
248
+ configuration
249
+ });
250
+ if (options.maximumDurationMs !== void 0 && capabilities?.durationLimit === "bestEffort") {
251
+ const limit = options.maximumDurationMs;
252
+ const checkLimit = () => {
253
+ if (this.attempt?.id !== id || current.state !== "recording") return;
254
+ const remaining = limit - this.backend.elapsedMs();
255
+ if (remaining > 0) current.limitTimer = setTimeout(checkLimit, Math.min(remaining, 2147483647));
256
+ else this.requestStop("maximumDuration");
257
+ };
258
+ current.limitTimer = setTimeout(checkLimit, Math.min(limit, 2147483647));
259
+ }
260
+ if (current.stopRequested) this.requestStop(current.cause ?? "stopped");
261
+ }
262
+ },
263
+ finishing: (reason) => {
264
+ const current = this.attempt;
265
+ if (!current || current.id !== id || current.state === "settled") return;
266
+ current.cause ??= reason;
267
+ current.state = "finalizing";
268
+ this.setState("finalizing");
269
+ },
270
+ settled: (result) => {
271
+ const current = this.attempt;
272
+ if (!current || current.id !== id || current.state === "settled") return;
273
+ if (current.limitTimer) clearTimeout(current.limitTimer);
274
+ current.state = "settled";
275
+ const cause = current.cause ?? (result.kind === "clip" ? result.endReason : void 0);
276
+ const outcome = result.kind === "clip" ? {
277
+ kind: "clip",
278
+ clip: {
279
+ output: result.output,
280
+ mimeType: result.mimeType,
281
+ durationMs: result.durationMs,
282
+ width: result.width,
283
+ height: result.height,
284
+ orientation: result.orientation,
285
+ hasAudio: result.hasAudio
286
+ },
287
+ endReason: cause ?? (result.limitReached ? "maximumDuration" : "stopped"),
288
+ partial: cause !== void 0 && cause !== "stopped" && cause !== "maximumDuration",
289
+ warning: result.warning
290
+ } : {
291
+ kind: "failed",
292
+ endReason: cause ?? "captureError",
293
+ stage: result.stage,
294
+ error: new CameraCaptureError(result.issue)
295
+ };
296
+ this.attempt = void 0;
297
+ current.resolve(outcome);
298
+ this.emit({
299
+ type: "finished",
300
+ attemptId: id,
301
+ outcome
302
+ });
303
+ if (this.disposeRequested) this.finishDispose();
304
+ else this.setState("idle");
305
+ }
306
+ };
307
+ const request = {
308
+ audio: this.config.audio === true,
309
+ orientation: options.orientation,
310
+ maximumDurationMs: options.maximumDurationMs,
311
+ destinationPath: options.destinationFileUrl?.startsWith("file://") ? decodeURIComponent(options.destinationFileUrl.slice(7)) : void 0
312
+ };
313
+ try {
314
+ this.backend.startCapture(request, sink);
315
+ } catch (cause) {
316
+ sink.settled({
317
+ kind: "failed",
318
+ stage: "preparation",
319
+ issue: cause instanceof CameraCaptureError ? cause : {
320
+ kind: "captureFailed",
321
+ operation: "startRecording",
322
+ message: cause instanceof Error ? cause.message : String(cause),
323
+ cause
324
+ }
325
+ });
326
+ }
327
+ const backend = this.backend;
328
+ return {
329
+ id,
330
+ get state() {
331
+ return attempt.state;
332
+ },
333
+ get elapsedMs() {
334
+ return attempt.state === "recording" || attempt.state === "finalizing" ? backend.elapsedMs() : 0;
335
+ },
336
+ completion,
337
+ stop: () => {
338
+ if (this.attempt?.id === id) this.requestStop("stopped");
339
+ return completion;
340
+ }
341
+ };
342
+ }
343
+ openOutput(output) {
344
+ return this.backend.openOutput(output);
345
+ }
346
+ async dispose() {
347
+ if (this.disposed) return;
348
+ this.disposeRequested = true;
349
+ const attempt = this.attempt;
350
+ if (attempt && attempt.state !== "settled") {
351
+ this.requestStop("disposed");
352
+ await attempt.completion;
353
+ }
354
+ await this.finishDispose();
355
+ }
356
+ async finishDispose() {
357
+ if (this.disposed) return;
358
+ this.disposed = true;
359
+ this.backend.setObserver(void 0);
360
+ try {
361
+ await this.backend.release();
362
+ } finally {
363
+ this.setState("disposed");
364
+ }
365
+ }
366
+ };
367
+ //#endregion
368
+ export { SharedCameraSession };
@@ -0,0 +1,29 @@
1
+ import { CameraCaptureError } from "./types.js";
2
+ import { SharedCameraSession } from "./session-core.js";
3
+ import { createSessionBackend } from "./session-backend.web.js";
4
+ //#region src/session.ts
5
+ /** Create a shared camera owner: one acquisition shared between a
6
+ * `CameraView` preview (`session` prop) and at most one movie-recording
7
+ * attempt. Creating a session never prompts for permission or acquires
8
+ * hardware — both happen when a session-backed preview activates or a
9
+ * permission request runs.
10
+ *
11
+ * Recording support is delivered per platform; `capabilities()` reports
12
+ * honest `supported`/`available` state on every host. */
13
+ function createCameraSession(config = {}) {
14
+ return new SharedCameraSession(createSessionBackend(config), config);
15
+ }
16
+ /** Package-internal: attach `host` as the session's preview surface.
17
+ * Used by `CameraView` when its `session` prop is set; detaching the
18
+ * returned function requests stop on an in-flight attempt and releases
19
+ * camera ownership once finalization settles. */
20
+ function attachSessionPreview(session, host, listener) {
21
+ if (!(session instanceof SharedCameraSession)) throw new CameraCaptureError({
22
+ kind: "invalidArgument",
23
+ operation: "attachPreview",
24
+ message: "session was not created by createCameraSession"
25
+ });
26
+ return session.attach(host, listener);
27
+ }
28
+ //#endregion
29
+ export { attachSessionPreview, createCameraSession };
@@ -0,0 +1,280 @@
1
+ import { SharedCameraSession } from "./session-core.js";
2
+ //#region src/synthetic-session.ts
3
+ /** In-memory storage for the seam — a `fileUrl`-shaped map so outcomes
4
+ * exercise the same reopen path without touching a real filesystem. */
5
+ function createMemoryCameraStorage() {
6
+ const files = /* @__PURE__ */ new Map();
7
+ return {
8
+ files,
9
+ write: (path, bytes) => {
10
+ files.set(path, bytes);
11
+ },
12
+ read: (path) => files.get(path),
13
+ delete: (path) => {
14
+ files.delete(path);
15
+ },
16
+ exists: (path) => files.has(path)
17
+ };
18
+ }
19
+ var defaultScript = () => ({
20
+ permission: {
21
+ camera: "granted",
22
+ microphone: "granted"
23
+ },
24
+ available: true,
25
+ startDelayMs: 20,
26
+ clipDurationMs: 3e3,
27
+ produceMedia: true
28
+ });
29
+ var capabilities = {
30
+ supported: true,
31
+ available: true,
32
+ cameras: [{
33
+ id: "synthetic-back",
34
+ label: "Synthetic back camera",
35
+ facing: "back"
36
+ }, {
37
+ id: "synthetic-front",
38
+ label: "Synthetic front camera",
39
+ facing: "front"
40
+ }],
41
+ profiles: [{
42
+ profile: "standard",
43
+ width: 1280,
44
+ height: 720,
45
+ frameRate: 30
46
+ }],
47
+ audio: true,
48
+ orientations: [
49
+ "portrait",
50
+ "portraitUpsideDown",
51
+ "landscapeLeft",
52
+ "landscapeRight"
53
+ ],
54
+ durationLimit: "native",
55
+ elapsedTime: "nativeMedia",
56
+ output: {
57
+ mimeType: "video/quicktime",
58
+ container: "mov",
59
+ storage: "appPrivateFile",
60
+ destinationFileUrl: true
61
+ }
62
+ };
63
+ /** Create a `CameraSession` backed by the deterministic synthetic source.
64
+ * Marked for development and testing — never present this as live
65
+ * capture to users. */
66
+ function createSyntheticCameraSession(config = {}, script = {}, storage = createMemoryCameraStorage()) {
67
+ const controls = {
68
+ ...defaultScript(),
69
+ ...script
70
+ };
71
+ return new SharedCameraSession(createSyntheticBackend(controls, storage), config);
72
+ }
73
+ function createSyntheticBackend(script, storage) {
74
+ let observer;
75
+ let attached = false;
76
+ let sink;
77
+ let startedAt;
78
+ let timers = [];
79
+ let pendingRequest;
80
+ const later = (ms, fn) => {
81
+ timers.push(setTimeout(fn, ms));
82
+ };
83
+ const clearTimers = () => {
84
+ for (const timer of timers) clearTimeout(timer);
85
+ timers = [];
86
+ };
87
+ const settleClip = async (request, limitReached) => {
88
+ const path = request.destinationPath ?? `/synthetic/clip-${Date.now()}-${Math.random().toString(36).slice(2)}.mov`;
89
+ if (!script.produceMedia) {
90
+ sink?.settled({
91
+ kind: "failed",
92
+ stage: "capture",
93
+ issue: {
94
+ kind: "noMedia",
95
+ operation: "finalize",
96
+ message: "Synthetic source produced no media"
97
+ }
98
+ });
99
+ return;
100
+ }
101
+ if (script.failAt === "finalize") {
102
+ sink?.settled({
103
+ kind: "failed",
104
+ stage: "finalization",
105
+ issue: {
106
+ kind: "finalizationFailed",
107
+ operation: "finalize",
108
+ message: "Synthetic finalization failure"
109
+ }
110
+ });
111
+ return;
112
+ }
113
+ if (script.failAt === "storage") {
114
+ sink?.settled({
115
+ kind: "failed",
116
+ stage: "storage",
117
+ issue: {
118
+ kind: "storageFailed",
119
+ operation: "store",
120
+ message: "Synthetic storage failure"
121
+ }
122
+ });
123
+ return;
124
+ }
125
+ try {
126
+ await storage.write(path, new Uint8Array([
127
+ 120,
128
+ 112,
129
+ 108,
130
+ 97,
131
+ 116
132
+ ]));
133
+ } catch (cause) {
134
+ sink?.settled({
135
+ kind: "failed",
136
+ stage: "storage",
137
+ issue: {
138
+ kind: "storageFailed",
139
+ operation: "store",
140
+ message: cause instanceof Error ? cause.message : String(cause),
141
+ cause
142
+ }
143
+ });
144
+ return;
145
+ }
146
+ const settle = {
147
+ kind: "clip",
148
+ output: {
149
+ kind: "nativeFile",
150
+ resourceId: `synthetic:${path}`,
151
+ fileUrl: `file://${path}`,
152
+ synthetic: true
153
+ },
154
+ mimeType: "video/quicktime",
155
+ durationMs: script.clipDurationMs,
156
+ width: 1280,
157
+ height: 720,
158
+ orientation: request.orientation ?? "portrait",
159
+ hasAudio: request.audio,
160
+ limitReached
161
+ };
162
+ sink?.settled(settle);
163
+ };
164
+ return {
165
+ platform: "synthetic",
166
+ supported: true,
167
+ checkPermission: async (kind) => script.permission[kind],
168
+ requestPermission: async (kind) => script.permission[kind],
169
+ getCapabilities: async () => ({
170
+ ...capabilities,
171
+ available: script.available,
172
+ reason: script.available ? void 0 : "Synthetic camera is unavailable"
173
+ }),
174
+ previewReady: () => attached && script.available,
175
+ attachPreview: (_host, listener) => {
176
+ attached = true;
177
+ queueMicrotask(() => {
178
+ listener?.onReady?.();
179
+ observer?.previewReady(true);
180
+ });
181
+ return () => {
182
+ attached = false;
183
+ observer?.previewReady(false);
184
+ };
185
+ },
186
+ startCapture: (request, attemptSink) => {
187
+ sink = attemptSink;
188
+ pendingRequest = request;
189
+ if (!script.available) {
190
+ attemptSink.settled({
191
+ kind: "failed",
192
+ stage: "preparation",
193
+ issue: {
194
+ kind: "unavailable",
195
+ operation: "startRecording",
196
+ message: "Synthetic camera is unavailable"
197
+ }
198
+ });
199
+ return;
200
+ }
201
+ if (script.failAt === "start") {
202
+ later(script.startDelayMs, () => {
203
+ attemptSink.settled({
204
+ kind: "failed",
205
+ stage: "preparation",
206
+ issue: {
207
+ kind: "captureFailed",
208
+ operation: "startRecording",
209
+ message: "Synthetic start failure"
210
+ }
211
+ });
212
+ });
213
+ return;
214
+ }
215
+ later(script.startDelayMs, () => {
216
+ startedAt = Date.now();
217
+ attemptSink.started({
218
+ cameraId: "synthetic-back",
219
+ facing: "back",
220
+ audio: request.audio,
221
+ width: 1280,
222
+ height: 720,
223
+ frameRate: 30,
224
+ orientation: request.orientation ?? "portrait",
225
+ mimeType: "video/quicktime",
226
+ container: "mov"
227
+ });
228
+ if (script.interruption) later(script.interruption.afterMs, () => {
229
+ observer?.interruption("began", script.interruption?.reason);
230
+ if (script.interruption?.endsAfterMs !== void 0) later(script.interruption.endsAfterMs, () => {
231
+ observer?.interruption("ended", script.interruption?.reason);
232
+ });
233
+ });
234
+ if (request.maximumDurationMs !== void 0) later(request.maximumDurationMs, () => {
235
+ settleClip(request, true);
236
+ });
237
+ if (script.failAt === "capture") later(50, () => {
238
+ attemptSink.settled({
239
+ kind: "failed",
240
+ stage: "capture",
241
+ issue: {
242
+ kind: "captureFailed",
243
+ operation: "record",
244
+ message: "Synthetic capture failure"
245
+ }
246
+ });
247
+ });
248
+ });
249
+ },
250
+ stopCapture: () => {
251
+ const request = pendingRequest;
252
+ later(5, () => {
253
+ if (request) settleClip(request, false);
254
+ });
255
+ },
256
+ elapsedMs: () => startedAt === void 0 ? 0 : Date.now() - startedAt,
257
+ openOutput: async (output) => {
258
+ const path = output.kind === "nativeFile" ? output.fileUrl.slice(7) : "";
259
+ if (!(storage.exists ? await storage.exists(path) : !!await storage.read(path))) throw new Error(`Synthetic clip is missing at ${output.resourceId}`);
260
+ return {
261
+ url: output.kind === "nativeFile" ? output.fileUrl : output.resourceId,
262
+ fileUrl: output.kind === "nativeFile" ? output.fileUrl : void 0,
263
+ release: () => {}
264
+ };
265
+ },
266
+ release: async () => {
267
+ attached = false;
268
+ clearTimers();
269
+ sink = void 0;
270
+ startedAt = void 0;
271
+ pendingRequest = void 0;
272
+ },
273
+ onPreviewDetached: () => {},
274
+ setObserver: (next) => {
275
+ observer = next;
276
+ }
277
+ };
278
+ }
279
+ //#endregion
280
+ export { createMemoryCameraStorage, createSyntheticCameraSession };
@@ -0,0 +1,19 @@
1
+ //#region src/types.ts
2
+ /** Error thrown or settled by the session API. Satisfies `CaptureIssue`
3
+ * so `error.kind` drives control flow without unwrapping. */
4
+ var CameraCaptureError = class extends Error {
5
+ kind;
6
+ operation;
7
+ permission;
8
+ cause;
9
+ constructor(issue) {
10
+ super(issue.message);
11
+ this.name = "CameraCaptureError";
12
+ this.kind = issue.kind;
13
+ this.operation = issue.operation;
14
+ this.permission = issue.permission;
15
+ this.cause = issue.cause;
16
+ }
17
+ };
18
+ //#endregion
19
+ export { CameraCaptureError };