@octane-xplat/camera 0.16.1 → 0.17.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/camera",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Live camera preview for Octane xplat — web getUserMedia + native capture (AVCapture / CameraX)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -43,16 +43,16 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@octane-xplat/platform": "0.
|
|
46
|
+
"@octane-xplat/platform": "0.17.1",
|
|
47
47
|
"mediabunny": "1.61.3"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@nativescript-community/octane": "0.2.5",
|
|
51
51
|
"@nativescript/core": "9.1.3",
|
|
52
|
-
"@octane-xplat/macos-renderer": "0.
|
|
52
|
+
"@octane-xplat/macos-renderer": "0.17.1",
|
|
53
53
|
"@octanejs/vite-plugin": "0.1.61",
|
|
54
|
-
"octane": "0.
|
|
55
|
-
"tsrx-typegen": "0.
|
|
54
|
+
"octane": "0.14.0",
|
|
55
|
+
"tsrx-typegen": "0.4.0",
|
|
56
56
|
"typescript": "5.9.3",
|
|
57
57
|
"vite": "8.3.0",
|
|
58
58
|
"vitest": "4.1.11"
|
|
@@ -66,6 +66,7 @@ public final class CameraSessionBridge implements LifecycleOwner {
|
|
|
66
66
|
private boolean resumeAfterFinalization;
|
|
67
67
|
private boolean ownsDestination;
|
|
68
68
|
private boolean ready;
|
|
69
|
+
private boolean streaming;
|
|
69
70
|
private boolean started;
|
|
70
71
|
private boolean finalizing;
|
|
71
72
|
private final java.util.concurrent.ExecutorService finalizer = java.util.concurrent.Executors.newSingleThreadExecutor();
|
|
@@ -89,7 +90,12 @@ public final class CameraSessionBridge implements LifecycleOwner {
|
|
|
89
90
|
}
|
|
90
91
|
@Override public Lifecycle getLifecycle() { return lifecycle; }
|
|
91
92
|
private void emit(String type, String value) { if (listener != null) listener.event(type, value); }
|
|
92
|
-
private void ready(boolean value) { ready = value; emit("ready", Boolean.toString(value)); }
|
|
93
|
+
private void ready(boolean value) { if (ready == value) return; ready = value; emit("ready", Boolean.toString(value)); }
|
|
94
|
+
/** The "ready" event means the JS `previewReady()` gate is satisfied,
|
|
95
|
+
* not just that the preview view reports streaming — a stream-state
|
|
96
|
+
* event can lead the first measured frame, and a stale STREAMING
|
|
97
|
+
* replayed by LiveData on rebind can precede the new camera's open. */
|
|
98
|
+
private void updateReady() { ready(streaming && sourceFrameRate > 0); }
|
|
93
99
|
public boolean isReady() { return ready && sourceFrameRate > 0 && !detached && !released; }
|
|
94
100
|
public long elapsedMs() { return elapsed; }
|
|
95
101
|
private void failure(String stage, String kind, String message) {
|
|
@@ -129,7 +135,12 @@ public final class CameraSessionBridge implements LifecycleOwner {
|
|
|
129
135
|
if (released || epoch != generation) return;
|
|
130
136
|
Long timestamp = result.get(android.hardware.camera2.CaptureResult.SENSOR_TIMESTAMP);
|
|
131
137
|
if (timestamp != null && timestamp > lastFrameTime) {
|
|
132
|
-
if (lastFrameTime > 0)
|
|
138
|
+
if (lastFrameTime > 0) {
|
|
139
|
+
boolean firstRate = sourceFrameRate == 0;
|
|
140
|
+
sourceFrameRate = 1_000_000_000.0 / (timestamp - lastFrameTime);
|
|
141
|
+
// Runs on the camera executor; readiness is emitted on main.
|
|
142
|
+
if (firstRate) handler.post(() -> updateReady());
|
|
143
|
+
}
|
|
133
144
|
lastFrameTime = timestamp;
|
|
134
145
|
}
|
|
135
146
|
}
|
|
@@ -139,18 +150,34 @@ public final class CameraSessionBridge implements LifecycleOwner {
|
|
|
139
150
|
camera = provider.bindToLifecycle(this, selector, preview, video);
|
|
140
151
|
camera.getCameraInfo().getCameraState().observe(this, state -> {
|
|
141
152
|
if (released || epoch != generation) return;
|
|
142
|
-
if (state.getError() != null
|
|
143
|
-
ready(false);
|
|
153
|
+
if (state.getError() != null) {
|
|
144
154
|
emit("availability", "false");
|
|
155
|
+
ready(false);
|
|
156
|
+
if (recording != null) { emit("interruption", "cameraInUse"); stop(); }
|
|
157
|
+
} else if (state.getType() == CameraState.Type.CLOSED) {
|
|
158
|
+
// Plain CLOSED is the camera's normal resting state —
|
|
159
|
+
// every bind starts here and it can be delivered late
|
|
160
|
+
// relative to stream state — so it must not flip
|
|
161
|
+
// availability while a live preview keeps streaming.
|
|
162
|
+
ready(false);
|
|
145
163
|
if (recording != null) { emit("interruption", "cameraInUse"); stop(); }
|
|
164
|
+
} else if (state.getType() == CameraState.Type.OPEN) {
|
|
165
|
+
// An open recovers availability without waiting for a
|
|
166
|
+
// stream-state cycle, which never re-fires while
|
|
167
|
+
// STREAMING persists through an internal retry.
|
|
168
|
+
emit("availability", "true");
|
|
169
|
+
updateReady();
|
|
146
170
|
}
|
|
147
171
|
});
|
|
148
172
|
observedHost = view;
|
|
149
173
|
view.getStreamState().observe(this, state -> {
|
|
150
174
|
if (host != view || detached || released || epoch != generation) return;
|
|
151
|
-
|
|
152
|
-
ready
|
|
175
|
+
streaming = state == androidx.camera.view.PreviewView.StreamState.STREAMING;
|
|
176
|
+
// Availability/recovery must land before "ready": onReady
|
|
177
|
+
// consumers may admit recording synchronously, and the
|
|
178
|
+
// session gates on the flags these events update.
|
|
153
179
|
if (streaming) { emit("availability", "true"); emit("recovered", "unknown"); }
|
|
180
|
+
updateReady();
|
|
154
181
|
});
|
|
155
182
|
} catch (Exception cause) { ready(false); emit("previewError", cause.toString()); unbind(); }
|
|
156
183
|
}, ContextCompat.getMainExecutor(context));
|
|
@@ -167,14 +194,16 @@ public final class CameraSessionBridge implements LifecycleOwner {
|
|
|
167
194
|
}
|
|
168
195
|
public void detach() {
|
|
169
196
|
detached = true;
|
|
197
|
+
streaming = false;
|
|
170
198
|
generation++;
|
|
171
199
|
host = null;
|
|
172
200
|
ready(false);
|
|
173
201
|
if (recording == null) unbind();
|
|
174
202
|
}
|
|
175
|
-
public void background() { backgrounded = true; resumeAfterFinalization = false; ready(false); emit("interruption", "background"); if (recording != null) stop(); else unbind(); }
|
|
203
|
+
public void background() { backgrounded = true; resumeAfterFinalization = false; streaming = false; ready(false); emit("interruption", "background"); if (recording != null) stop(); else unbind(); }
|
|
176
204
|
public void foreground() { backgrounded = false; emit("recovered", "background"); if (recording != null) { resumeAfterFinalization = true; } else if (host != null && !detached) { unbind(); attach(host, selection); } }
|
|
177
205
|
private void unbind() {
|
|
206
|
+
streaming = false;
|
|
178
207
|
if (camera != null) camera.getCameraInfo().getCameraState().removeObservers(this);
|
|
179
208
|
if (observedHost != null) { observedHost.getStreamState().removeObservers(this); observedHost = null; }
|
|
180
209
|
if (provider != null) {
|
package/src/layout-child.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { CameraViewProps } from './props'
|
|
2
2
|
import { isIOS } from '@nativescript/core'
|
|
3
|
+
import { mergeStyle } from './style-merge'
|
|
3
4
|
|
|
4
5
|
/** NativeScript reads layout fields from a child view during parent layout. */
|
|
5
6
|
export function layoutChildProps(
|
|
6
7
|
props: CameraViewProps,
|
|
7
|
-
baseStyle = props.style,
|
|
8
|
-
)
|
|
8
|
+
baseStyle: Record<string, any> | string | undefined = props.style,
|
|
9
|
+
) {
|
|
9
10
|
const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
|
|
10
11
|
if (props.row !== undefined) {
|
|
11
12
|
style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
|
|
@@ -43,7 +44,7 @@ export function layoutChildProps(
|
|
|
43
44
|
if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
|
|
44
45
|
style.position = style.position ?? 'absolute'
|
|
45
46
|
}
|
|
46
|
-
return { style }
|
|
47
|
+
return { style: typeof baseStyle === 'string' ? mergeStyle(style, baseStyle) : style }
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CameraViewProps } from './props'
|
|
2
|
+
import { mergeStyle } from './style-merge'
|
|
2
3
|
|
|
3
4
|
/** Windows twins of the layout-child helpers — the unsuffixed module
|
|
4
5
|
* resolves the platform escape bag through `isIOS`, which has no Windows
|
|
@@ -7,28 +8,43 @@ import type { CameraViewProps } from './props'
|
|
|
7
8
|
|
|
8
9
|
export function layoutChildProps(
|
|
9
10
|
props: CameraViewProps,
|
|
10
|
-
baseStyle = props.style,
|
|
11
|
-
)
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
'
|
|
25
|
-
|
|
11
|
+
baseStyle: Record<string, any> | string | undefined = props.style,
|
|
12
|
+
) {
|
|
13
|
+
const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
|
|
14
|
+
if (props.row !== undefined) {
|
|
15
|
+
style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
|
|
16
|
+
} else if (props.rowSpan !== undefined) {
|
|
17
|
+
style.gridRow = `span ${props.rowSpan}`
|
|
18
|
+
}
|
|
19
|
+
if (props.col !== undefined) {
|
|
20
|
+
style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
|
|
21
|
+
} else if (props.colSpan !== undefined) {
|
|
22
|
+
style.gridColumn = `span ${props.colSpan}`
|
|
23
|
+
}
|
|
24
|
+
if (props.dock !== undefined) {
|
|
25
|
+
style.position = style.position ?? 'absolute'
|
|
26
|
+
if (props.dock === 'left' || props.dock === 'top' || props.dock === 'bottom') {
|
|
27
|
+
style.left = style.left ?? 0
|
|
28
|
+
}
|
|
29
|
+
if (props.dock === 'right' || props.dock === 'top' || props.dock === 'bottom') {
|
|
30
|
+
style.right = style.right ?? 0
|
|
31
|
+
}
|
|
32
|
+
if (props.dock === 'top' || props.dock === 'left' || props.dock === 'right') {
|
|
33
|
+
style.top = style.top ?? 0
|
|
34
|
+
}
|
|
35
|
+
if (props.dock === 'bottom' || props.dock === 'left' || props.dock === 'right') {
|
|
36
|
+
style.bottom = style.bottom ?? 0
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const key of ['left', 'top', 'flexGrow', 'flexShrink', 'alignSelf', 'order'] as const) {
|
|
26
40
|
if (props[key] !== undefined) {
|
|
27
|
-
|
|
41
|
+
style[key] = props[key]
|
|
28
42
|
}
|
|
29
43
|
}
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
|
|
45
|
+
style.position = style.position ?? 'absolute'
|
|
46
|
+
}
|
|
47
|
+
return { style: typeof baseStyle === 'string' ? mergeStyle(style, baseStyle) : style }
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
export function applyEscapeProps(view: any, props: { windows?: any }): void {
|
package/src/session-core.ts
CHANGED
|
@@ -170,6 +170,7 @@ export class SharedCameraSession implements CameraSession {
|
|
|
170
170
|
private cameraAvailable = true
|
|
171
171
|
private previewAttached = false
|
|
172
172
|
private interrupted = false
|
|
173
|
+
private pendingOnReady: (() => void) | undefined
|
|
173
174
|
private disposeRequested = false
|
|
174
175
|
private disposed = false
|
|
175
176
|
private lastCapabilities: CameraSessionCapabilities | undefined
|
|
@@ -195,7 +196,14 @@ export class SharedCameraSession implements CameraSession {
|
|
|
195
196
|
backend.setObserver({
|
|
196
197
|
interruption: (phase, reason) => this.handleInterruption(phase, reason),
|
|
197
198
|
availability: (available) => this.handleAvailability(available),
|
|
198
|
-
previewReady: () => {
|
|
199
|
+
previewReady: (ready) => {
|
|
200
|
+
// A retracted readiness signal invalidates a pending onReady;
|
|
201
|
+
// a delivered one retries admission.
|
|
202
|
+
if (!ready) {
|
|
203
|
+
this.pendingOnReady = undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.flushPendingReady()
|
|
199
207
|
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
200
208
|
},
|
|
201
209
|
})
|
|
@@ -212,7 +220,12 @@ export class SharedCameraSession implements CameraSession {
|
|
|
212
220
|
}
|
|
213
221
|
|
|
214
222
|
const detach = this.backend.attachPreview(host, {
|
|
215
|
-
onReady: listener?.onReady
|
|
223
|
+
onReady: listener?.onReady
|
|
224
|
+
? () => {
|
|
225
|
+
this.pendingOnReady = listener.onReady
|
|
226
|
+
this.flushPendingReady()
|
|
227
|
+
}
|
|
228
|
+
: undefined,
|
|
216
229
|
onError: (captureIssue) => {
|
|
217
230
|
listener?.onError?.(captureIssue)
|
|
218
231
|
},
|
|
@@ -235,6 +248,7 @@ export class SharedCameraSession implements CameraSession {
|
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
this.previewAttached = false
|
|
251
|
+
this.pendingOnReady = undefined
|
|
238
252
|
const attempt = this.attempt
|
|
239
253
|
if (attempt && (attempt.state === 'starting' || attempt.state === 'recording')) {
|
|
240
254
|
this.requestStop('previewReleased')
|
|
@@ -245,6 +259,22 @@ export class SharedCameraSession implements CameraSession {
|
|
|
245
259
|
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
246
260
|
}
|
|
247
261
|
|
|
262
|
+
/** `onReady` means "recording can start": it is delivered only while
|
|
263
|
+
* the session would actually admit `startRecording`. Adapters emit
|
|
264
|
+
* their availability/interruption observations in their own order, so
|
|
265
|
+
* a ready signal that arrives before those updates settles pends
|
|
266
|
+
* until the next lifecycle event makes admission possible — or is
|
|
267
|
+
* dropped if readiness is retracted first. */
|
|
268
|
+
private flushPendingReady() {
|
|
269
|
+
const pending = this.pendingOnReady
|
|
270
|
+
if (!pending || this.interrupted || !this.cameraAvailable || !this.backend.previewReady()) {
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.pendingOnReady = undefined
|
|
275
|
+
pending()
|
|
276
|
+
}
|
|
277
|
+
|
|
248
278
|
private emit(event: CameraSessionEvent) {
|
|
249
279
|
for (const listener of this.listeners) {
|
|
250
280
|
try {
|
|
@@ -423,6 +453,7 @@ export class SharedCameraSession implements CameraSession {
|
|
|
423
453
|
this.requestStop(cause)
|
|
424
454
|
}
|
|
425
455
|
|
|
456
|
+
this.flushPendingReady()
|
|
426
457
|
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
427
458
|
}
|
|
428
459
|
|
|
@@ -437,6 +468,7 @@ export class SharedCameraSession implements CameraSession {
|
|
|
437
468
|
this.requestStop('cameraUnavailable')
|
|
438
469
|
}
|
|
439
470
|
|
|
471
|
+
this.flushPendingReady()
|
|
440
472
|
this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
|
|
441
473
|
}
|
|
442
474
|
|
|
@@ -713,6 +745,7 @@ export class SharedCameraSession implements CameraSession {
|
|
|
713
745
|
}
|
|
714
746
|
|
|
715
747
|
this.disposed = true
|
|
748
|
+
this.pendingOnReady = undefined
|
|
716
749
|
this.backend.setObserver(undefined)
|
|
717
750
|
try {
|
|
718
751
|
await this.backend.release()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const kebab = (key: string) => key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
|
|
2
|
+
|
|
3
|
+
/** Anything accepted as a style value, including nested arrays. */
|
|
4
|
+
export type StyleInput =
|
|
5
|
+
| string
|
|
6
|
+
| Record<string, any>
|
|
7
|
+
| null
|
|
8
|
+
| undefined
|
|
9
|
+
| readonly StyleInput[]
|
|
10
|
+
|
|
11
|
+
/** Merge component-emitted style defaults under the authored `style` prop.
|
|
12
|
+
* Authored keys win; array styles fold in order; a string style is appended
|
|
13
|
+
* after the serialized defaults so its declarations override them too. */
|
|
14
|
+
export function mergeStyle(
|
|
15
|
+
base: Record<string, any>,
|
|
16
|
+
style?: Record<string, any> | readonly (Record<string, any> | null | undefined)[] | null,
|
|
17
|
+
): Record<string, any>
|
|
18
|
+
export function mergeStyle(
|
|
19
|
+
base: Record<string, any>,
|
|
20
|
+
style?: StyleInput,
|
|
21
|
+
): string | Record<string, any>
|
|
22
|
+
export function mergeStyle(
|
|
23
|
+
base: Record<string, any>,
|
|
24
|
+
style?: StyleInput,
|
|
25
|
+
): string | Record<string, any> {
|
|
26
|
+
if (style == null) {
|
|
27
|
+
return base
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let obj: Record<string, any> = { ...base }
|
|
31
|
+
const strings: string[] = []
|
|
32
|
+
const add = (s: StyleInput) => {
|
|
33
|
+
if (s == null) {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (typeof s === 'string') {
|
|
37
|
+
strings.push(s)
|
|
38
|
+
} else if (Array.isArray(s)) {
|
|
39
|
+
for (const part of s) {
|
|
40
|
+
add(part)
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
obj = { ...obj, ...s }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
add(style)
|
|
47
|
+
|
|
48
|
+
if (strings.length === 0) {
|
|
49
|
+
return obj
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const prefix = Object.entries(obj)
|
|
53
|
+
.filter(([, v]) => v != null)
|
|
54
|
+
.map(([k, v]) => `${kebab(k)}:${v}`)
|
|
55
|
+
.join(';')
|
|
56
|
+
return [prefix, ...strings].filter(Boolean).join(';')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type StyleProp = string | Record<string, any> | readonly StyleProp[] | null | undefined
|
package/types/session-core.d.ts
CHANGED
|
@@ -106,6 +106,7 @@ export declare class SharedCameraSession implements CameraSession {
|
|
|
106
106
|
private cameraAvailable;
|
|
107
107
|
private previewAttached;
|
|
108
108
|
private interrupted;
|
|
109
|
+
private pendingOnReady;
|
|
109
110
|
private disposeRequested;
|
|
110
111
|
private disposed;
|
|
111
112
|
private lastCapabilities;
|
|
@@ -116,6 +117,13 @@ export declare class SharedCameraSession implements CameraSession {
|
|
|
116
117
|
attach(host: unknown, listener?: BackendPreviewListener): () => void;
|
|
117
118
|
get attached(): boolean;
|
|
118
119
|
private detachInternal;
|
|
120
|
+
/** `onReady` means "recording can start": it is delivered only while
|
|
121
|
+
* the session would actually admit `startRecording`. Adapters emit
|
|
122
|
+
* their availability/interruption observations in their own order, so
|
|
123
|
+
* a ready signal that arrives before those updates settles pends
|
|
124
|
+
* until the next lifecycle event makes admission possible — or is
|
|
125
|
+
* dropped if readiness is retracted first. */
|
|
126
|
+
private flushPendingReady;
|
|
119
127
|
private emit;
|
|
120
128
|
private setState;
|
|
121
129
|
snapshot(): CameraSessionSnapshot;
|