@deibid/no-hands-react 0.0.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/dist/index.d.mts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +503 -0
- package/dist/index.mjs +467 -0
- package/package.json +34 -0
- package/src/context/context.tsx +147 -0
- package/src/context/index.ts +1 -0
- package/src/context/types.ts +22 -0
- package/src/detection-bounds-display.tsx +29 -0
- package/src/hooks.ts +239 -0
- package/src/index.ts +24 -0
- package/src/nhcomponent.ts +87 -0
- package/src/pointer.tsx +62 -0
- package/src/types.ts +25 -0
- package/tsconfig.json +17 -0
- package/tsconfig.node.json +11 -0
- package/tsup.config.ts +13 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// src/nhcomponent.ts
|
|
2
|
+
import React, { forwardRef } from "react";
|
|
3
|
+
|
|
4
|
+
// src/hooks.ts
|
|
5
|
+
import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
6
|
+
import { NHEvent } from "@deibid/no-hands";
|
|
7
|
+
|
|
8
|
+
// src/context/context.tsx
|
|
9
|
+
import {
|
|
10
|
+
createEventDispatcher,
|
|
11
|
+
createFaceTracker
|
|
12
|
+
} from "@deibid/no-hands";
|
|
13
|
+
import {
|
|
14
|
+
useContext,
|
|
15
|
+
useEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
createContext
|
|
20
|
+
} from "react";
|
|
21
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
22
|
+
var NHContext = createContext({
|
|
23
|
+
error: null,
|
|
24
|
+
eventDispatcher: null,
|
|
25
|
+
faceTracker: null,
|
|
26
|
+
loading: false,
|
|
27
|
+
videoStream: null
|
|
28
|
+
});
|
|
29
|
+
function NHProvider({ children, eventDispatcherOpts, faceTrackerOpts }) {
|
|
30
|
+
const [faceTracker, setFaceTracker] = useState(null);
|
|
31
|
+
const [eventDispatcher] = useState(
|
|
32
|
+
() => createEventDispatcher(eventDispatcherOpts)
|
|
33
|
+
);
|
|
34
|
+
const faceTrackerOptsRef = useRef(faceTrackerOpts);
|
|
35
|
+
const streamOwnerVideoRef = useRef(null);
|
|
36
|
+
const { error, loading, stream } = useCamera();
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
faceTrackerOptsRef.current = faceTrackerOpts;
|
|
39
|
+
});
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const videoElement = streamOwnerVideoRef.current;
|
|
42
|
+
if (!videoElement || !stream) return;
|
|
43
|
+
let ftActive = true;
|
|
44
|
+
let localFaceTracker;
|
|
45
|
+
async function init() {
|
|
46
|
+
if (!videoElement) {
|
|
47
|
+
console.warn("has no video element");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
localFaceTracker = await createFaceTracker({
|
|
51
|
+
videoElement,
|
|
52
|
+
detectionBounds: faceTrackerOptsRef.current?.detectionBounds,
|
|
53
|
+
projectionBounds: faceTrackerOptsRef.current?.projectionBounds
|
|
54
|
+
});
|
|
55
|
+
if (!ftActive) {
|
|
56
|
+
localFaceTracker.stop();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
videoElement.srcObject = stream;
|
|
60
|
+
videoElement.addEventListener("loadeddata", () => {
|
|
61
|
+
localFaceTracker.subscribe((point) => {
|
|
62
|
+
eventDispatcher.computeEvents(point);
|
|
63
|
+
});
|
|
64
|
+
localFaceTracker.start();
|
|
65
|
+
setFaceTracker(localFaceTracker);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
init();
|
|
69
|
+
return () => {
|
|
70
|
+
ftActive = false;
|
|
71
|
+
localFaceTracker?.stop();
|
|
72
|
+
};
|
|
73
|
+
}, [stream, eventDispatcher]);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!faceTracker) return;
|
|
76
|
+
if (faceTrackerOpts?.detectionBounds) {
|
|
77
|
+
faceTracker.setDetectionBounds(faceTrackerOpts.detectionBounds);
|
|
78
|
+
}
|
|
79
|
+
if (faceTrackerOpts?.projectionBounds) {
|
|
80
|
+
faceTracker.setProjectionBounds(faceTrackerOpts.projectionBounds);
|
|
81
|
+
}
|
|
82
|
+
}, [faceTracker, faceTrackerOpts]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (eventDispatcher && eventDispatcherOpts) {
|
|
85
|
+
eventDispatcher.setEventConfig(eventDispatcherOpts);
|
|
86
|
+
}
|
|
87
|
+
}, [eventDispatcher, eventDispatcherOpts]);
|
|
88
|
+
const contextValue = useMemo(
|
|
89
|
+
() => ({
|
|
90
|
+
error,
|
|
91
|
+
eventDispatcher,
|
|
92
|
+
faceTracker,
|
|
93
|
+
loading,
|
|
94
|
+
videoStream: stream
|
|
95
|
+
}),
|
|
96
|
+
[error, eventDispatcher, faceTracker, loading, stream]
|
|
97
|
+
);
|
|
98
|
+
return /* @__PURE__ */ jsxs(NHContext.Provider, { value: contextValue, children: [
|
|
99
|
+
children,
|
|
100
|
+
/* @__PURE__ */ jsx(
|
|
101
|
+
"video",
|
|
102
|
+
{
|
|
103
|
+
ref: streamOwnerVideoRef,
|
|
104
|
+
muted: true,
|
|
105
|
+
autoPlay: true,
|
|
106
|
+
style: {
|
|
107
|
+
opacity: 0,
|
|
108
|
+
position: "absolute",
|
|
109
|
+
top: 0,
|
|
110
|
+
left: 0,
|
|
111
|
+
zIndex: -999
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
] });
|
|
116
|
+
}
|
|
117
|
+
function useNH() {
|
|
118
|
+
return useContext(NHContext);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/hooks.ts
|
|
122
|
+
function useEvents(props) {
|
|
123
|
+
const ref = useRef2(null);
|
|
124
|
+
useEffect2(() => {
|
|
125
|
+
if (!ref.current) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (props.onNHMouseEnter) {
|
|
129
|
+
ref.current.addEventListener(NHEvent.MOUSE_ENTER, props.onNHMouseEnter);
|
|
130
|
+
}
|
|
131
|
+
if (props.onNHMouseLeave) {
|
|
132
|
+
ref.current.addEventListener(NHEvent.MOUSE_LEAVE, props.onNHMouseLeave);
|
|
133
|
+
}
|
|
134
|
+
if (props.onNHClickGestureBegin) {
|
|
135
|
+
ref.current.addEventListener(
|
|
136
|
+
NHEvent.CLICK_GESTURE_BEGIN,
|
|
137
|
+
props.onNHClickGestureBegin
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (props.onNHClickGestureEnd) {
|
|
141
|
+
ref.current.addEventListener(
|
|
142
|
+
NHEvent.CLICK_GESTURE_END,
|
|
143
|
+
props.onNHClickGestureEnd
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return () => {
|
|
147
|
+
if (!ref.current) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (props.onNHMouseEnter) {
|
|
151
|
+
ref.current.removeEventListener(
|
|
152
|
+
NHEvent.MOUSE_ENTER,
|
|
153
|
+
props.onNHMouseEnter
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (props.onNHMouseLeave) {
|
|
157
|
+
ref.current.removeEventListener(
|
|
158
|
+
NHEvent.MOUSE_LEAVE,
|
|
159
|
+
props.onNHMouseLeave
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (props.onNHClickGestureBegin) {
|
|
163
|
+
ref.current.removeEventListener(
|
|
164
|
+
NHEvent.CLICK_GESTURE_BEGIN,
|
|
165
|
+
props.onNHClickGestureBegin
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (props.onNHClickGestureEnd) {
|
|
169
|
+
ref.current.removeEventListener(
|
|
170
|
+
NHEvent.CLICK_GESTURE_END,
|
|
171
|
+
props.onNHClickGestureEnd
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}, [
|
|
176
|
+
props.onNHClickGestureBegin,
|
|
177
|
+
props.onNHClickGestureEnd,
|
|
178
|
+
props.onNHMouseEnter,
|
|
179
|
+
props.onNHMouseLeave
|
|
180
|
+
]);
|
|
181
|
+
return {
|
|
182
|
+
ref
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function useCamera() {
|
|
186
|
+
const [stream, setStream] = useState2(null);
|
|
187
|
+
const [loading, setLoading] = useState2(true);
|
|
188
|
+
const [error, setError] = useState2(null);
|
|
189
|
+
useEffect2(() => {
|
|
190
|
+
let localStream = null;
|
|
191
|
+
let active = true;
|
|
192
|
+
async function getCamera() {
|
|
193
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
194
|
+
console.warn("device does not support camera");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const s = await navigator.mediaDevices.getUserMedia({
|
|
199
|
+
video: true
|
|
200
|
+
});
|
|
201
|
+
if (!active) {
|
|
202
|
+
s.getTracks().forEach((t) => t.stop());
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
localStream = s;
|
|
206
|
+
setStream(s);
|
|
207
|
+
setLoading(false);
|
|
208
|
+
} catch (error2) {
|
|
209
|
+
console.error("could not initiate camera", error2);
|
|
210
|
+
setLoading(false);
|
|
211
|
+
setError(error2);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
getCamera();
|
|
215
|
+
return () => {
|
|
216
|
+
active = false;
|
|
217
|
+
localStream?.getTracks().forEach((t) => t.stop());
|
|
218
|
+
};
|
|
219
|
+
}, []);
|
|
220
|
+
return {
|
|
221
|
+
stream,
|
|
222
|
+
loading,
|
|
223
|
+
error
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function useNHPoint() {
|
|
227
|
+
const [point, setPoint] = useState2(null);
|
|
228
|
+
const { faceTracker } = useNH();
|
|
229
|
+
useEffect2(() => {
|
|
230
|
+
if (!faceTracker) return;
|
|
231
|
+
const subscriptionId = faceTracker.subscribe(setPoint);
|
|
232
|
+
return () => {
|
|
233
|
+
faceTracker.unsubscribe(subscriptionId);
|
|
234
|
+
};
|
|
235
|
+
}, [faceTracker]);
|
|
236
|
+
return point;
|
|
237
|
+
}
|
|
238
|
+
function useNHEventNotification({
|
|
239
|
+
onNHClickGestureBegin,
|
|
240
|
+
onNHClickGestureEnd,
|
|
241
|
+
onNHMouseEnter,
|
|
242
|
+
onNHMouseLeave
|
|
243
|
+
}) {
|
|
244
|
+
const { eventDispatcher } = useNH();
|
|
245
|
+
useEffect2(() => {
|
|
246
|
+
const unsubscribeFns = [];
|
|
247
|
+
if (eventDispatcher && onNHMouseEnter) {
|
|
248
|
+
const unsubscribe = eventDispatcher.onEvent(
|
|
249
|
+
NHEvent.MOUSE_ENTER,
|
|
250
|
+
onNHMouseEnter
|
|
251
|
+
);
|
|
252
|
+
unsubscribeFns.push(unsubscribe);
|
|
253
|
+
}
|
|
254
|
+
if (eventDispatcher && onNHMouseLeave) {
|
|
255
|
+
const unsubscribe = eventDispatcher.onEvent(
|
|
256
|
+
NHEvent.MOUSE_LEAVE,
|
|
257
|
+
onNHMouseLeave
|
|
258
|
+
);
|
|
259
|
+
unsubscribeFns.push(unsubscribe);
|
|
260
|
+
}
|
|
261
|
+
if (eventDispatcher && onNHClickGestureBegin) {
|
|
262
|
+
const unsubscribe = eventDispatcher.onEvent(
|
|
263
|
+
NHEvent.CLICK_GESTURE_BEGIN,
|
|
264
|
+
onNHClickGestureBegin
|
|
265
|
+
);
|
|
266
|
+
unsubscribeFns.push(unsubscribe);
|
|
267
|
+
}
|
|
268
|
+
if (eventDispatcher && onNHClickGestureEnd) {
|
|
269
|
+
const unsubscribe = eventDispatcher.onEvent(
|
|
270
|
+
NHEvent.CLICK_GESTURE_END,
|
|
271
|
+
onNHClickGestureEnd
|
|
272
|
+
);
|
|
273
|
+
unsubscribeFns.push(unsubscribe);
|
|
274
|
+
}
|
|
275
|
+
return () => {
|
|
276
|
+
unsubscribeFns.forEach((unsubscribe) => unsubscribe?.());
|
|
277
|
+
};
|
|
278
|
+
}, [
|
|
279
|
+
eventDispatcher,
|
|
280
|
+
onNHMouseEnter,
|
|
281
|
+
onNHMouseLeave,
|
|
282
|
+
onNHClickGestureBegin,
|
|
283
|
+
onNHClickGestureEnd
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
286
|
+
function useWindowSize() {
|
|
287
|
+
const computeWindowSize = (window2) => ({
|
|
288
|
+
width: window2.innerWidth,
|
|
289
|
+
height: window2.innerHeight,
|
|
290
|
+
centerX: window2.innerWidth / 2,
|
|
291
|
+
centerY: window2.innerHeight / 2
|
|
292
|
+
});
|
|
293
|
+
const [windowSize, setWindowSize] = useState2(computeWindowSize(window));
|
|
294
|
+
const handleWindowResize = useCallback(() => {
|
|
295
|
+
setWindowSize(computeWindowSize(window));
|
|
296
|
+
}, [computeWindowSize]);
|
|
297
|
+
useEffect2(() => {
|
|
298
|
+
window.addEventListener("resize", handleWindowResize);
|
|
299
|
+
return () => window.removeEventListener("resize", handleWindowResize);
|
|
300
|
+
}, [handleWindowResize]);
|
|
301
|
+
return windowSize;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// src/nhcomponent.ts
|
|
305
|
+
import { mergeRefs } from "react-merge-refs";
|
|
306
|
+
var htmlElementTags = [
|
|
307
|
+
"a",
|
|
308
|
+
"article",
|
|
309
|
+
"aside",
|
|
310
|
+
"audio",
|
|
311
|
+
"button",
|
|
312
|
+
"canvas",
|
|
313
|
+
"details",
|
|
314
|
+
"dialog",
|
|
315
|
+
"div",
|
|
316
|
+
"figure",
|
|
317
|
+
"footer",
|
|
318
|
+
"form",
|
|
319
|
+
"h1",
|
|
320
|
+
"h2",
|
|
321
|
+
"h3",
|
|
322
|
+
"h4",
|
|
323
|
+
"h5",
|
|
324
|
+
"h6",
|
|
325
|
+
"header",
|
|
326
|
+
"img",
|
|
327
|
+
"input",
|
|
328
|
+
"label",
|
|
329
|
+
"li",
|
|
330
|
+
"main",
|
|
331
|
+
"nav",
|
|
332
|
+
"ol",
|
|
333
|
+
"p",
|
|
334
|
+
"picture",
|
|
335
|
+
"section",
|
|
336
|
+
"select",
|
|
337
|
+
"span",
|
|
338
|
+
"summary",
|
|
339
|
+
"svg",
|
|
340
|
+
"table",
|
|
341
|
+
"td",
|
|
342
|
+
"textarea",
|
|
343
|
+
"th",
|
|
344
|
+
"ul",
|
|
345
|
+
"video"
|
|
346
|
+
];
|
|
347
|
+
function NHComponentFactory(tag) {
|
|
348
|
+
return forwardRef(
|
|
349
|
+
(props, ref) => {
|
|
350
|
+
const {
|
|
351
|
+
onNHMouseEnter,
|
|
352
|
+
onNHMouseLeave,
|
|
353
|
+
onNHClickGestureBegin,
|
|
354
|
+
onNHClickGestureEnd,
|
|
355
|
+
...htmlProps
|
|
356
|
+
} = props;
|
|
357
|
+
const { ref: nhRef } = useEvents({
|
|
358
|
+
onNHMouseEnter,
|
|
359
|
+
onNHMouseLeave,
|
|
360
|
+
onNHClickGestureBegin,
|
|
361
|
+
onNHClickGestureEnd
|
|
362
|
+
});
|
|
363
|
+
return React.createElement(tag, {
|
|
364
|
+
...htmlProps,
|
|
365
|
+
ref: mergeRefs([ref, nhRef]),
|
|
366
|
+
"data-nh-component": "true"
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
var NH = htmlElementTags.reduce((result, tag) => {
|
|
372
|
+
const NHComponent = NHComponentFactory(tag);
|
|
373
|
+
NHComponent.displayName = `NH.${tag}`;
|
|
374
|
+
result[tag] = NHComponent;
|
|
375
|
+
return result;
|
|
376
|
+
}, {});
|
|
377
|
+
|
|
378
|
+
// src/detection-bounds-display.tsx
|
|
379
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
380
|
+
function DetectionBoundsDisplay({
|
|
381
|
+
detectionBounds,
|
|
382
|
+
style
|
|
383
|
+
}) {
|
|
384
|
+
return /* @__PURE__ */ jsx2(
|
|
385
|
+
"div",
|
|
386
|
+
{
|
|
387
|
+
style: {
|
|
388
|
+
pointerEvents: "none",
|
|
389
|
+
position: "absolute",
|
|
390
|
+
width: detectionBounds.p2.x - detectionBounds.p1.x,
|
|
391
|
+
height: detectionBounds.p2.y - detectionBounds.p1.y,
|
|
392
|
+
opacity: 0.5,
|
|
393
|
+
zIndex: -1,
|
|
394
|
+
top: detectionBounds.p1.y,
|
|
395
|
+
left: detectionBounds.p1.x,
|
|
396
|
+
border: "1px solid black",
|
|
397
|
+
borderRadius: 12,
|
|
398
|
+
...style
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/pointer.tsx
|
|
405
|
+
import { useCallback as useCallback2, useState as useState3 } from "react";
|
|
406
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
407
|
+
function Pointer({
|
|
408
|
+
children,
|
|
409
|
+
style
|
|
410
|
+
}) {
|
|
411
|
+
const point = useNHPoint();
|
|
412
|
+
const [, setColor] = useState3("red");
|
|
413
|
+
const handleNHClickGestureBegin = useCallback2(() => {
|
|
414
|
+
setColor((prevColor) => prevColor === "red" ? "blue" : "red");
|
|
415
|
+
}, []);
|
|
416
|
+
const handeNHClickGestureEnd = useCallback2(() => {
|
|
417
|
+
setColor("green");
|
|
418
|
+
}, []);
|
|
419
|
+
useNHEventNotification({
|
|
420
|
+
onNHClickGestureEnd: handeNHClickGestureEnd,
|
|
421
|
+
onNHClickGestureBegin: handleNHClickGestureBegin
|
|
422
|
+
});
|
|
423
|
+
return /* @__PURE__ */ jsx3(
|
|
424
|
+
"div",
|
|
425
|
+
{
|
|
426
|
+
style: {
|
|
427
|
+
pointerEvents: "none",
|
|
428
|
+
position: "absolute",
|
|
429
|
+
...point === null && { visibility: "hidden" },
|
|
430
|
+
...point !== null && { left: point.x, top: point.y },
|
|
431
|
+
...style
|
|
432
|
+
// backgroundColor: color,
|
|
433
|
+
},
|
|
434
|
+
children: children ?? /* @__PURE__ */ jsx3(DefaultPointerIndicator, {})
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
function DefaultPointerIndicator() {
|
|
439
|
+
return /* @__PURE__ */ jsx3(
|
|
440
|
+
"div",
|
|
441
|
+
{
|
|
442
|
+
style: {
|
|
443
|
+
height: 24,
|
|
444
|
+
width: 24,
|
|
445
|
+
borderRadius: "50%",
|
|
446
|
+
transform: "translate(-50%, -50%)",
|
|
447
|
+
boxShadow: "rgba(0, 0, 0, 0.24) 0px 3px 8px",
|
|
448
|
+
background: "black"
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/index.ts
|
|
455
|
+
import { NHEvent as NHEvent2 } from "@deibid/no-hands";
|
|
456
|
+
export {
|
|
457
|
+
DetectionBoundsDisplay,
|
|
458
|
+
NH,
|
|
459
|
+
NHEvent2 as NHEvent,
|
|
460
|
+
NHProvider,
|
|
461
|
+
Pointer,
|
|
462
|
+
useCamera,
|
|
463
|
+
useEvents,
|
|
464
|
+
useNH,
|
|
465
|
+
useNHPoint,
|
|
466
|
+
useWindowSize
|
|
467
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deibid/no-hands-react",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React adapter for @deibid/no-hands",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
17
|
+
"build": "tsup"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"packageManager": "pnpm@10.24.0",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@deibid/no-hands": "workspace:*",
|
|
25
|
+
"react-merge-refs": "^3.0.2"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": "^18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "^19.2.7",
|
|
32
|
+
"react": "18.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEventDispatcher,
|
|
3
|
+
createFaceTracker,
|
|
4
|
+
type EventDispatcher,
|
|
5
|
+
type FaceTracker,
|
|
6
|
+
} from "@deibid/no-hands";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type PropsWithChildren,
|
|
10
|
+
useContext,
|
|
11
|
+
useEffect,
|
|
12
|
+
useMemo,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
createContext
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
import { useCamera } from "../hooks";
|
|
19
|
+
import type { INHContext, NHProviderOptions } from "./types";
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const NHContext = createContext<INHContext>({
|
|
23
|
+
error: null,
|
|
24
|
+
eventDispatcher: null,
|
|
25
|
+
faceTracker: null,
|
|
26
|
+
loading: false,
|
|
27
|
+
videoStream: null,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
type NHProviderProps = PropsWithChildren<NHProviderOptions>;
|
|
32
|
+
export function NHProvider({ children, eventDispatcherOpts, faceTrackerOpts }: NHProviderProps) {
|
|
33
|
+
|
|
34
|
+
const [faceTracker, setFaceTracker] = useState<FaceTracker | null>(null);
|
|
35
|
+
const [eventDispatcher] = useState<EventDispatcher>(
|
|
36
|
+
() => createEventDispatcher(eventDispatcherOpts)
|
|
37
|
+
);
|
|
38
|
+
// needed to update the faceTracker without recreating it
|
|
39
|
+
const faceTrackerOptsRef = useRef<NHProviderOptions['faceTrackerOpts']>(faceTrackerOpts);
|
|
40
|
+
const streamOwnerVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
41
|
+
|
|
42
|
+
const { error, loading, stream } = useCamera();
|
|
43
|
+
|
|
44
|
+
// keeps the latest value in the ref
|
|
45
|
+
useEffect(() => { faceTrackerOptsRef.current = faceTrackerOpts; });
|
|
46
|
+
|
|
47
|
+
// initialize and re-create face tracker if stream changes
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const videoElement = streamOwnerVideoRef.current;
|
|
50
|
+
if (!videoElement || !stream) return;
|
|
51
|
+
|
|
52
|
+
let ftActive = true;
|
|
53
|
+
let localFaceTracker: FaceTracker;
|
|
54
|
+
|
|
55
|
+
async function init() {
|
|
56
|
+
if (!videoElement) {
|
|
57
|
+
console.warn("has no video element");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
localFaceTracker = await createFaceTracker({
|
|
62
|
+
videoElement,
|
|
63
|
+
detectionBounds: faceTrackerOptsRef.current?.detectionBounds,
|
|
64
|
+
projectionBounds: faceTrackerOptsRef.current?.projectionBounds,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!ftActive) {
|
|
68
|
+
localFaceTracker.stop();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
videoElement.srcObject = stream;
|
|
73
|
+
videoElement.addEventListener("loadeddata", () => {
|
|
74
|
+
localFaceTracker.subscribe((point) => {
|
|
75
|
+
eventDispatcher.computeEvents(point);
|
|
76
|
+
});
|
|
77
|
+
localFaceTracker.start();
|
|
78
|
+
setFaceTracker(localFaceTracker);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
init();
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
ftActive = false;
|
|
86
|
+
localFaceTracker?.stop();
|
|
87
|
+
};
|
|
88
|
+
}, [stream, eventDispatcher]);
|
|
89
|
+
|
|
90
|
+
// update the face tracker bounds if they change from props
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
|
|
93
|
+
if (!faceTracker) return;
|
|
94
|
+
|
|
95
|
+
if (faceTrackerOpts?.detectionBounds) {
|
|
96
|
+
faceTracker.setDetectionBounds(faceTrackerOpts.detectionBounds);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (faceTrackerOpts?.projectionBounds) {
|
|
100
|
+
faceTracker.setProjectionBounds(faceTrackerOpts.projectionBounds);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
}, [faceTracker, faceTrackerOpts]);
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
// update the event dispatcher config if the config changes
|
|
107
|
+
// this implementation assumes eventDispatcher to be stable (which it is)
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (eventDispatcher && eventDispatcherOpts) {
|
|
110
|
+
eventDispatcher.setEventConfig(eventDispatcherOpts)
|
|
111
|
+
}
|
|
112
|
+
}, [eventDispatcher, eventDispatcherOpts]);
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
const contextValue: INHContext = useMemo(
|
|
116
|
+
() => ({
|
|
117
|
+
error,
|
|
118
|
+
eventDispatcher,
|
|
119
|
+
faceTracker,
|
|
120
|
+
loading,
|
|
121
|
+
videoStream: stream,
|
|
122
|
+
}),
|
|
123
|
+
[error, eventDispatcher, faceTracker, loading, stream]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<NHContext.Provider value={contextValue}>
|
|
127
|
+
{children}
|
|
128
|
+
{/* transparent video element that attaches to the video stream and face tracker */}
|
|
129
|
+
<video
|
|
130
|
+
ref={streamOwnerVideoRef}
|
|
131
|
+
muted
|
|
132
|
+
autoPlay
|
|
133
|
+
style={{
|
|
134
|
+
opacity: 0,
|
|
135
|
+
position: "absolute",
|
|
136
|
+
top: 0,
|
|
137
|
+
left: 0,
|
|
138
|
+
zIndex: -999,
|
|
139
|
+
}}
|
|
140
|
+
></video>
|
|
141
|
+
</NHContext.Provider>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function useNH() {
|
|
146
|
+
return useContext(NHContext);
|
|
147
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './context'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateEventDispatcherOpts,
|
|
3
|
+
EventDispatcher,
|
|
4
|
+
FaceTracker,
|
|
5
|
+
Rect
|
|
6
|
+
} from "@deibid/no-hands";
|
|
7
|
+
|
|
8
|
+
export interface INHContext {
|
|
9
|
+
error: Error | null;
|
|
10
|
+
eventDispatcher: EventDispatcher | null;
|
|
11
|
+
faceTracker: FaceTracker | null;
|
|
12
|
+
loading: boolean;
|
|
13
|
+
videoStream: MediaStream | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NHProviderOptions {
|
|
17
|
+
faceTrackerOpts?: {
|
|
18
|
+
detectionBounds?: Rect;
|
|
19
|
+
projectionBounds?: Rect;
|
|
20
|
+
};
|
|
21
|
+
eventDispatcherOpts?: CreateEventDispatcherOpts;
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Rect } from "@deibid/no-hands";
|
|
2
|
+
|
|
3
|
+
interface DetectionBoundsDisplayProps {
|
|
4
|
+
detectionBounds: Rect;
|
|
5
|
+
style?: React.CSSProperties;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function DetectionBoundsDisplay({
|
|
9
|
+
detectionBounds,
|
|
10
|
+
style
|
|
11
|
+
}: DetectionBoundsDisplayProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
style={{
|
|
15
|
+
pointerEvents: "none",
|
|
16
|
+
position: "absolute",
|
|
17
|
+
width: detectionBounds.p2.x - detectionBounds.p1.x,
|
|
18
|
+
height: detectionBounds.p2.y - detectionBounds.p1.y,
|
|
19
|
+
opacity: 0.5,
|
|
20
|
+
zIndex: -1,
|
|
21
|
+
top: detectionBounds.p1.y,
|
|
22
|
+
left: detectionBounds.p1.x,
|
|
23
|
+
border: "1px solid black",
|
|
24
|
+
borderRadius: 12,
|
|
25
|
+
...style
|
|
26
|
+
}}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|