@octane-xplat/camera 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/package.json +22 -16
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +139 -13
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +6 -2
- package/src/CameraView.ios.tsrx +4 -2
- package/src/CameraView.macos.tsrx +4 -2
- package/src/CameraView.web.tsrx +11 -0
- package/src/CameraView.windows.tsrx +4 -2
- package/src/camera-permission.mobile.ts +18 -4
- package/src/camera-session.windows.ts +4 -1
- package/src/host-movie.web.ts +141 -26
- package/src/index.ts +1 -0
- package/src/index.web.ts +1 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +8 -1
- package/src/session-backend.android.ts +42 -0
- package/src/session-backend.ios.ts +183 -17
- package/src/session-backend.linux.ts +22 -4
- package/src/session-backend.macos.ts +100 -17
- package/src/session-backend.ts +2 -0
- package/src/session-backend.web.ts +17 -0
- package/src/session-backend.windows.ts +88 -6
- package/src/session-core.ts +73 -9
- package/src/synthetic-session.ts +10 -0
- package/src/types.ts +28 -0
- package/types/props.d.ts +8 -1
- package/types/session-core.d.ts +11 -1
- package/types/synthetic-session.d.ts +4 -1
- package/types/types.d.ts +27 -0
- package/dist/linux/CameraView.web.tsrx.js +0 -337
- package/dist/linux/host-movie.web.js +0 -100
- package/dist/linux/index.js +0 -5
- package/dist/linux/layout-child.web.js +0 -29
- package/dist/linux/movie-metadata.web.js +0 -65
- package/dist/linux/octane-client-build.json +0 -8
- package/dist/linux/session-backend.linux.js +0 -479
- package/dist/linux/session-core.js +0 -368
- package/dist/linux/session.js +0 -29
- package/dist/linux/synthetic-session.js +0 -280
- package/dist/linux/types.js +0 -19
- package/dist/native/CameraView.android.js +0 -190
- package/dist/native/CameraView.ios.js +0 -154
- package/dist/native/CameraView.windows.js +0 -173
- package/dist/native/camera-permission.mobile.js +0 -45
- package/dist/native/camera-permission.windows.js +0 -43
- package/dist/native/camera-session.ios.js +0 -45
- package/dist/native/camera-session.windows.js +0 -78
- package/dist/native/cx.mobile.js +0 -9
- package/dist/native/cx.windows.js +0 -9
- package/dist/native/index.js +0 -5
- package/dist/native/layout-child.js +0 -53
- package/dist/native/layout-child.windows.js +0 -53
- package/dist/native/octane-client-build.json +0 -8
- package/dist/native/session-backend.android.js +0 -234
- package/dist/native/session-backend.ios.js +0 -682
- package/dist/native/session-backend.js +0 -55
- package/dist/native/session-backend.windows.js +0 -905
- package/dist/native/session-core.js +0 -368
- package/dist/native/session.js +0 -29
- package/dist/native/synthetic-session.js +0 -280
- package/dist/native/types.js +0 -19
- package/dist/web/CameraView.web.tsrx.js +0 -337
- package/dist/web/host-movie.web.js +0 -100
- package/dist/web/index.js +0 -5
- package/dist/web/layout-child.web.js +0 -29
- package/dist/web/movie-metadata.web.js +0 -65
- package/dist/web/movie-storage.web.js +0 -119
- package/dist/web/octane-client-build.json +0 -8
- package/dist/web/session-backend.web.js +0 -490
- package/dist/web/session-core.js +0 -368
- package/dist/web/session.js +0 -29
- package/dist/web/synthetic-session.js +0 -280
- package/dist/web/types.js +0 -19
- package/src/cx.macos.ts +0 -5
- package/src/cx.mobile.ts +0 -6
- package/src/cx.windows.ts +0 -6
|
@@ -134,6 +134,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
134
134
|
observer?.interruption('began', 'background')
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Track-caused interruptions (ended/mute) need an explicit 'ended' —
|
|
138
|
+
// a fresh live stream resolves them, and without the event the core
|
|
139
|
+
// keeps interrupted=true until the session is recreated.
|
|
140
|
+
let deviceInterrupted = false
|
|
141
|
+
|
|
137
142
|
const observeTracks = (target: MediaStream) => {
|
|
138
143
|
for (const track of target.getTracks()) {
|
|
139
144
|
track.addEventListener('ended', () => {
|
|
@@ -146,6 +151,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
markReady(false)
|
|
154
|
+
deviceInterrupted = true
|
|
149
155
|
observer?.availability(false)
|
|
150
156
|
observer?.interruption('began', 'unknown')
|
|
151
157
|
})
|
|
@@ -157,12 +163,14 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
157
163
|
|
|
158
164
|
if (stream === target) {
|
|
159
165
|
markReady(false)
|
|
166
|
+
deviceInterrupted = true
|
|
160
167
|
observer?.interruption('began', 'unknown')
|
|
161
168
|
}
|
|
162
169
|
})
|
|
163
170
|
|
|
164
171
|
track.addEventListener('unmute', () => {
|
|
165
172
|
if (stream === target && document.visibilityState !== 'hidden') {
|
|
173
|
+
deviceInterrupted = false
|
|
166
174
|
observer?.interruption('ended', 'unknown')
|
|
167
175
|
observer?.availability(true)
|
|
168
176
|
markReady(!!host)
|
|
@@ -219,6 +227,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
219
227
|
stream = acquired
|
|
220
228
|
known.camera = 'granted'
|
|
221
229
|
observeTracks(acquired)
|
|
230
|
+
if (deviceInterrupted) {
|
|
231
|
+
deviceInterrupted = false
|
|
232
|
+
observer?.interruption('ended', 'unknown')
|
|
233
|
+
}
|
|
234
|
+
|
|
222
235
|
observer?.availability(true)
|
|
223
236
|
} catch (cause) {
|
|
224
237
|
if (owner === lease) {
|
|
@@ -498,6 +511,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
498
511
|
]
|
|
499
512
|
: [],
|
|
500
513
|
audio: devices.some((device) => device.kind === 'audioinput'),
|
|
514
|
+
// The `pointsOfInterest` track constraint is not wired into
|
|
515
|
+
// the adapter — report honestly rather than fabricate.
|
|
516
|
+
focusPointOfInterest: false,
|
|
517
|
+
exposurePointOfInterest: false,
|
|
501
518
|
orientations: [],
|
|
502
519
|
durationLimit: 'bestEffort',
|
|
503
520
|
elapsedTime: 'estimated',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Application } from '@nativescript/core'
|
|
2
2
|
import { CameraCaptureError } from './types'
|
|
3
|
+
import { canonicalizePath } from './session-core'
|
|
3
4
|
import type {
|
|
4
5
|
BackendCaptureRequest,
|
|
5
6
|
BackendCaptureSink,
|
|
@@ -533,10 +534,19 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
533
534
|
const message = String(args?.Message ?? args?.message ?? 'Camera capture failed')
|
|
534
535
|
markReady(false)
|
|
535
536
|
observer?.availability(false)
|
|
536
|
-
observer?.interruption(
|
|
537
|
+
observer?.interruption(
|
|
538
|
+
'began',
|
|
539
|
+
/in use|denied|access/i.test(message) ? 'cameraInUse' : 'unknown',
|
|
540
|
+
)
|
|
541
|
+
|
|
537
542
|
if (sink) {
|
|
538
543
|
// The pipeline died under a live take — try to finalize the
|
|
539
|
-
// partial media instead of discarding it outright.
|
|
544
|
+
// partial media instead of discarding it outright. wantStop
|
|
545
|
+
// makes the early stop stick while
|
|
546
|
+
// StartRecordToStorageFileAsync is still in flight, and the
|
|
547
|
+
// deadline settles the take if that call never resolves.
|
|
548
|
+
wantStop = true
|
|
549
|
+
armSettleDeadline(sink)
|
|
540
550
|
sink.finishing(/device|available|found/i.test(message) ? 'cameraUnavailable' : 'captureError')
|
|
541
551
|
void stopRecording()
|
|
542
552
|
}
|
|
@@ -545,6 +555,8 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
545
555
|
const onRecordLimitationExceeded = () => {
|
|
546
556
|
// The OS-level recording limitation ended the take — finalize the
|
|
547
557
|
// usable media and keep first-cause semantics from the core.
|
|
558
|
+
wantStop = true
|
|
559
|
+
armSettleDeadline(sink)
|
|
548
560
|
sink?.finishing('interrupted')
|
|
549
561
|
void stopRecording()
|
|
550
562
|
}
|
|
@@ -850,11 +862,29 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
850
862
|
return toPromise(folder.CreateFileAsync(name, collisionGenerateUniqueName))
|
|
851
863
|
}
|
|
852
864
|
|
|
853
|
-
|
|
865
|
+
// Durable output stays app-private like every other leaf — resolve
|
|
866
|
+
// against LocalFolder, canonicalizing first so '..' cannot escape it.
|
|
867
|
+
const filePath = canonicalizePath(normalizePath(destinationPath), '\\')
|
|
868
|
+
const localRoot = normalizePath(
|
|
869
|
+
String(storage?.ApplicationData?.Current?.LocalFolder?.Path ?? ''),
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
if (!localRoot || !filePath.toLowerCase().startsWith(`${localRoot.toLowerCase()}\\`)) {
|
|
873
|
+
throw error(
|
|
874
|
+
'destinationUnavailable',
|
|
875
|
+
'startRecording',
|
|
876
|
+
'destinationFileUrl must resolve inside app-private storage',
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
854
880
|
const directory = filePath.slice(0, filePath.lastIndexOf('\\'))
|
|
855
881
|
const name = filePath.slice(filePath.lastIndexOf('\\') + 1)
|
|
856
882
|
if (!directory || !name) {
|
|
857
|
-
throw error(
|
|
883
|
+
throw error(
|
|
884
|
+
'destinationUnavailable',
|
|
885
|
+
'startRecording',
|
|
886
|
+
'destinationFileUrl is not a file path',
|
|
887
|
+
)
|
|
858
888
|
}
|
|
859
889
|
|
|
860
890
|
const tempPath = String(storage?.ApplicationData?.Current?.TemporaryFolder?.Path ?? '')
|
|
@@ -985,7 +1015,13 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
985
1015
|
const durationMs = (await input.computeDuration()) * 1000
|
|
986
1016
|
const width = await video.getDisplayWidth()
|
|
987
1017
|
const height = await video.getDisplayHeight()
|
|
988
|
-
return {
|
|
1018
|
+
return {
|
|
1019
|
+
durationMs,
|
|
1020
|
+
width,
|
|
1021
|
+
height,
|
|
1022
|
+
hasAudio,
|
|
1023
|
+
orientation: 'unspecified' as CameraOrientation,
|
|
1024
|
+
}
|
|
989
1025
|
} finally {
|
|
990
1026
|
input.dispose()
|
|
991
1027
|
}
|
|
@@ -1011,6 +1047,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1011
1047
|
finalizing = false
|
|
1012
1048
|
wantStop = false
|
|
1013
1049
|
recordStartedAt = 0
|
|
1050
|
+
if (settleDeadline !== undefined) {
|
|
1051
|
+
clearTimeout(settleDeadline)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1014
1054
|
const file = recordFile
|
|
1015
1055
|
recordFile = undefined
|
|
1016
1056
|
void removeFileQuietly(file)
|
|
@@ -1018,6 +1058,30 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1018
1058
|
teardown()
|
|
1019
1059
|
}
|
|
1020
1060
|
|
|
1061
|
+
// Same 30s settle deadline the web/linux adapters carry — once
|
|
1062
|
+
// finalization starts, a hung native call must still settle the take.
|
|
1063
|
+
let settleDeadline: ReturnType<typeof setTimeout> | undefined
|
|
1064
|
+
const armSettleDeadline = (target: BackendCaptureSink | undefined) => {
|
|
1065
|
+
if (settleDeadline !== undefined) {
|
|
1066
|
+
clearTimeout(settleDeadline)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (!target) {
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
settleDeadline = setTimeout(() => {
|
|
1074
|
+
if (sink !== target) {
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
finishWithError(
|
|
1079
|
+
'finalization',
|
|
1080
|
+
error('finalizationFailed', 'finalize', 'Movie finalization timed out'),
|
|
1081
|
+
)
|
|
1082
|
+
}, 30_000)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1021
1085
|
const settleClip = async (file: any) => {
|
|
1022
1086
|
const target = sink
|
|
1023
1087
|
const take = request
|
|
@@ -1025,6 +1089,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1025
1089
|
return
|
|
1026
1090
|
}
|
|
1027
1091
|
|
|
1092
|
+
if (settleDeadline !== undefined) {
|
|
1093
|
+
clearTimeout(settleDeadline)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1028
1096
|
try {
|
|
1029
1097
|
let metadata: Awaited<ReturnType<typeof nativeVideoMetadata>>
|
|
1030
1098
|
try {
|
|
@@ -1108,6 +1176,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1108
1176
|
}
|
|
1109
1177
|
|
|
1110
1178
|
finalizing = true
|
|
1179
|
+
armSettleDeadline(sink)
|
|
1111
1180
|
try {
|
|
1112
1181
|
await toPromise(target.StopRecordAsync())
|
|
1113
1182
|
} catch (cause) {
|
|
@@ -1211,6 +1280,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1211
1280
|
cameras,
|
|
1212
1281
|
profiles: [{ profile: 'standard', ...profile }],
|
|
1213
1282
|
audio: (await listAudioDevices()) > 0,
|
|
1283
|
+
// WinUI MediaCapture focus regions are not wired into the
|
|
1284
|
+
// adapter — report honestly rather than fabricate.
|
|
1285
|
+
focusPointOfInterest: false,
|
|
1286
|
+
exposurePointOfInterest: false,
|
|
1214
1287
|
orientations:
|
|
1215
1288
|
capture && typeof capture.SetRecordRotationAsync === 'function'
|
|
1216
1289
|
? ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight']
|
|
@@ -1476,7 +1549,12 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1476
1549
|
try {
|
|
1477
1550
|
await toPromise(storage.StorageFile.GetFileFromPathAsync(filePath))
|
|
1478
1551
|
} catch (cause) {
|
|
1479
|
-
throw error(
|
|
1552
|
+
throw error(
|
|
1553
|
+
'unavailable',
|
|
1554
|
+
'openOutput',
|
|
1555
|
+
`Movie file is missing at ${output.fileUrl}`,
|
|
1556
|
+
cause,
|
|
1557
|
+
)
|
|
1480
1558
|
}
|
|
1481
1559
|
|
|
1482
1560
|
const handle: MovieOutputHandle = {
|
|
@@ -1491,6 +1569,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
1491
1569
|
released = true
|
|
1492
1570
|
generation += 1
|
|
1493
1571
|
unbindLifecycle()
|
|
1572
|
+
if (settleDeadline !== undefined) {
|
|
1573
|
+
clearTimeout(settleDeadline)
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1494
1576
|
sink = undefined
|
|
1495
1577
|
request = undefined
|
|
1496
1578
|
wantStop = false
|
package/src/session-core.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
CameraInterruptionReason,
|
|
4
4
|
CameraPermissionKind,
|
|
5
5
|
CameraPermissionStatus,
|
|
6
|
+
CameraPreviewPoint,
|
|
6
7
|
CameraSession,
|
|
7
8
|
CameraSessionCapabilities,
|
|
8
9
|
CameraSessionConfig,
|
|
@@ -86,6 +87,10 @@ export interface CameraSessionBackend {
|
|
|
86
87
|
attachPreview(host: unknown, listener?: BackendPreviewListener): () => void
|
|
87
88
|
/** Replace configuration while idle; throws CameraCaptureError. */
|
|
88
89
|
configure?(config: CameraSessionConfig): void
|
|
90
|
+
/** Meter one autofocus/auto-exposure pass at a normalized preview-space
|
|
91
|
+
* point — the tap-to-focus primitive. Absent means the adapter cannot
|
|
92
|
+
* meter at a point; the core turns it into a no-op. */
|
|
93
|
+
focusAtPoint?(point: CameraPreviewPoint): void
|
|
89
94
|
/** Synchronous platform-specific admission checks; creates no attempt on failure. */
|
|
90
95
|
validateStart?(options: StartRecordingOptions): void
|
|
91
96
|
/** Begin capture for an admitted attempt. Async failures settle via
|
|
@@ -123,6 +128,34 @@ const issue = (
|
|
|
123
128
|
extra?: Partial<CaptureIssue>,
|
|
124
129
|
): CameraCaptureError => new CameraCaptureError({ kind, operation, message, ...extra })
|
|
125
130
|
|
|
131
|
+
/** Collapse '.' and '..' segments without touching the filesystem — the
|
|
132
|
+
* adapters' app-private containment checks compare against this canonical
|
|
133
|
+
* form, so a '..' inside the requested path can never keep the root
|
|
134
|
+
* prefix while escaping it. */
|
|
135
|
+
export const canonicalizePath = (raw: string, separator = '/'): string => {
|
|
136
|
+
const rooted = raw.startsWith(separator)
|
|
137
|
+
const parts: string[] = []
|
|
138
|
+
for (const part of raw.split(separator)) {
|
|
139
|
+
if (part === '' || part === '.') {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (part === '..') {
|
|
144
|
+
if (parts.length > 0 && parts[parts.length - 1] !== '..') {
|
|
145
|
+
parts.pop()
|
|
146
|
+
} else if (!rooted) {
|
|
147
|
+
parts.push(part)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
parts.push(part)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (rooted ? separator : '') + parts.join(separator)
|
|
157
|
+
}
|
|
158
|
+
|
|
126
159
|
/** The concrete session object wrapping a platform backend. Internal to
|
|
127
160
|
* the package; `CameraSession` is the public shape. */
|
|
128
161
|
export class SharedCameraSession implements CameraSession {
|
|
@@ -283,7 +316,14 @@ export class SharedCameraSession implements CameraSession {
|
|
|
283
316
|
return status
|
|
284
317
|
})
|
|
285
318
|
|
|
286
|
-
|
|
319
|
+
// Store a swallowed continuation — a rejected backend call must not
|
|
320
|
+
// poison the chain and refuse every later request with the original
|
|
321
|
+
// error. The caller still receives the real rejection.
|
|
322
|
+
this.requestChains.set(
|
|
323
|
+
kind,
|
|
324
|
+
chain.catch(() => 'unknown' as CameraPermissionStatus),
|
|
325
|
+
)
|
|
326
|
+
|
|
287
327
|
return chain
|
|
288
328
|
}
|
|
289
329
|
|
|
@@ -293,6 +333,21 @@ export class SharedCameraSession implements CameraSession {
|
|
|
293
333
|
return capabilities
|
|
294
334
|
}
|
|
295
335
|
|
|
336
|
+
focusAtPoint(point: CameraPreviewPoint): void {
|
|
337
|
+
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
338
|
+
throw issue('invalidArgument', 'focusAtPoint', 'point.x and point.y must be finite numbers')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (this.disposed || this.disposeRequested) {
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this.backend.focusAtPoint?.({
|
|
346
|
+
x: Math.min(1, Math.max(0, point.x)),
|
|
347
|
+
y: Math.min(1, Math.max(0, point.y)),
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
296
351
|
async configure(next: Partial<CameraSessionConfig>): Promise<void> {
|
|
297
352
|
if (this.disposed || this.disposeRequested) {
|
|
298
353
|
throw issue('invalidState', 'configure', 'CameraSession is disposed')
|
|
@@ -414,11 +469,22 @@ export class SharedCameraSession implements CameraSession {
|
|
|
414
469
|
}
|
|
415
470
|
}
|
|
416
471
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
!options.destinationFileUrl.startsWith('file://')
|
|
420
|
-
|
|
421
|
-
|
|
472
|
+
let destinationPath: string | undefined
|
|
473
|
+
if (options.destinationFileUrl !== undefined) {
|
|
474
|
+
if (!options.destinationFileUrl.startsWith('file://')) {
|
|
475
|
+
throw issue('invalidArgument', operation, 'destinationFileUrl must be a local file:// URL')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
destinationPath = decodeURIComponent(options.destinationFileUrl.slice('file://'.length))
|
|
480
|
+
} catch (cause) {
|
|
481
|
+
throw issue(
|
|
482
|
+
'invalidArgument',
|
|
483
|
+
operation,
|
|
484
|
+
'destinationFileUrl is not valid percent-encoding',
|
|
485
|
+
{ cause },
|
|
486
|
+
)
|
|
487
|
+
}
|
|
422
488
|
}
|
|
423
489
|
|
|
424
490
|
if (
|
|
@@ -577,9 +643,7 @@ export class SharedCameraSession implements CameraSession {
|
|
|
577
643
|
audio: this.config.audio === true,
|
|
578
644
|
orientation: options.orientation,
|
|
579
645
|
maximumDurationMs: options.maximumDurationMs,
|
|
580
|
-
destinationPath
|
|
581
|
-
? decodeURIComponent(options.destinationFileUrl.slice('file://'.length))
|
|
582
|
-
: undefined,
|
|
646
|
+
destinationPath,
|
|
583
647
|
}
|
|
584
648
|
|
|
585
649
|
try {
|
package/src/synthetic-session.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import type {
|
|
11
11
|
CameraInterruptionReason,
|
|
12
12
|
CameraPermissionStatus,
|
|
13
|
+
CameraPreviewPoint,
|
|
13
14
|
CameraSession,
|
|
14
15
|
CameraSessionCapabilities,
|
|
15
16
|
CameraSessionConfig,
|
|
@@ -47,6 +48,9 @@ export interface SyntheticCameraScript {
|
|
|
47
48
|
reason: CameraInterruptionReason
|
|
48
49
|
endsAfterMs?: number
|
|
49
50
|
}
|
|
51
|
+
/** When set, each `focusAtPoint` call pushes its normalized point
|
|
52
|
+
* here so a spec can observe metering requests. */
|
|
53
|
+
focusRequests?: CameraPreviewPoint[]
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export interface SyntheticCameraStorage {
|
|
@@ -92,6 +96,9 @@ const capabilities: CameraSessionCapabilities = {
|
|
|
92
96
|
],
|
|
93
97
|
profiles: [{ profile: 'standard', width: 1280, height: 720, frameRate: 30 }],
|
|
94
98
|
audio: true,
|
|
99
|
+
// The seam accepts metering points so tests exercise the supported path.
|
|
100
|
+
focusPointOfInterest: true,
|
|
101
|
+
exposurePointOfInterest: true,
|
|
95
102
|
orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
|
|
96
103
|
durationLimit: 'native',
|
|
97
104
|
elapsedTime: 'nativeMedia',
|
|
@@ -238,6 +245,9 @@ function createSyntheticBackend(
|
|
|
238
245
|
reason: script.available ? undefined : 'Synthetic camera is unavailable',
|
|
239
246
|
}),
|
|
240
247
|
previewReady: () => attached && script.available,
|
|
248
|
+
focusAtPoint: (point) => {
|
|
249
|
+
script.focusRequests?.push(point)
|
|
250
|
+
},
|
|
241
251
|
attachPreview: (_host, listener) => {
|
|
242
252
|
attached = true
|
|
243
253
|
queueMicrotask(() => {
|
package/src/types.ts
CHANGED
|
@@ -79,6 +79,16 @@ export class CameraCaptureError extends Error implements CaptureIssue {
|
|
|
79
79
|
|
|
80
80
|
export type CameraFacing = 'front' | 'back'
|
|
81
81
|
|
|
82
|
+
/** A normalized position on the live preview as the user sees it:
|
|
83
|
+
* `{x: 0, y: 0}` is the top-left corner and `{x: 1, y: 1}` is
|
|
84
|
+
* bottom-right, independent of sensor orientation and front-camera
|
|
85
|
+
* mirroring. The `CameraView` `onTap` event and
|
|
86
|
+
* `CameraSession.focusAtPoint` share this space. */
|
|
87
|
+
export interface CameraPreviewPoint {
|
|
88
|
+
x: number
|
|
89
|
+
y: number
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
export type CameraOrientation =
|
|
83
93
|
| 'portrait'
|
|
84
94
|
| 'portraitUpsideDown'
|
|
@@ -130,6 +140,13 @@ export interface CameraSessionCapabilities {
|
|
|
130
140
|
profiles: CameraProfileCapability[]
|
|
131
141
|
/** The session can add an audio track when permission allows. */
|
|
132
142
|
audio: boolean
|
|
143
|
+
/** `focusAtPoint` can move autofocus metering on the active camera —
|
|
144
|
+
* false on fixed-focus devices (common for front cameras) and on
|
|
145
|
+
* platforms without point-of-interest metering. */
|
|
146
|
+
focusPointOfInterest: boolean
|
|
147
|
+
/** `focusAtPoint` can move auto-exposure metering on the active
|
|
148
|
+
* camera. */
|
|
149
|
+
exposurePointOfInterest: boolean
|
|
133
150
|
/** Fixed orientations the source can lock for a take. */
|
|
134
151
|
orientations: CameraOrientation[]
|
|
135
152
|
/** `native` enforcement can still overshoot at media boundaries;
|
|
@@ -314,6 +331,17 @@ export interface CameraSession {
|
|
|
314
331
|
* subscribing late — `completion` is authoritative. Listener
|
|
315
332
|
* exceptions do not change capture. */
|
|
316
333
|
subscribe(listener: (event: CameraSessionEvent) => void): () => void
|
|
334
|
+
/** Tap-to-focus: meters one autofocus/auto-exposure pass at a
|
|
335
|
+
* normalized preview-space point (see `CameraPreviewPoint`), pairing
|
|
336
|
+
* with the `CameraView` `onTap` event. No-ops while no preview is
|
|
337
|
+
* attached, after `dispose`, and on hosts without point-of-interest
|
|
338
|
+
* metering — `capabilities()` reports `focusPointOfInterest` and
|
|
339
|
+
* `exposurePointOfInterest` separately. Out-of-range coordinates
|
|
340
|
+
* clamp to the preview edge; non-finite coordinates throw
|
|
341
|
+
* `invalidArgument`. Metering is per-device state: a camera swap
|
|
342
|
+
* through `configure` returns focus/exposure to the new device's
|
|
343
|
+
* default. */
|
|
344
|
+
focusAtPoint(point: CameraPreviewPoint): void
|
|
317
345
|
/** Replaces configuration while idle. Validates against current
|
|
318
346
|
* capabilities and rejects with `invalidState` during an attempt. */
|
|
319
347
|
configure(config: Partial<CameraSessionConfig>): Promise<void>
|
package/types/props.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Octane } from 'octane/jsx-runtime';
|
|
2
|
-
import type { CameraSession } from './types';
|
|
2
|
+
import type { CameraPreviewPoint, CameraSession } from './types';
|
|
3
3
|
export type CameraAccessibilityRole = 'button' | 'link' | 'search' | 'image' | 'heading' | 'adjustable' | 'summary' | 'text' | 'none' | 'progressbar' | 'checkbox' | 'switch' | 'radio' | 'spinbutton' | 'tab';
|
|
4
4
|
export interface CameraViewHandle {
|
|
5
5
|
/** The native preview surface (`HTMLVideoElement` on web). */
|
|
@@ -44,6 +44,13 @@ export interface CameraViewProps {
|
|
|
44
44
|
onError?: (error: {
|
|
45
45
|
message?: string;
|
|
46
46
|
}) => void;
|
|
47
|
+
/** Notification-only tap event — fires with the normalized
|
|
48
|
+
* preview-space position (`CameraPreviewPoint`) and never consumes
|
|
49
|
+
* the gesture, so recognizers layered above the preview (double-tap,
|
|
50
|
+
* pan) still run. Pair with `session.focusAtPoint` for tap-to-focus.
|
|
51
|
+
* Delivered on iOS, Android, and web; desktop runtimes do not dispatch
|
|
52
|
+
* it yet. */
|
|
53
|
+
onTap?: (point: CameraPreviewPoint) => void;
|
|
47
54
|
/** Native preview surface for platform-specific controls. */
|
|
48
55
|
ref?: Octane.Ref<CameraViewHandle>;
|
|
49
56
|
/** Platform-specific properties applied to the preview surface. */
|
package/types/session-core.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CameraInterruptionReason, CameraPermissionKind, CameraPermissionStatus, CameraSession, CameraSessionCapabilities, CameraSessionConfig, CameraSessionEvent, CameraSessionSnapshot, CaptureIssue, EffectiveCaptureConfig, MovieEndReason, MovieOutput, MovieOutputHandle, MovieTake, StartRecordingOptions } from './types';
|
|
1
|
+
import type { CameraInterruptionReason, CameraPermissionKind, CameraPermissionStatus, CameraPreviewPoint, CameraSession, CameraSessionCapabilities, CameraSessionConfig, CameraSessionEvent, CameraSessionSnapshot, CaptureIssue, EffectiveCaptureConfig, MovieEndReason, MovieOutput, MovieOutputHandle, MovieTake, StartRecordingOptions } from './types';
|
|
2
2
|
/** Result an adapter reports when a capture attempt settles. Media
|
|
3
3
|
* metadata is already read from the finalized movie by the adapter. */
|
|
4
4
|
export type BackendSettle = {
|
|
@@ -60,6 +60,10 @@ export interface CameraSessionBackend {
|
|
|
60
60
|
attachPreview(host: unknown, listener?: BackendPreviewListener): () => void;
|
|
61
61
|
/** Replace configuration while idle; throws CameraCaptureError. */
|
|
62
62
|
configure?(config: CameraSessionConfig): void;
|
|
63
|
+
/** Meter one autofocus/auto-exposure pass at a normalized preview-space
|
|
64
|
+
* point — the tap-to-focus primitive. Absent means the adapter cannot
|
|
65
|
+
* meter at a point; the core turns it into a no-op. */
|
|
66
|
+
focusAtPoint?(point: CameraPreviewPoint): void;
|
|
63
67
|
/** Synchronous platform-specific admission checks; creates no attempt on failure. */
|
|
64
68
|
validateStart?(options: StartRecordingOptions): void;
|
|
65
69
|
/** Begin capture for an admitted attempt. Async failures settle via
|
|
@@ -86,6 +90,11 @@ export interface BackendObserver {
|
|
|
86
90
|
/** Preview became ready/unready (session running state changed). */
|
|
87
91
|
previewReady(ready: boolean): void;
|
|
88
92
|
}
|
|
93
|
+
/** Collapse '.' and '..' segments without touching the filesystem — the
|
|
94
|
+
* adapters' app-private containment checks compare against this canonical
|
|
95
|
+
* form, so a '..' inside the requested path can never keep the root
|
|
96
|
+
* prefix while escaping it. */
|
|
97
|
+
export declare const canonicalizePath: (raw: string, separator?: string) => string;
|
|
89
98
|
/** The concrete session object wrapping a platform backend. Internal to
|
|
90
99
|
* the package; `CameraSession` is the public shape. */
|
|
91
100
|
export declare class SharedCameraSession implements CameraSession {
|
|
@@ -117,6 +126,7 @@ export declare class SharedCameraSession implements CameraSession {
|
|
|
117
126
|
}>;
|
|
118
127
|
requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>;
|
|
119
128
|
capabilities(): Promise<CameraSessionCapabilities>;
|
|
129
|
+
focusAtPoint(point: CameraPreviewPoint): void;
|
|
120
130
|
configure(next: Partial<CameraSessionConfig>): Promise<void>;
|
|
121
131
|
private validateCameraSelection;
|
|
122
132
|
private requestStop;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CameraInterruptionReason, CameraPermissionStatus, CameraSession, CameraSessionConfig } from './types';
|
|
1
|
+
import type { CameraInterruptionReason, CameraPermissionStatus, CameraPreviewPoint, CameraSession, CameraSessionConfig } from './types';
|
|
2
2
|
/** Development/testing seam — a deterministic camera source that drives
|
|
3
3
|
* the shared session state machine without hardware. NOT a production
|
|
4
4
|
* recording source: outcomes carry `synthetic: true` on their output so
|
|
@@ -30,6 +30,9 @@ export interface SyntheticCameraScript {
|
|
|
30
30
|
reason: CameraInterruptionReason;
|
|
31
31
|
endsAfterMs?: number;
|
|
32
32
|
};
|
|
33
|
+
/** When set, each `focusAtPoint` call pushes its normalized point
|
|
34
|
+
* here so a spec can observe metering requests. */
|
|
35
|
+
focusRequests?: CameraPreviewPoint[];
|
|
33
36
|
}
|
|
34
37
|
export interface SyntheticCameraStorage {
|
|
35
38
|
write(path: string, bytes: Uint8Array): void | Promise<void>;
|
package/types/types.d.ts
CHANGED
|
@@ -43,6 +43,15 @@ export declare class CameraCaptureError extends Error implements CaptureIssue {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
export type CameraFacing = 'front' | 'back';
|
|
46
|
+
/** A normalized position on the live preview as the user sees it:
|
|
47
|
+
* `{x: 0, y: 0}` is the top-left corner and `{x: 1, y: 1}` is
|
|
48
|
+
* bottom-right, independent of sensor orientation and front-camera
|
|
49
|
+
* mirroring. The `CameraView` `onTap` event and
|
|
50
|
+
* `CameraSession.focusAtPoint` share this space. */
|
|
51
|
+
export interface CameraPreviewPoint {
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
}
|
|
46
55
|
export type CameraOrientation = 'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight' | 'unspecified';
|
|
47
56
|
/** Camera selection for `CameraSessionConfig.camera`. Desktop and browser
|
|
48
57
|
* hosts expose device identities instead of front/back semantics. */
|
|
@@ -86,6 +95,13 @@ export interface CameraSessionCapabilities {
|
|
|
86
95
|
profiles: CameraProfileCapability[];
|
|
87
96
|
/** The session can add an audio track when permission allows. */
|
|
88
97
|
audio: boolean;
|
|
98
|
+
/** `focusAtPoint` can move autofocus metering on the active camera —
|
|
99
|
+
* false on fixed-focus devices (common for front cameras) and on
|
|
100
|
+
* platforms without point-of-interest metering. */
|
|
101
|
+
focusPointOfInterest: boolean;
|
|
102
|
+
/** `focusAtPoint` can move auto-exposure metering on the active
|
|
103
|
+
* camera. */
|
|
104
|
+
exposurePointOfInterest: boolean;
|
|
89
105
|
/** Fixed orientations the source can lock for a take. */
|
|
90
106
|
orientations: CameraOrientation[];
|
|
91
107
|
/** `native` enforcement can still overshoot at media boundaries;
|
|
@@ -248,6 +264,17 @@ export interface CameraSession {
|
|
|
248
264
|
* subscribing late — `completion` is authoritative. Listener
|
|
249
265
|
* exceptions do not change capture. */
|
|
250
266
|
subscribe(listener: (event: CameraSessionEvent) => void): () => void;
|
|
267
|
+
/** Tap-to-focus: meters one autofocus/auto-exposure pass at a
|
|
268
|
+
* normalized preview-space point (see `CameraPreviewPoint`), pairing
|
|
269
|
+
* with the `CameraView` `onTap` event. No-ops while no preview is
|
|
270
|
+
* attached, after `dispose`, and on hosts without point-of-interest
|
|
271
|
+
* metering — `capabilities()` reports `focusPointOfInterest` and
|
|
272
|
+
* `exposurePointOfInterest` separately. Out-of-range coordinates
|
|
273
|
+
* clamp to the preview edge; non-finite coordinates throw
|
|
274
|
+
* `invalidArgument`. Metering is per-device state: a camera swap
|
|
275
|
+
* through `configure` returns focus/exposure to the new device's
|
|
276
|
+
* default. */
|
|
277
|
+
focusAtPoint(point: CameraPreviewPoint): void;
|
|
251
278
|
/** Replaces configuration while idle. Validates against current
|
|
252
279
|
* capabilities and rejects with `invalidState` during an attempt. */
|
|
253
280
|
configure(config: Partial<CameraSessionConfig>): Promise<void>;
|