@octane-xplat/camera 0.0.0-nightly.g2e22171
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/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +104 -0
- package/platforms/android/AndroidManifest.xml +6 -0
- package/platforms/android/include.gradle +14 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +10 -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 +150 -0
- package/src/CameraView.ios.tsrx +102 -0
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.mobile.ts +6 -0
- package/src/CameraView.web.tsrx +132 -0
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +91 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.ios.ts +65 -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 +36 -0
- package/src/index.web.ts +34 -0
- package/src/layout-child.ts +81 -0
- package/src/layout-child.web.ts +66 -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 +77 -0
- 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 +48 -0
- package/types/props.d.ts +61 -0
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- package/types/types.d.ts +290 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHostClient, type HostClient } from '@octane-xplat/platform/host'
|
|
2
|
+
import { createWebKitTransport } from '@octane-xplat/platform/host/web'
|
|
3
|
+
import { CameraCaptureError } from './types'
|
|
4
|
+
import type { MovieOutput, MovieOutputHandle } from './types'
|
|
5
|
+
|
|
6
|
+
// Inside a desktop native webview the typed host channel may expose a
|
|
7
|
+
// `camera` service: truthful macOS privacy status plus app-private durable
|
|
8
|
+
// file storage. Outside a host — or when the service is absent — every probe
|
|
9
|
+
// resolves absent and the adapter keeps its browser storage behavior.
|
|
10
|
+
|
|
11
|
+
export interface CameraHostServices {
|
|
12
|
+
camera: {
|
|
13
|
+
permissionStatus(options: { kind: string }): Promise<{ status: string }>
|
|
14
|
+
requestPermission(options: { kind: string }): Promise<{ status: string }>
|
|
15
|
+
movieDirectory(): Promise<{ fileUrl: string | null }>
|
|
16
|
+
reserveMoviePath(options: {
|
|
17
|
+
fileUrl?: string
|
|
18
|
+
container?: string
|
|
19
|
+
}): Promise<{ reservation: { fileUrl: string } | null }>
|
|
20
|
+
writeMovieFile(options: {
|
|
21
|
+
fileUrl: string
|
|
22
|
+
base64: string
|
|
23
|
+
}): Promise<{ file: { name: string; uri: string } | null }>
|
|
24
|
+
movieFileInfo(options: {
|
|
25
|
+
fileUrl: string
|
|
26
|
+
}): Promise<{ info: { exists: boolean; fileUrl?: string; size?: number } | null }>
|
|
27
|
+
readMovieFile(options: { fileUrl: string }): Promise<{ base64: string | null }>
|
|
28
|
+
deleteMovieFile(options: { fileUrl: string }): Promise<{ removed: boolean }>
|
|
29
|
+
/** Optional chunked variants — a multi-hundred-MB movie must not
|
|
30
|
+
* round-trip through the bridge as one base64 payload. Absent on
|
|
31
|
+
* hosts built before the chunked contract; detected per client. */
|
|
32
|
+
writeMovieFileChunk?(options: {
|
|
33
|
+
fileUrl: string
|
|
34
|
+
base64: string
|
|
35
|
+
final?: boolean
|
|
36
|
+
}): Promise<{ file: { name: string; uri: string } | { ok: boolean } | null }>
|
|
37
|
+
readMovieFileChunk?(options: {
|
|
38
|
+
fileUrl: string
|
|
39
|
+
offset: number
|
|
40
|
+
length: number
|
|
41
|
+
}): Promise<{ base64: string | null }>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const requiredMethods = [
|
|
46
|
+
'permissionStatus',
|
|
47
|
+
'requestPermission',
|
|
48
|
+
'movieDirectory',
|
|
49
|
+
'reserveMoviePath',
|
|
50
|
+
'writeMovieFile',
|
|
51
|
+
'movieFileInfo',
|
|
52
|
+
'readMovieFile',
|
|
53
|
+
'deleteMovieFile',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
export type CameraHost = HostClient<CameraHostServices>
|
|
57
|
+
|
|
58
|
+
let hostProbe: Promise<CameraHost | null> | undefined
|
|
59
|
+
|
|
60
|
+
const probeHost = async (): Promise<CameraHost | null> => {
|
|
61
|
+
try {
|
|
62
|
+
const transport = createWebKitTransport()
|
|
63
|
+
if (!transport) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const client = createHostClient<CameraHostServices>(transport)
|
|
68
|
+
const capabilities = (await client.capabilities().catch(() => null)) as {
|
|
69
|
+
camera?: string[]
|
|
70
|
+
} | null
|
|
71
|
+
|
|
72
|
+
const methods = capabilities?.camera
|
|
73
|
+
if (Array.isArray(methods) && requiredMethods.every((name) => methods.includes(name))) {
|
|
74
|
+
return client
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
client.dispose()
|
|
78
|
+
return null
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The camera-capable desktop host client, or null outside a qualified host.
|
|
85
|
+
* Only a confirmed client stays cached — a null result may be a transient
|
|
86
|
+
* transport or capabilities failure, so the next call probes again rather
|
|
87
|
+
* than sticking 'absent' for the page lifetime. */
|
|
88
|
+
export async function cameraHost(): Promise<CameraHost | null> {
|
|
89
|
+
hostProbe ??= probeHost()
|
|
90
|
+
const host = await hostProbe
|
|
91
|
+
if (!host) {
|
|
92
|
+
hostProbe = probeHost()
|
|
93
|
+
return hostProbe
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return host
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const error = (kind: 'destinationUnavailable' | 'storageFailed' | 'unavailable', message: string) =>
|
|
100
|
+
new CameraCaptureError({ kind, operation: 'storage', message })
|
|
101
|
+
|
|
102
|
+
const dataToBase64 = (data: Uint8Array): string => {
|
|
103
|
+
let binary = ''
|
|
104
|
+
const chunk = 0x8000
|
|
105
|
+
for (let offset = 0; offset < data.length; offset += chunk) {
|
|
106
|
+
binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return btoa(binary)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const blobToBase64 = async (bytes: Blob): Promise<string> =>
|
|
113
|
+
dataToBase64(new Uint8Array(await bytes.arrayBuffer()))
|
|
114
|
+
|
|
115
|
+
const base64ToBlob = (base64: string, type: string): Blob => {
|
|
116
|
+
const binary = atob(base64)
|
|
117
|
+
const data = new Uint8Array(binary.length)
|
|
118
|
+
for (let index = 0; index < binary.length; index++) {
|
|
119
|
+
data[index] = binary.charCodeAt(index)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return new Blob([data], { type })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mimeForFileUrl = (fileUrl: string): string =>
|
|
126
|
+
fileUrl.endsWith('.webm')
|
|
127
|
+
? 'video/webm'
|
|
128
|
+
: fileUrl.endsWith('.mov')
|
|
129
|
+
? 'video/quicktime'
|
|
130
|
+
: 'video/mp4'
|
|
131
|
+
|
|
132
|
+
/** Reserve a host-side movie file. `destinationFileUrl` is validated
|
|
133
|
+
* host-side: it must be a new file inside the app-private storage root. */
|
|
134
|
+
export const reserveHostMovie = async (
|
|
135
|
+
host: CameraHost,
|
|
136
|
+
options: { destinationFileUrl?: string; container?: string },
|
|
137
|
+
): Promise<{ fileUrl: string }> => {
|
|
138
|
+
const { reservation } = await host.call('camera', 'reserveMoviePath', {
|
|
139
|
+
fileUrl: options.destinationFileUrl,
|
|
140
|
+
container: options.container,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if (!reservation?.fileUrl) {
|
|
144
|
+
throw error(
|
|
145
|
+
'destinationUnavailable',
|
|
146
|
+
options.destinationFileUrl
|
|
147
|
+
? 'destinationFileUrl must be a new file inside the app-private recordings directory'
|
|
148
|
+
: 'The host could not allocate a movie recording file',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return reservation
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Bytes bridged per call when the host exposes the chunked movie contract —
|
|
156
|
+
* large enough to amortize IPC, small enough to keep peak copies bounded. */
|
|
157
|
+
const MOVIE_CHUNK_BYTES = 4 * 1024 * 1024
|
|
158
|
+
|
|
159
|
+
const chunkedSupport = new WeakMap<CameraHost, Promise<boolean>>()
|
|
160
|
+
|
|
161
|
+
/** A host built before the chunked contract lacks the methods entirely —
|
|
162
|
+
* the capabilities probe answers that without a probe call. */
|
|
163
|
+
const supportsChunkedMovies = (host: CameraHost): Promise<boolean> => {
|
|
164
|
+
let probe = chunkedSupport.get(host)
|
|
165
|
+
if (!probe) {
|
|
166
|
+
probe = host
|
|
167
|
+
.capabilities()
|
|
168
|
+
.then((capabilities) => {
|
|
169
|
+
const methods = (capabilities as { camera?: string[] }).camera ?? []
|
|
170
|
+
return methods.includes('writeMovieFileChunk') && methods.includes('readMovieFileChunk')
|
|
171
|
+
})
|
|
172
|
+
.catch(() => false)
|
|
173
|
+
|
|
174
|
+
chunkedSupport.set(host, probe)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return probe
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Commit finalized movie bytes to host-side app-private storage. Hosts
|
|
181
|
+
* with the chunked contract stream 4MB slices — a multi-hundred-MB
|
|
182
|
+
* recording no longer materializes ~3-4x its size across the bridge. */
|
|
183
|
+
export const commitHostMovie = async (
|
|
184
|
+
host: CameraHost,
|
|
185
|
+
fileUrl: string,
|
|
186
|
+
bytes: Blob,
|
|
187
|
+
): Promise<MovieOutput> => {
|
|
188
|
+
if (await supportsChunkedMovies(host)) {
|
|
189
|
+
for (let offset = 0; ; offset += MOVIE_CHUNK_BYTES) {
|
|
190
|
+
const slice = bytes.slice(offset, offset + MOVIE_CHUNK_BYTES)
|
|
191
|
+
const final = offset + MOVIE_CHUNK_BYTES >= bytes.size
|
|
192
|
+
const { file } = await host.call('camera', 'writeMovieFileChunk', {
|
|
193
|
+
fileUrl,
|
|
194
|
+
base64: dataToBase64(new Uint8Array(await slice.arrayBuffer())),
|
|
195
|
+
final,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if (final) {
|
|
199
|
+
const committed = file && 'uri' in file ? file : null
|
|
200
|
+
if (!committed?.uri) {
|
|
201
|
+
throw error('storageFailed', 'The host could not write the recorded movie')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { kind: 'nativeFile', resourceId: committed.uri, fileUrl: committed.uri }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!file || !('ok' in file) || file.ok !== true) {
|
|
208
|
+
throw error('storageFailed', 'The host could not write a recorded movie chunk')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const base64 = await blobToBase64(bytes)
|
|
214
|
+
const { file } = await host.call('camera', 'writeMovieFile', { fileUrl, base64 })
|
|
215
|
+
if (!file?.uri) {
|
|
216
|
+
throw error('storageFailed', 'The host could not write the recorded movie')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { kind: 'nativeFile', resourceId: file.uri, fileUrl: file.uri }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Drop an uncommitted host reservation after a failed attempt. */
|
|
223
|
+
export const removeHostReservation = async (host: CameraHost, fileUrl: string): Promise<void> => {
|
|
224
|
+
await host.call('camera', 'deleteMovieFile', { fileUrl }).catch(() => {})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Reopen a host-stored movie as a temporary playback URL. The durable
|
|
228
|
+
* reference remains `fileUrl`; the returned url lives until `release()`. */
|
|
229
|
+
export const openHostMovie = async (
|
|
230
|
+
host: CameraHost,
|
|
231
|
+
output: MovieOutput,
|
|
232
|
+
): Promise<MovieOutputHandle> => {
|
|
233
|
+
if (output.kind !== 'nativeFile' || !output.fileUrl) {
|
|
234
|
+
throw new CameraCaptureError({
|
|
235
|
+
kind: 'invalidArgument',
|
|
236
|
+
operation: 'openOutput',
|
|
237
|
+
message: 'Only file-backed output can be opened through the desktop host',
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { info } = await host.call('camera', 'movieFileInfo', { fileUrl: output.fileUrl })
|
|
242
|
+
if (!info?.exists) {
|
|
243
|
+
throw error('unavailable', `Movie file is missing at ${output.fileUrl}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const size = Number(info.size ?? -1)
|
|
247
|
+
if (size > 0 && (await supportsChunkedMovies(host))) {
|
|
248
|
+
const data = new Uint8Array(size)
|
|
249
|
+
for (let offset = 0; offset < size; offset += MOVIE_CHUNK_BYTES) {
|
|
250
|
+
const { base64: chunk } = await host.call('camera', 'readMovieFileChunk', {
|
|
251
|
+
fileUrl: output.fileUrl,
|
|
252
|
+
offset,
|
|
253
|
+
length: Math.min(MOVIE_CHUNK_BYTES, size - offset),
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
if (chunk == null) {
|
|
257
|
+
throw error('unavailable', `Movie file could not be read at ${output.fileUrl}`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let decoded: string
|
|
261
|
+
try {
|
|
262
|
+
decoded = atob(chunk)
|
|
263
|
+
} catch {
|
|
264
|
+
throw error('unavailable', 'The host returned invalid movie bytes')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (decoded.length !== Math.min(MOVIE_CHUNK_BYTES, size - offset)) {
|
|
268
|
+
throw error('unavailable', 'The stored movie changed or could not be read completely')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (let index = 0; index < decoded.length; index += 1) {
|
|
272
|
+
data[offset + index] = decoded.charCodeAt(index)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const url = URL.createObjectURL(new Blob([data], { type: mimeForFileUrl(output.fileUrl) }))
|
|
277
|
+
return {
|
|
278
|
+
url,
|
|
279
|
+
fileUrl: output.fileUrl,
|
|
280
|
+
release: () => URL.revokeObjectURL(url),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const { base64 } = await host.call('camera', 'readMovieFile', { fileUrl: output.fileUrl })
|
|
285
|
+
if (!base64) {
|
|
286
|
+
throw error('unavailable', `Movie file could not be read at ${output.fileUrl}`)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const url = URL.createObjectURL(base64ToBlob(base64, mimeForFileUrl(output.fileUrl)))
|
|
290
|
+
return {
|
|
291
|
+
url,
|
|
292
|
+
fileUrl: output.fileUrl,
|
|
293
|
+
release: () => URL.revokeObjectURL(url),
|
|
294
|
+
}
|
|
295
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Keep this relative import extensionless so the NativeScript resolver picks
|
|
2
|
+
// the iOS or Android component for the current build target.
|
|
3
|
+
export { CameraView } from './CameraView'
|
|
4
|
+
export type { CameraAccessibilityRole, CameraViewHandle, CameraViewProps } from './props'
|
|
5
|
+
export { createCameraSession } from './session'
|
|
6
|
+
export { CameraCaptureError } from './types'
|
|
7
|
+
export type {
|
|
8
|
+
CameraCaptureErrorKind,
|
|
9
|
+
CameraDeviceInfo,
|
|
10
|
+
CameraFacing,
|
|
11
|
+
CameraInterruptionReason,
|
|
12
|
+
CameraOrientation,
|
|
13
|
+
CameraPermissionKind,
|
|
14
|
+
CameraPermissionStatus,
|
|
15
|
+
CameraPreviewPoint,
|
|
16
|
+
CameraProfileCapability,
|
|
17
|
+
CameraRecorderState,
|
|
18
|
+
CameraSelection,
|
|
19
|
+
CameraSession,
|
|
20
|
+
CameraSessionCapabilities,
|
|
21
|
+
CameraSessionConfig,
|
|
22
|
+
CameraSessionEvent,
|
|
23
|
+
CameraSessionSnapshot,
|
|
24
|
+
CaptureIssue,
|
|
25
|
+
EffectiveCaptureConfig,
|
|
26
|
+
MovieClip,
|
|
27
|
+
MovieEndReason,
|
|
28
|
+
MovieOutcome,
|
|
29
|
+
MovieOutput,
|
|
30
|
+
MovieOutputHandle,
|
|
31
|
+
MovieTake,
|
|
32
|
+
StartRecordingOptions,
|
|
33
|
+
} from './types'
|
|
34
|
+
|
|
35
|
+
export { createMemoryCameraStorage, createSyntheticCameraSession } from './synthetic-session'
|
|
36
|
+
export type { SyntheticCameraScript, SyntheticCameraStorage } from './synthetic-session'
|
package/src/index.web.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export { CameraView } from './CameraView.web.tsrx'
|
|
2
|
+
export type { CameraAccessibilityRole, CameraViewHandle, CameraViewProps } from './props'
|
|
3
|
+
export { createCameraSession } from './session'
|
|
4
|
+
export { CameraCaptureError } from './types'
|
|
5
|
+
export type {
|
|
6
|
+
CameraCaptureErrorKind,
|
|
7
|
+
CameraDeviceInfo,
|
|
8
|
+
CameraFacing,
|
|
9
|
+
CameraInterruptionReason,
|
|
10
|
+
CameraOrientation,
|
|
11
|
+
CameraPermissionKind,
|
|
12
|
+
CameraPermissionStatus,
|
|
13
|
+
CameraPreviewPoint,
|
|
14
|
+
CameraProfileCapability,
|
|
15
|
+
CameraRecorderState,
|
|
16
|
+
CameraSelection,
|
|
17
|
+
CameraSession,
|
|
18
|
+
CameraSessionCapabilities,
|
|
19
|
+
CameraSessionConfig,
|
|
20
|
+
CameraSessionEvent,
|
|
21
|
+
CameraSessionSnapshot,
|
|
22
|
+
CaptureIssue,
|
|
23
|
+
EffectiveCaptureConfig,
|
|
24
|
+
MovieClip,
|
|
25
|
+
MovieEndReason,
|
|
26
|
+
MovieOutcome,
|
|
27
|
+
MovieOutput,
|
|
28
|
+
MovieOutputHandle,
|
|
29
|
+
MovieTake,
|
|
30
|
+
StartRecordingOptions,
|
|
31
|
+
} from './types'
|
|
32
|
+
|
|
33
|
+
export { createMemoryCameraStorage, createSyntheticCameraSession } from './synthetic-session'
|
|
34
|
+
export type { SyntheticCameraScript, SyntheticCameraStorage } from './synthetic-session'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CameraViewProps } from './props'
|
|
2
|
+
import { isIOS } from '@nativescript/core'
|
|
3
|
+
|
|
4
|
+
/** NativeScript reads layout fields from a child view during parent layout. */
|
|
5
|
+
export function layoutChildProps(props: CameraViewProps): Record<string, any> {
|
|
6
|
+
const result: Record<string, any> = {}
|
|
7
|
+
for (const key of [
|
|
8
|
+
'row',
|
|
9
|
+
'col',
|
|
10
|
+
'rowSpan',
|
|
11
|
+
'colSpan',
|
|
12
|
+
'dock',
|
|
13
|
+
'left',
|
|
14
|
+
'top',
|
|
15
|
+
'flexGrow',
|
|
16
|
+
'flexShrink',
|
|
17
|
+
'alignSelf',
|
|
18
|
+
'order',
|
|
19
|
+
] as const) {
|
|
20
|
+
if (props[key] !== undefined) {
|
|
21
|
+
result[key] = props[key]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
|
|
29
|
+
if (!view) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const bag = isIOS ? props.ios : props.android
|
|
34
|
+
if (bag) {
|
|
35
|
+
Object.assign(view, bag)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const nativeRoles: Record<string, string> = {
|
|
40
|
+
button: 'button',
|
|
41
|
+
link: 'link',
|
|
42
|
+
search: 'search',
|
|
43
|
+
image: 'image',
|
|
44
|
+
heading: 'header',
|
|
45
|
+
adjustable: 'adjustable',
|
|
46
|
+
summary: 'summary',
|
|
47
|
+
text: 'text',
|
|
48
|
+
none: 'none',
|
|
49
|
+
progressbar: 'progressBar',
|
|
50
|
+
checkbox: 'checkbox',
|
|
51
|
+
switch: 'switch',
|
|
52
|
+
radio: 'radioButton',
|
|
53
|
+
spinbutton: 'spinButton',
|
|
54
|
+
tab: 'button',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nativeAccessibilityRole(role?: string): string | undefined {
|
|
58
|
+
return role ? nativeRoles[role] : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function nativeAccessibilityState(
|
|
62
|
+
state?: CameraViewProps['accessibilityState'],
|
|
63
|
+
): string | undefined {
|
|
64
|
+
if (state?.disabled) {
|
|
65
|
+
return 'disabled'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (state?.selected) {
|
|
69
|
+
return 'selected'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state?.checked === true) {
|
|
73
|
+
return 'checked'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (state?.checked === false) {
|
|
77
|
+
return 'unchecked'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { CameraViewProps } from './props'
|
|
2
|
+
|
|
3
|
+
export function layoutChildProps(props: CameraViewProps, baseStyle: any = props.style) {
|
|
4
|
+
const style = { ...baseStyle }
|
|
5
|
+
if (props.row !== undefined) {
|
|
6
|
+
style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
|
|
7
|
+
} else if (props.rowSpan !== undefined) {
|
|
8
|
+
style.gridRow = `span ${props.rowSpan}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (props.col !== undefined) {
|
|
12
|
+
style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
|
|
13
|
+
} else if (props.colSpan !== undefined) {
|
|
14
|
+
style.gridColumn = `span ${props.colSpan}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (props.left !== undefined) {
|
|
18
|
+
style.left = props.left
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (props.top !== undefined) {
|
|
22
|
+
style.top = props.top
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (props.flexGrow !== undefined) {
|
|
26
|
+
style.flexGrow = props.flexGrow
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (props.flexShrink !== undefined) {
|
|
30
|
+
style.flexShrink = props.flexShrink
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (props.alignSelf !== undefined) {
|
|
34
|
+
style.alignSelf = props.alignSelf
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (props.order !== undefined) {
|
|
38
|
+
style.order = props.order
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { style }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function applyEscapeProps(element: any, props: { web?: any }): void {
|
|
45
|
+
if (!element || !props.web) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const [key, value] of Object.entries(props.web)) {
|
|
50
|
+
if (key.includes('-')) {
|
|
51
|
+
if (value == null || value === false) {
|
|
52
|
+
element.removeAttribute(key)
|
|
53
|
+
} else {
|
|
54
|
+
element.setAttribute(key, value === true ? '' : String(value))
|
|
55
|
+
}
|
|
56
|
+
} else if (key in element) {
|
|
57
|
+
try {
|
|
58
|
+
element[key] = value
|
|
59
|
+
} catch {
|
|
60
|
+
element.setAttribute(key, String(value))
|
|
61
|
+
}
|
|
62
|
+
} else if (value != null) {
|
|
63
|
+
element.setAttribute(key, String(value))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CameraViewProps } from './props'
|
|
2
|
+
|
|
3
|
+
/** Windows twins of the layout-child helpers — the unsuffixed module
|
|
4
|
+
* resolves the platform escape bag through `isIOS`, which has no Windows
|
|
5
|
+
* branch. Pure helpers are re-declared here to keep divergence at the file
|
|
6
|
+
* boundary. */
|
|
7
|
+
|
|
8
|
+
export function layoutChildProps(props: CameraViewProps): Record<string, any> {
|
|
9
|
+
const result: Record<string, any> = {}
|
|
10
|
+
for (const key of [
|
|
11
|
+
'row',
|
|
12
|
+
'col',
|
|
13
|
+
'rowSpan',
|
|
14
|
+
'colSpan',
|
|
15
|
+
'dock',
|
|
16
|
+
'left',
|
|
17
|
+
'top',
|
|
18
|
+
'flexGrow',
|
|
19
|
+
'flexShrink',
|
|
20
|
+
'alignSelf',
|
|
21
|
+
'order',
|
|
22
|
+
] as const) {
|
|
23
|
+
if (props[key] !== undefined) {
|
|
24
|
+
result[key] = props[key]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyEscapeProps(view: any, props: { windows?: any }): void {
|
|
32
|
+
if (view && props.windows) {
|
|
33
|
+
Object.assign(view, props.windows)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nativeRoles: Record<string, string> = {
|
|
38
|
+
button: 'button',
|
|
39
|
+
link: 'link',
|
|
40
|
+
search: 'search',
|
|
41
|
+
image: 'image',
|
|
42
|
+
heading: 'header',
|
|
43
|
+
adjustable: 'adjustable',
|
|
44
|
+
summary: 'summary',
|
|
45
|
+
text: 'text',
|
|
46
|
+
none: 'none',
|
|
47
|
+
progressbar: 'progressBar',
|
|
48
|
+
checkbox: 'checkbox',
|
|
49
|
+
switch: 'switch',
|
|
50
|
+
radio: 'radioButton',
|
|
51
|
+
spinbutton: 'spinButton',
|
|
52
|
+
tab: 'button',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function nativeAccessibilityRole(role?: string): string | undefined {
|
|
56
|
+
return role ? nativeRoles[role] : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nativeAccessibilityState(
|
|
60
|
+
state?: CameraViewProps['accessibilityState'],
|
|
61
|
+
): string | undefined {
|
|
62
|
+
if (state?.disabled) {
|
|
63
|
+
return 'disabled'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (state?.selected) {
|
|
67
|
+
return 'selected'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (state?.checked === true) {
|
|
71
|
+
return 'checked'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (state?.checked === false) {
|
|
75
|
+
return 'unchecked'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BlobSource, Input, MP4, WEBM } from 'mediabunny'
|
|
2
|
+
import { CameraCaptureError } from './types'
|
|
3
|
+
|
|
4
|
+
/** Read packet end timestamps and track metadata, never the wall-clock stop time. */
|
|
5
|
+
export async function inspectMovie(bytes: Blob, audioRequested: boolean) {
|
|
6
|
+
if (!bytes.size) {
|
|
7
|
+
throw new CameraCaptureError({
|
|
8
|
+
kind: 'noMedia',
|
|
9
|
+
operation: 'finalize',
|
|
10
|
+
message: 'The recorder produced no movie bytes',
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const input = new Input({ source: new BlobSource(bytes), formats: [WEBM, MP4] })
|
|
15
|
+
const read = async () => {
|
|
16
|
+
const video = await input.getPrimaryVideoTrack()
|
|
17
|
+
const audio = await input.getPrimaryAudioTrack()
|
|
18
|
+
if (!video) {
|
|
19
|
+
throw new CameraCaptureError({
|
|
20
|
+
kind: 'noMedia',
|
|
21
|
+
operation: 'finalize',
|
|
22
|
+
message: 'The movie contains no video track',
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const videoDuration = await video.computeDuration()
|
|
27
|
+
const hasAudio = audio !== null && (await audio.computeDuration()) > 0
|
|
28
|
+
const durationMs = (await input.computeDuration()) * 1000
|
|
29
|
+
const width = await video.getDisplayWidth()
|
|
30
|
+
const height = await video.getDisplayHeight()
|
|
31
|
+
if (
|
|
32
|
+
!Number.isFinite(durationMs) ||
|
|
33
|
+
durationMs <= 0 ||
|
|
34
|
+
videoDuration <= 0 ||
|
|
35
|
+
width <= 0 ||
|
|
36
|
+
height <= 0
|
|
37
|
+
) {
|
|
38
|
+
throw new CameraCaptureError({
|
|
39
|
+
kind: 'noMedia',
|
|
40
|
+
operation: 'finalize',
|
|
41
|
+
message: 'The movie has no usable video media',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (audioRequested && !hasAudio) {
|
|
46
|
+
throw new CameraCaptureError({
|
|
47
|
+
kind: 'captureFailed',
|
|
48
|
+
operation: 'finalize',
|
|
49
|
+
message: 'Requested audio is absent from the finalized movie',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
durationMs,
|
|
55
|
+
width,
|
|
56
|
+
height,
|
|
57
|
+
hasAudio,
|
|
58
|
+
mimeType: await input.getMimeType(),
|
|
59
|
+
orientation: 'unspecified' as const,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
64
|
+
try {
|
|
65
|
+
return await Promise.race([
|
|
66
|
+
read(),
|
|
67
|
+
new Promise<never>((_resolve, reject) => {
|
|
68
|
+
timer = setTimeout(() => {
|
|
69
|
+
input.dispose()
|
|
70
|
+
reject(
|
|
71
|
+
new CameraCaptureError({
|
|
72
|
+
kind: 'finalizationFailed',
|
|
73
|
+
operation: 'metadata',
|
|
74
|
+
message: 'Movie metadata verification timed out',
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
}, 30_000)
|
|
78
|
+
}),
|
|
79
|
+
])
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer)
|
|
82
|
+
input.dispose()
|
|
83
|
+
}
|
|
84
|
+
}
|