@octane-xplat/camera 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -18
- package/src/CameraView.ios.tsrx +4 -17
- package/src/CameraView.macos.tsrx +4 -17
- package/src/CameraView.web.tsrx +11 -0
- package/src/CameraView.windows.tsrx +6 -18
- 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/layout-child.ts +39 -18
- package/src/layout-child.windows.ts +5 -2
- 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
|
@@ -7,7 +7,9 @@ import type { CameraViewProps } from './props'
|
|
|
7
7
|
import { attachSessionPreview } from './session'
|
|
8
8
|
import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
|
|
9
9
|
import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
|
|
10
|
-
import {
|
|
10
|
+
import { observePreviewTap } from './preview-tap'
|
|
11
|
+
import { java } from '@octane-xplat/platform/android'
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class CameraPreview extends View {
|
|
13
15
|
createNativeView() {
|
|
@@ -126,21 +128,9 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
126
128
|
applyEscapeProps(view.android, props)
|
|
127
129
|
}, [props.ref, props.android])
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
style={{ backgroundColor: '#000', ...props.style }}
|
|
135
|
-
{...layoutChildProps(props)}
|
|
136
|
-
accessible={props.accessible}
|
|
137
|
-
accessibilityLabel={props.accessibilityLabel}
|
|
138
|
-
accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
|
|
139
|
-
accessibilityHint={props.accessibilityHint}
|
|
140
|
-
accessibilityValue={props.accessibilityValue}
|
|
141
|
-
accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
|
|
142
|
-
accessibilityLiveRegion={props.accessibilityLiveRegion as any}
|
|
143
|
-
>
|
|
144
|
-
<camerapreview ref={bindHost} row={0} col={0} />
|
|
145
|
-
</gridlayout>
|
|
131
|
+
useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
|
|
132
|
+
|
|
133
|
+
<masonview id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}>
|
|
134
|
+
<camerapreview ref={bindHost} style={{gridRow: '1', gridColumn: '1'}} />
|
|
135
|
+
</masonview>
|
|
146
136
|
}
|
package/src/CameraView.ios.tsrx
CHANGED
|
@@ -5,7 +5,7 @@ import { cameraHardwarePresent, ensureCameraPermission } from './camera-permissi
|
|
|
5
5
|
import { startCameraPreview } from './camera-session.ios'
|
|
6
6
|
import { attachSessionPreview } from './session'
|
|
7
7
|
import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
|
|
8
|
-
import {
|
|
8
|
+
import { observePreviewTap } from './preview-tap'
|
|
9
9
|
|
|
10
10
|
/** Live iOS preview backed by AVFoundation. With `session`, the preview
|
|
11
11
|
* attaches to the session-owned AVCaptureSession so movie recording can
|
|
@@ -81,20 +81,7 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
81
81
|
applyEscapeProps(view.ios ?? view.nativeViewProtected, props)
|
|
82
82
|
}, [props.ref, props.ios])
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
rows="*"
|
|
88
|
-
columns="*"
|
|
89
|
-
className={cx('vx-layers', 'vx-cameraview', props.className)}
|
|
90
|
-
style={{ backgroundColor: '#000', ...props.style }}
|
|
91
|
-
{...layoutChildProps(props)}
|
|
92
|
-
accessible={props.accessible}
|
|
93
|
-
accessibilityLabel={props.accessibilityLabel}
|
|
94
|
-
accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
|
|
95
|
-
accessibilityHint={props.accessibilityHint}
|
|
96
|
-
accessibilityValue={props.accessibilityValue}
|
|
97
|
-
accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
|
|
98
|
-
accessibilityLiveRegion={props.accessibilityLiveRegion as any}
|
|
99
|
-
/>
|
|
84
|
+
useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
|
|
85
|
+
|
|
86
|
+
<masonview ref={bindHost} id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}/>
|
|
100
87
|
}
|
|
@@ -4,7 +4,7 @@ import type { CameraViewProps } from './props'
|
|
|
4
4
|
import { cameraHardwarePresent, ensureCameraPermission, startCameraPreview } from './camera-session.macos'
|
|
5
5
|
import { attachSessionPreview } from './session'
|
|
6
6
|
import { layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
|
|
7
|
-
import {
|
|
7
|
+
import { observePreviewTap } from './preview-tap'
|
|
8
8
|
|
|
9
9
|
/** Live AppKit preview backed by AVFoundation. With `session`, the preview
|
|
10
10
|
* attaches to the session-owned AVCaptureSession so movie recording can
|
|
@@ -74,20 +74,7 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
74
74
|
}
|
|
75
75
|
}, [active, facing, props.session])
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
rows="*"
|
|
81
|
-
columns="*"
|
|
82
|
-
className={cx('vx-layers', 'vx-cameraview', props.className)}
|
|
83
|
-
style={{ backgroundColor: '#000000', ...props.style }}
|
|
84
|
-
{...layoutChildProps(props)}
|
|
85
|
-
accessible={props.accessible}
|
|
86
|
-
accessibilityLabel={props.accessibilityLabel}
|
|
87
|
-
accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
|
|
88
|
-
accessibilityHint={props.accessibilityHint}
|
|
89
|
-
accessibilityValue={props.accessibilityValue}
|
|
90
|
-
accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
|
|
91
|
-
accessibilityLiveRegion={props.accessibilityLiveRegion as any}
|
|
92
|
-
/>
|
|
77
|
+
useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
|
|
78
|
+
|
|
79
|
+
<masonview ref={bindHost} id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}/>
|
|
93
80
|
}
|
package/src/CameraView.web.tsrx
CHANGED
|
@@ -103,6 +103,17 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
103
103
|
muted={true}
|
|
104
104
|
autoplay={true}
|
|
105
105
|
playsInline={true}
|
|
106
|
+
onClick={(event: MouseEvent) => {
|
|
107
|
+
const onTap = props.onTap
|
|
108
|
+
const target = event.currentTarget as HTMLElement | null
|
|
109
|
+
if (!onTap || !target) return
|
|
110
|
+
const rect = target.getBoundingClientRect()
|
|
111
|
+
if (!(rect.width > 0) || !(rect.height > 0)) return
|
|
112
|
+
onTap({
|
|
113
|
+
x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
|
|
114
|
+
y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
|
|
115
|
+
})
|
|
116
|
+
}}
|
|
106
117
|
style={{
|
|
107
118
|
display: 'block',
|
|
108
119
|
width: '100%',
|
|
@@ -8,7 +8,7 @@ import { attachSessionPreview } from './session'
|
|
|
8
8
|
import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.windows'
|
|
9
9
|
import { startCameraPreview } from './camera-session.windows'
|
|
10
10
|
import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child.windows'
|
|
11
|
-
import {
|
|
11
|
+
import { observePreviewTap } from './preview-tap'
|
|
12
12
|
|
|
13
13
|
class CameraPreview extends View {
|
|
14
14
|
createNativeView() {
|
|
@@ -104,21 +104,9 @@ export function CameraView(props: CameraViewProps) @{
|
|
|
104
104
|
applyEscapeProps(view.nativeViewProtected, props)
|
|
105
105
|
}, [props.ref, props.windows])
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
style={{ backgroundColor: '#000', ...props.style }}
|
|
113
|
-
{...layoutChildProps(props)}
|
|
114
|
-
accessible={props.accessible}
|
|
115
|
-
accessibilityLabel={props.accessibilityLabel}
|
|
116
|
-
accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
|
|
117
|
-
accessibilityHint={props.accessibilityHint}
|
|
118
|
-
accessibilityValue={props.accessibilityValue}
|
|
119
|
-
accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
|
|
120
|
-
accessibilityLiveRegion={props.accessibilityLiveRegion as any}
|
|
121
|
-
>
|
|
122
|
-
<camerapreview ref={bindHost} row={0} col={0} />
|
|
123
|
-
</gridlayout>
|
|
107
|
+
useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
|
|
108
|
+
|
|
109
|
+
<masonview id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}>
|
|
110
|
+
<camerapreview ref={bindHost} style={{gridRow: '1', gridColumn: '1'}} />
|
|
111
|
+
</masonview>
|
|
124
112
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Application, isAndroid, isIOS } from '@nativescript/core'
|
|
2
|
+
import { android } from '@octane-xplat/platform/android'
|
|
2
3
|
|
|
3
4
|
let requestCode = 7719
|
|
4
5
|
|
|
@@ -54,19 +55,32 @@ export async function ensureCameraPermission(): Promise<boolean> {
|
|
|
54
55
|
|
|
55
56
|
return await new Promise<boolean>((resolve) => {
|
|
56
57
|
const currentRequest = requestCode++
|
|
58
|
+
const finish = (granted: boolean) => {
|
|
59
|
+
clearTimeout(timer)
|
|
60
|
+
Application.android.off(Application.android.activityRequestPermissionsEvent, onResult)
|
|
61
|
+
|
|
62
|
+
resolve(granted)
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
const onResult = (args: any) => {
|
|
58
66
|
if (args.requestCode !== currentRequest) {
|
|
59
67
|
return
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
resolve(args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED)
|
|
70
|
+
finish(args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED)
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
// Same bound the session adapter uses — an activity destroyed or
|
|
74
|
+
// rotated before the result must not hang the caller or leak the
|
|
75
|
+
// listener.
|
|
76
|
+
const timer = setTimeout(() => finish(false), 120_000)
|
|
67
77
|
Application.android.on(Application.android.activityRequestPermissionsEvent, onResult)
|
|
68
78
|
|
|
69
|
-
|
|
79
|
+
try {
|
|
80
|
+
activity.requestPermissions([permission], currentRequest)
|
|
81
|
+
} catch {
|
|
82
|
+
finish(false)
|
|
83
|
+
}
|
|
70
84
|
})
|
|
71
85
|
}
|
|
72
86
|
} catch {
|
|
@@ -87,7 +87,10 @@ export async function startCameraPreview(
|
|
|
87
87
|
const element = view?.nativeViewProtected ?? view
|
|
88
88
|
element.Source = capture
|
|
89
89
|
try {
|
|
90
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
Number(device?.EnclosureLocation?.Panel) === 1 &&
|
|
92
|
+
typeof capture.SetPreviewMirroring === 'function'
|
|
93
|
+
) {
|
|
91
94
|
capture.SetPreviewMirroring(true)
|
|
92
95
|
}
|
|
93
96
|
} catch {
|
package/src/host-movie.web.ts
CHANGED
|
@@ -26,6 +26,19 @@ export interface CameraHostServices {
|
|
|
26
26
|
}): Promise<{ info: { exists: boolean; fileUrl?: string; size?: number } | null }>
|
|
27
27
|
readMovieFile(options: { fileUrl: string }): Promise<{ base64: string | null }>
|
|
28
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 }>
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
44
|
|
|
@@ -44,40 +57,49 @@ export type CameraHost = HostClient<CameraHostServices>
|
|
|
44
57
|
|
|
45
58
|
let hostProbe: Promise<CameraHost | null> | undefined
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const client = createHostClient<CameraHostServices>(transport)
|
|
57
|
-
const capabilities = (await client.capabilities().catch(() => null)) as {
|
|
58
|
-
camera?: string[]
|
|
59
|
-
} | null
|
|
60
|
+
const probeHost = async (): Promise<CameraHost | null> => {
|
|
61
|
+
try {
|
|
62
|
+
const transport = createWebKitTransport()
|
|
63
|
+
if (!transport) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
const client = createHostClient<CameraHostServices>(transport)
|
|
68
|
+
const capabilities = (await client.capabilities().catch(() => null)) as {
|
|
69
|
+
camera?: string[]
|
|
70
|
+
} | null
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return null
|
|
72
|
+
const methods = capabilities?.camera
|
|
73
|
+
if (Array.isArray(methods) && requiredMethods.every((name) => methods.includes(name))) {
|
|
74
|
+
return client
|
|
70
75
|
}
|
|
71
|
-
})()
|
|
72
76
|
|
|
73
|
-
|
|
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
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
const error = (kind: 'destinationUnavailable' | 'storageFailed' | 'unavailable', message: string) =>
|
|
77
100
|
new CameraCaptureError({ kind, operation: 'storage', message })
|
|
78
101
|
|
|
79
|
-
const
|
|
80
|
-
const data = new Uint8Array(await bytes.arrayBuffer())
|
|
102
|
+
const dataToBase64 = (data: Uint8Array): string => {
|
|
81
103
|
let binary = ''
|
|
82
104
|
const chunk = 0x8000
|
|
83
105
|
for (let offset = 0; offset < data.length; offset += chunk) {
|
|
@@ -87,6 +109,9 @@ const blobToBase64 = async (bytes: Blob): Promise<string> => {
|
|
|
87
109
|
return btoa(binary)
|
|
88
110
|
}
|
|
89
111
|
|
|
112
|
+
const blobToBase64 = async (bytes: Blob): Promise<string> =>
|
|
113
|
+
dataToBase64(new Uint8Array(await bytes.arrayBuffer()))
|
|
114
|
+
|
|
90
115
|
const base64ToBlob = (base64: string, type: string): Blob => {
|
|
91
116
|
const binary = atob(base64)
|
|
92
117
|
const data = new Uint8Array(binary.length)
|
|
@@ -127,12 +152,64 @@ export const reserveHostMovie = async (
|
|
|
127
152
|
return reservation
|
|
128
153
|
}
|
|
129
154
|
|
|
130
|
-
/**
|
|
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. */
|
|
131
183
|
export const commitHostMovie = async (
|
|
132
184
|
host: CameraHost,
|
|
133
185
|
fileUrl: string,
|
|
134
186
|
bytes: Blob,
|
|
135
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
|
+
|
|
136
213
|
const base64 = await blobToBase64(bytes)
|
|
137
214
|
const { file } = await host.call('camera', 'writeMovieFile', { fileUrl, base64 })
|
|
138
215
|
if (!file?.uri) {
|
|
@@ -166,6 +243,44 @@ export const openHostMovie = async (
|
|
|
166
243
|
throw error('unavailable', `Movie file is missing at ${output.fileUrl}`)
|
|
167
244
|
}
|
|
168
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
|
+
|
|
169
284
|
const { base64 } = await host.call('camera', 'readMovieFile', { fileUrl: output.fileUrl })
|
|
170
285
|
if (!base64) {
|
|
171
286
|
throw error('unavailable', `Movie file could not be read at ${output.fileUrl}`)
|
package/src/index.ts
CHANGED
package/src/index.web.ts
CHANGED
package/src/layout-child.ts
CHANGED
|
@@ -2,27 +2,48 @@ import type { CameraViewProps } from './props'
|
|
|
2
2
|
import { isIOS } from '@nativescript/core'
|
|
3
3
|
|
|
4
4
|
/** NativeScript reads layout fields from a child view during parent layout. */
|
|
5
|
-
export function layoutChildProps(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
5
|
+
export function layoutChildProps(
|
|
6
|
+
props: CameraViewProps,
|
|
7
|
+
baseStyle = props.style,
|
|
8
|
+
): { style: Record<string, any> } {
|
|
9
|
+
const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
|
|
10
|
+
if (props.row !== undefined) {
|
|
11
|
+
style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
|
|
12
|
+
} else if (props.rowSpan !== undefined) {
|
|
13
|
+
style.gridRow = `span ${props.rowSpan}`
|
|
14
|
+
}
|
|
15
|
+
if (props.col !== undefined) {
|
|
16
|
+
style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
|
|
17
|
+
} else if (props.colSpan !== undefined) {
|
|
18
|
+
style.gridColumn = `span ${props.colSpan}`
|
|
19
|
+
}
|
|
20
|
+
if (props.dock !== undefined) {
|
|
21
|
+
style.position = style.position ?? 'absolute'
|
|
22
|
+
if (props.dock === 'left' || props.dock === 'top' || props.dock === 'bottom') {
|
|
23
|
+
style.left = style.left ?? 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (props.dock === 'right' || props.dock === 'top' || props.dock === 'bottom') {
|
|
27
|
+
style.right = style.right ?? 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (props.dock === 'top' || props.dock === 'left' || props.dock === 'right') {
|
|
31
|
+
style.top = style.top ?? 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (props.dock === 'bottom' || props.dock === 'left' || props.dock === 'right') {
|
|
35
|
+
style.bottom = style.bottom ?? 0
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const key of ['left', 'top', 'flexGrow', 'flexShrink', 'alignSelf', 'order'] as const) {
|
|
20
39
|
if (props[key] !== undefined) {
|
|
21
|
-
|
|
40
|
+
style[key] = props[key]
|
|
22
41
|
}
|
|
23
42
|
}
|
|
24
|
-
|
|
25
|
-
|
|
43
|
+
if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
|
|
44
|
+
style.position = style.position ?? 'absolute'
|
|
45
|
+
}
|
|
46
|
+
return { style }
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
|
|
@@ -5,8 +5,11 @@ import type { CameraViewProps } from './props'
|
|
|
5
5
|
* branch. Pure helpers are re-declared here to keep divergence at the file
|
|
6
6
|
* boundary. */
|
|
7
7
|
|
|
8
|
-
export function layoutChildProps(
|
|
9
|
-
|
|
8
|
+
export function layoutChildProps(
|
|
9
|
+
props: CameraViewProps,
|
|
10
|
+
baseStyle = props.style,
|
|
11
|
+
): Record<string, any> {
|
|
12
|
+
const result: Record<string, any> = { style: baseStyle }
|
|
10
13
|
for (const key of [
|
|
11
14
|
'row',
|
|
12
15
|
'col',
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CameraPreviewPoint } from './types'
|
|
2
|
+
|
|
3
|
+
/** Attach a notification-only 'tap' observer to a preview host view and
|
|
4
|
+
* report each tap as a `CameraPreviewPoint` normalized to the view's
|
|
5
|
+
* bounds. The tap gesture never consumes the touch — recognizers layered
|
|
6
|
+
* above the preview still see it. Platforms whose runtime never
|
|
7
|
+
* dispatches 'tap' simply never fire the callback. */
|
|
8
|
+
export function observePreviewTap(
|
|
9
|
+
view: any,
|
|
10
|
+
onTap: ((point: CameraPreviewPoint) => void) | undefined,
|
|
11
|
+
): () => void {
|
|
12
|
+
if (!view || !onTap || typeof view.on !== 'function') {
|
|
13
|
+
return () => {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const handler = (args: any) => {
|
|
17
|
+
const size = view.getActualSize?.()
|
|
18
|
+
const width = size?.width ?? 0
|
|
19
|
+
const height = size?.height ?? 0
|
|
20
|
+
const x = typeof args?.getX === 'function' ? args.getX() : NaN
|
|
21
|
+
const y = typeof args?.getY === 'function' ? args.getY() : NaN
|
|
22
|
+
if (!(width > 0) || !(height > 0) || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onTap({
|
|
27
|
+
x: Math.min(1, Math.max(0, x / width)),
|
|
28
|
+
y: Math.min(1, Math.max(0, y / height)),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
view.on('tap', handler)
|
|
33
|
+
return () => view.off?.('tap', handler)
|
|
34
|
+
}
|
package/src/props.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
|
|
|
4
4
|
export type CameraAccessibilityRole =
|
|
5
5
|
| 'button'
|
|
@@ -60,6 +60,13 @@ export interface CameraViewProps {
|
|
|
60
60
|
/** Fires after the preview surface starts. */
|
|
61
61
|
onReady?: () => void
|
|
62
62
|
onError?: (error: { message?: string }) => void
|
|
63
|
+
/** Notification-only tap event — fires with the normalized
|
|
64
|
+
* preview-space position (`CameraPreviewPoint`) and never consumes
|
|
65
|
+
* the gesture, so recognizers layered above the preview (double-tap,
|
|
66
|
+
* pan) still run. Pair with `session.focusAtPoint` for tap-to-focus.
|
|
67
|
+
* Delivered on iOS, Android, and web; desktop runtimes do not dispatch
|
|
68
|
+
* it yet. */
|
|
69
|
+
onTap?: (point: CameraPreviewPoint) => void
|
|
63
70
|
/** Native preview surface for platform-specific controls. */
|
|
64
71
|
ref?: Octane.Ref<CameraViewHandle>
|
|
65
72
|
/** Platform-specific properties applied to the preview surface. */
|
|
@@ -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
|
BackendCaptureSink,
|
|
5
6
|
BackendObserver,
|
|
@@ -235,6 +236,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
235
236
|
cameras: value.cameras,
|
|
236
237
|
profiles: value.profiles,
|
|
237
238
|
audio: context().getPackageManager().hasSystemFeature('android.hardware.microphone'),
|
|
239
|
+
// The bridge only reports metering support once a camera is
|
|
240
|
+
// bound — absent fields read as unsupported, which is honest
|
|
241
|
+
// for the current acquisition state.
|
|
242
|
+
focusPointOfInterest: value.focusPointOfInterest === true,
|
|
243
|
+
exposurePointOfInterest: value.exposurePointOfInterest === true,
|
|
238
244
|
orientations: [],
|
|
239
245
|
durationLimit: 'native',
|
|
240
246
|
elapsedTime: 'nativeMedia',
|
|
@@ -339,8 +345,44 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
|
|
|
339
345
|
bridge.attach(host, selected())
|
|
340
346
|
}
|
|
341
347
|
},
|
|
348
|
+
focusAtPoint: (point) => {
|
|
349
|
+
try {
|
|
350
|
+
bridge?.focusAtPoint?.(point.x, point.y)
|
|
351
|
+
} catch {
|
|
352
|
+
// A missed metering pass must not fault the session.
|
|
353
|
+
}
|
|
354
|
+
},
|
|
342
355
|
startCapture: (request, target) => {
|
|
343
356
|
sink = target
|
|
357
|
+
if (request.destinationPath) {
|
|
358
|
+
// Durable output stays app-private like every other leaf —
|
|
359
|
+
// canonicalize so '..' cannot escape the files root.
|
|
360
|
+
const destination = canonicalizePath(request.destinationPath)
|
|
361
|
+
let filesRoot = ''
|
|
362
|
+
try {
|
|
363
|
+
filesRoot = context()?.getFilesDir?.().getAbsolutePath?.() ?? ''
|
|
364
|
+
} catch {
|
|
365
|
+
filesRoot = ''
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!filesRoot || !destination.startsWith(`${filesRoot}/`)) {
|
|
369
|
+
sink = undefined
|
|
370
|
+
target.settled({
|
|
371
|
+
kind: 'failed',
|
|
372
|
+
stage: 'preparation',
|
|
373
|
+
issue: error(
|
|
374
|
+
'destinationUnavailable',
|
|
375
|
+
'startRecording',
|
|
376
|
+
'destinationFileUrl must resolve inside the app-private files directory',
|
|
377
|
+
),
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
request = { ...request, destinationPath: destination }
|
|
384
|
+
}
|
|
385
|
+
|
|
344
386
|
try {
|
|
345
387
|
bridge.start(
|
|
346
388
|
request.audio,
|