@octane-xplat/camera 0.13.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 +89 -3
- package/package.json +35 -11
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +2 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +639 -0
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +27 -3
- package/src/CameraView.ios.tsrx +32 -4
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.web.tsrx +31 -2
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +18 -4
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +126 -0
- package/src/host-movie.web.ts +295 -0
- package/src/index.ts +32 -0
- package/src/index.web.ts +32 -0
- package/src/layout-child.windows.ts +79 -0
- package/src/movie-metadata.web.ts +84 -0
- package/src/movie-storage.web.ts +164 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +14 -1
- package/src/session-backend.android.ts +439 -0
- package/src/session-backend.ios.ts +1258 -0
- package/src/session-backend.linux.ts +887 -0
- package/src/session-backend.macos.ts +707 -0
- package/src/session-backend.ts +59 -0
- package/src/session-backend.web.ts +891 -0
- package/src/session-backend.windows.ts +1599 -0
- package/src/session-core.ts +723 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +377 -0
- package/src/types.ts +357 -0
- package/types/index.d.ts +43 -0
- package/types/props.d.ts +14 -1
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- package/types/types.d.ts +290 -0
- package/dist/native/CameraView.android.js +0 -166
- package/dist/native/CameraView.ios.js +0 -131
- package/dist/native/camera-permission.mobile.js +0 -45
- package/dist/native/camera-session.ios.js +0 -45
- package/dist/native/cx.mobile.js +0 -9
- package/dist/native/index.js +0 -2
- package/dist/native/layout-child.js +0 -53
- package/dist/native/octane-client-build.json +0 -8
- package/dist/web/CameraView.web.tsrx.js +0 -317
- package/dist/web/index.js +0 -2
- package/dist/web/layout-child.web.js +0 -29
- package/dist/web/octane-client-build.json +0 -8
- package/src/CameraView.windows.ts +0 -5
- package/src/cx.mobile.ts +0 -6
package/src/session.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SharedCameraSession } from './session-core'
|
|
2
|
+
import type { BackendPreviewListener } from './session-core'
|
|
3
|
+
import { createSessionBackend } from './session-backend'
|
|
4
|
+
import { CameraCaptureError } from './types'
|
|
5
|
+
import type { CameraSession, CameraSessionConfig } from './types'
|
|
6
|
+
|
|
7
|
+
/** Create a shared camera owner: one acquisition shared between a
|
|
8
|
+
* `CameraView` preview (`session` prop) and at most one movie-recording
|
|
9
|
+
* attempt. Creating a session never prompts for permission or acquires
|
|
10
|
+
* hardware — both happen when a session-backed preview activates or a
|
|
11
|
+
* permission request runs.
|
|
12
|
+
*
|
|
13
|
+
* Recording support is delivered per platform; `capabilities()` reports
|
|
14
|
+
* honest `supported`/`available` state on every host. */
|
|
15
|
+
export function createCameraSession(config: CameraSessionConfig = {}): CameraSession {
|
|
16
|
+
return new SharedCameraSession(createSessionBackend(config), config)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Package-internal: attach `host` as the session's preview surface.
|
|
20
|
+
* Used by `CameraView` when its `session` prop is set; detaching the
|
|
21
|
+
* returned function requests stop on an in-flight attempt and releases
|
|
22
|
+
* camera ownership once finalization settles. */
|
|
23
|
+
export function attachSessionPreview(
|
|
24
|
+
session: CameraSession,
|
|
25
|
+
host: unknown,
|
|
26
|
+
listener?: BackendPreviewListener,
|
|
27
|
+
): () => void {
|
|
28
|
+
if (!(session instanceof SharedCameraSession)) {
|
|
29
|
+
throw new CameraCaptureError({
|
|
30
|
+
kind: 'invalidArgument',
|
|
31
|
+
operation: 'attachPreview',
|
|
32
|
+
message: 'session was not created by createCameraSession',
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return session.attach(host, listener)
|
|
37
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { SharedCameraSession } from './session-core'
|
|
2
|
+
import type {
|
|
3
|
+
BackendCaptureRequest,
|
|
4
|
+
BackendCaptureSink,
|
|
5
|
+
BackendObserver,
|
|
6
|
+
BackendSettle,
|
|
7
|
+
CameraSessionBackend,
|
|
8
|
+
} from './session-core'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CameraInterruptionReason,
|
|
12
|
+
CameraPermissionStatus,
|
|
13
|
+
CameraPreviewPoint,
|
|
14
|
+
CameraSession,
|
|
15
|
+
CameraSessionCapabilities,
|
|
16
|
+
CameraSessionConfig,
|
|
17
|
+
MovieOutput,
|
|
18
|
+
} from './types'
|
|
19
|
+
|
|
20
|
+
/** Development/testing seam — a deterministic camera source that drives
|
|
21
|
+
* the shared session state machine without hardware. NOT a production
|
|
22
|
+
* recording source: outcomes carry `synthetic: true` on their output so
|
|
23
|
+
* seam clips stay distinguishable from real capture.
|
|
24
|
+
*
|
|
25
|
+
* `script` is read live at each transition — mutate fields between
|
|
26
|
+
* operations to script permissions, delayed starts, interruptions,
|
|
27
|
+
* partial completions, and failures.
|
|
28
|
+
*/
|
|
29
|
+
export interface SyntheticCameraScript {
|
|
30
|
+
permission: {
|
|
31
|
+
camera: CameraPermissionStatus
|
|
32
|
+
microphone: CameraPermissionStatus
|
|
33
|
+
}
|
|
34
|
+
/** Camera hardware availability observation. */
|
|
35
|
+
available: boolean
|
|
36
|
+
/** Delay before `started` reports; models slow native activation. */
|
|
37
|
+
startDelayMs: number
|
|
38
|
+
/** Finalized clip duration reported by the seam. */
|
|
39
|
+
clipDurationMs: number
|
|
40
|
+
/** Produce a usable clip on stop; false settles `noMedia`. */
|
|
41
|
+
produceMedia: boolean
|
|
42
|
+
/** When set, the attempt settles failed at this stage. */
|
|
43
|
+
failAt?: 'start' | 'capture' | 'finalize' | 'storage'
|
|
44
|
+
/** Emit an interruption after this many ms of recording; the take
|
|
45
|
+
* ends with the mapped end reason and a partial clip. */
|
|
46
|
+
interruption?: {
|
|
47
|
+
afterMs: number
|
|
48
|
+
reason: CameraInterruptionReason
|
|
49
|
+
endsAfterMs?: number
|
|
50
|
+
}
|
|
51
|
+
/** When set, each `focusAtPoint` call pushes its normalized point
|
|
52
|
+
* here so a spec can observe metering requests. */
|
|
53
|
+
focusRequests?: CameraPreviewPoint[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SyntheticCameraStorage {
|
|
57
|
+
write(path: string, bytes: Uint8Array): void | Promise<void>
|
|
58
|
+
read(path: string): Uint8Array | undefined | Promise<Uint8Array | undefined>
|
|
59
|
+
delete?(path: string): void | Promise<void>
|
|
60
|
+
exists?(path: string): boolean | Promise<boolean>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** In-memory storage for the seam — a `fileUrl`-shaped map so outcomes
|
|
64
|
+
* exercise the same reopen path without touching a real filesystem. */
|
|
65
|
+
export function createMemoryCameraStorage(): SyntheticCameraStorage & {
|
|
66
|
+
files: Map<string, Uint8Array>
|
|
67
|
+
} {
|
|
68
|
+
const files = new Map<string, Uint8Array>()
|
|
69
|
+
return {
|
|
70
|
+
files,
|
|
71
|
+
write: (path, bytes) => {
|
|
72
|
+
files.set(path, bytes)
|
|
73
|
+
},
|
|
74
|
+
read: (path) => files.get(path),
|
|
75
|
+
delete: (path) => {
|
|
76
|
+
files.delete(path)
|
|
77
|
+
},
|
|
78
|
+
exists: (path) => files.has(path),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const defaultScript = (): SyntheticCameraScript => ({
|
|
83
|
+
permission: { camera: 'granted', microphone: 'granted' },
|
|
84
|
+
available: true,
|
|
85
|
+
startDelayMs: 20,
|
|
86
|
+
clipDurationMs: 3000,
|
|
87
|
+
produceMedia: true,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const capabilities: CameraSessionCapabilities = {
|
|
91
|
+
supported: true,
|
|
92
|
+
available: true,
|
|
93
|
+
cameras: [
|
|
94
|
+
{ id: 'synthetic-back', label: 'Synthetic back camera', facing: 'back' },
|
|
95
|
+
{ id: 'synthetic-front', label: 'Synthetic front camera', facing: 'front' },
|
|
96
|
+
],
|
|
97
|
+
profiles: [{ profile: 'standard', width: 1280, height: 720, frameRate: 30 }],
|
|
98
|
+
audio: true,
|
|
99
|
+
// The seam accepts metering points so tests exercise the supported path.
|
|
100
|
+
focusPointOfInterest: true,
|
|
101
|
+
exposurePointOfInterest: true,
|
|
102
|
+
orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
|
|
103
|
+
durationLimit: 'native',
|
|
104
|
+
elapsedTime: 'nativeMedia',
|
|
105
|
+
output: {
|
|
106
|
+
mimeType: 'video/quicktime',
|
|
107
|
+
container: 'mov',
|
|
108
|
+
storage: 'appPrivateFile',
|
|
109
|
+
destinationFileUrl: true,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Create a `CameraSession` backed by the deterministic synthetic source.
|
|
114
|
+
* Marked for development and testing — never present this as live
|
|
115
|
+
* capture to users. */
|
|
116
|
+
export function createSyntheticCameraSession(
|
|
117
|
+
config: CameraSessionConfig = {},
|
|
118
|
+
script: Partial<SyntheticCameraScript> = {},
|
|
119
|
+
storage: SyntheticCameraStorage = createMemoryCameraStorage(),
|
|
120
|
+
): CameraSession {
|
|
121
|
+
const controls: SyntheticCameraScript = { ...defaultScript(), ...script }
|
|
122
|
+
return new SharedCameraSession(createSyntheticBackend(controls, storage), config)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createSyntheticBackend(
|
|
126
|
+
script: SyntheticCameraScript,
|
|
127
|
+
storage: SyntheticCameraStorage,
|
|
128
|
+
): CameraSessionBackend {
|
|
129
|
+
let observer: BackendObserver | undefined
|
|
130
|
+
let attached = false
|
|
131
|
+
let sink: BackendCaptureSink | undefined
|
|
132
|
+
let startedAt: number | undefined
|
|
133
|
+
let timers: ReturnType<typeof setTimeout>[] = []
|
|
134
|
+
let pendingRequest: BackendCaptureRequest | undefined
|
|
135
|
+
|
|
136
|
+
const later = (ms: number, fn: () => void) => {
|
|
137
|
+
timers.push(setTimeout(fn, ms))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const clearTimers = () => {
|
|
141
|
+
for (const timer of timers) {
|
|
142
|
+
clearTimeout(timer)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
timers = []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const settleClip = async (request: BackendCaptureRequest, limitReached: boolean) => {
|
|
149
|
+
const path =
|
|
150
|
+
request.destinationPath ??
|
|
151
|
+
`/synthetic/clip-${Date.now()}-${Math.random().toString(36).slice(2)}.mov`
|
|
152
|
+
|
|
153
|
+
if (!script.produceMedia) {
|
|
154
|
+
sink?.settled({
|
|
155
|
+
kind: 'failed',
|
|
156
|
+
stage: 'capture',
|
|
157
|
+
issue: {
|
|
158
|
+
kind: 'noMedia',
|
|
159
|
+
operation: 'finalize',
|
|
160
|
+
message: 'Synthetic source produced no media',
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (script.failAt === 'finalize') {
|
|
168
|
+
sink?.settled({
|
|
169
|
+
kind: 'failed',
|
|
170
|
+
stage: 'finalization',
|
|
171
|
+
issue: {
|
|
172
|
+
kind: 'finalizationFailed',
|
|
173
|
+
operation: 'finalize',
|
|
174
|
+
message: 'Synthetic finalization failure',
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (script.failAt === 'storage') {
|
|
182
|
+
sink?.settled({
|
|
183
|
+
kind: 'failed',
|
|
184
|
+
stage: 'storage',
|
|
185
|
+
issue: {
|
|
186
|
+
kind: 'storageFailed',
|
|
187
|
+
operation: 'store',
|
|
188
|
+
message: 'Synthetic storage failure',
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// Not playable media — seam bytes stand in for a fixture. The
|
|
197
|
+
// output is marked `synthetic` so nothing mistakes it for real
|
|
198
|
+
// capture.
|
|
199
|
+
await storage.write(path, new Uint8Array([0x78, 0x70, 0x6c, 0x61, 0x74]))
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
sink?.settled({
|
|
202
|
+
kind: 'failed',
|
|
203
|
+
stage: 'storage',
|
|
204
|
+
issue: {
|
|
205
|
+
kind: 'storageFailed',
|
|
206
|
+
operation: 'store',
|
|
207
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
208
|
+
cause,
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const output: MovieOutput = {
|
|
216
|
+
kind: 'nativeFile',
|
|
217
|
+
resourceId: `synthetic:${path}`,
|
|
218
|
+
fileUrl: `file://${path}`,
|
|
219
|
+
synthetic: true,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const settle: BackendSettle = {
|
|
223
|
+
kind: 'clip',
|
|
224
|
+
output,
|
|
225
|
+
mimeType: 'video/quicktime',
|
|
226
|
+
durationMs: script.clipDurationMs,
|
|
227
|
+
width: 1280,
|
|
228
|
+
height: 720,
|
|
229
|
+
orientation: request.orientation ?? 'portrait',
|
|
230
|
+
hasAudio: request.audio,
|
|
231
|
+
limitReached,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
sink?.settled(settle)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
platform: 'synthetic',
|
|
239
|
+
supported: true,
|
|
240
|
+
checkPermission: async (kind) => script.permission[kind],
|
|
241
|
+
requestPermission: async (kind) => script.permission[kind],
|
|
242
|
+
getCapabilities: async () => ({
|
|
243
|
+
...capabilities,
|
|
244
|
+
available: script.available,
|
|
245
|
+
reason: script.available ? undefined : 'Synthetic camera is unavailable',
|
|
246
|
+
}),
|
|
247
|
+
previewReady: () => attached && script.available,
|
|
248
|
+
focusAtPoint: (point) => {
|
|
249
|
+
script.focusRequests?.push(point)
|
|
250
|
+
},
|
|
251
|
+
attachPreview: (_host, listener) => {
|
|
252
|
+
attached = true
|
|
253
|
+
queueMicrotask(() => {
|
|
254
|
+
listener?.onReady?.()
|
|
255
|
+
observer?.previewReady(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return () => {
|
|
259
|
+
attached = false
|
|
260
|
+
observer?.previewReady(false)
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
startCapture: (request, attemptSink) => {
|
|
264
|
+
sink = attemptSink
|
|
265
|
+
pendingRequest = request
|
|
266
|
+
if (!script.available) {
|
|
267
|
+
attemptSink.settled({
|
|
268
|
+
kind: 'failed',
|
|
269
|
+
stage: 'preparation',
|
|
270
|
+
issue: {
|
|
271
|
+
kind: 'unavailable',
|
|
272
|
+
operation: 'startRecording',
|
|
273
|
+
message: 'Synthetic camera is unavailable',
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (script.failAt === 'start') {
|
|
281
|
+
later(script.startDelayMs, () => {
|
|
282
|
+
attemptSink.settled({
|
|
283
|
+
kind: 'failed',
|
|
284
|
+
stage: 'preparation',
|
|
285
|
+
issue: {
|
|
286
|
+
kind: 'captureFailed',
|
|
287
|
+
operation: 'startRecording',
|
|
288
|
+
message: 'Synthetic start failure',
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
later(script.startDelayMs, () => {
|
|
297
|
+
startedAt = Date.now()
|
|
298
|
+
attemptSink.started({
|
|
299
|
+
cameraId: 'synthetic-back',
|
|
300
|
+
facing: 'back',
|
|
301
|
+
audio: request.audio,
|
|
302
|
+
width: 1280,
|
|
303
|
+
height: 720,
|
|
304
|
+
frameRate: 30,
|
|
305
|
+
orientation: request.orientation ?? 'portrait',
|
|
306
|
+
mimeType: 'video/quicktime',
|
|
307
|
+
container: 'mov',
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
if (script.interruption) {
|
|
311
|
+
later(script.interruption.afterMs, () => {
|
|
312
|
+
observer?.interruption('began', script.interruption?.reason)
|
|
313
|
+
if (script.interruption?.endsAfterMs !== undefined) {
|
|
314
|
+
later(script.interruption.endsAfterMs, () => {
|
|
315
|
+
observer?.interruption('ended', script.interruption?.reason)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (request.maximumDurationMs !== undefined) {
|
|
322
|
+
later(request.maximumDurationMs, () => {
|
|
323
|
+
void settleClip(request, true)
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (script.failAt === 'capture') {
|
|
328
|
+
later(50, () => {
|
|
329
|
+
attemptSink.settled({
|
|
330
|
+
kind: 'failed',
|
|
331
|
+
stage: 'capture',
|
|
332
|
+
issue: {
|
|
333
|
+
kind: 'captureFailed',
|
|
334
|
+
operation: 'record',
|
|
335
|
+
message: 'Synthetic capture failure',
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
},
|
|
342
|
+
stopCapture: () => {
|
|
343
|
+
// Settle asynchronously — native stop is async too.
|
|
344
|
+
const request = pendingRequest
|
|
345
|
+
later(5, () => {
|
|
346
|
+
if (request) {
|
|
347
|
+
void settleClip(request, false)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
},
|
|
351
|
+
elapsedMs: () => (startedAt === undefined ? 0 : Date.now() - startedAt),
|
|
352
|
+
openOutput: async (output) => {
|
|
353
|
+
const path = output.kind === 'nativeFile' ? output.fileUrl.slice('file://'.length) : ''
|
|
354
|
+
const exists = storage.exists ? await storage.exists(path) : !!(await storage.read(path))
|
|
355
|
+
if (!exists) {
|
|
356
|
+
throw new Error(`Synthetic clip is missing at ${output.resourceId}`)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
url: output.kind === 'nativeFile' ? output.fileUrl : output.resourceId,
|
|
361
|
+
fileUrl: output.kind === 'nativeFile' ? output.fileUrl : undefined,
|
|
362
|
+
release: () => {},
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
release: async () => {
|
|
366
|
+
attached = false
|
|
367
|
+
clearTimers()
|
|
368
|
+
sink = undefined
|
|
369
|
+
startedAt = undefined
|
|
370
|
+
pendingRequest = undefined
|
|
371
|
+
},
|
|
372
|
+
onPreviewDetached: () => {},
|
|
373
|
+
setObserver: (next) => {
|
|
374
|
+
observer = next
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
}
|