@whatpull/handface 0.2.0 → 0.2.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.
@@ -9,16 +9,35 @@ export interface DetectionResult {
9
9
  gestureConfidence: number;
10
10
  /** 클릭 감지용 엄지 ↔ 중지 거리 */
11
11
  clickPinchDistance: number;
12
+ /** 검지 끝 위치 (cursorAnchor: 'index' 용) */
12
13
  indexTip: {
13
14
  x: number;
14
15
  y: number;
15
16
  };
17
+ /** 손목 위치 (cursorAnchor: 'wrist' 용) */
18
+ wrist: {
19
+ x: number;
20
+ y: number;
21
+ };
22
+ /** 손바닥 중심 위치 (cursorAnchor: 'palm' 용) */
23
+ palmCenter: {
24
+ x: number;
25
+ y: number;
26
+ };
16
27
  }
17
28
  export declare class GestureDetector {
18
29
  private readonly wasmPath;
19
30
  private readonly clickThreshold;
31
+ private readonly handednessFilter;
20
32
  private recognizer;
21
- constructor(wasmPath?: string, clickThreshold?: number);
33
+ /**
34
+ * @param handednessFilter
35
+ * MediaPipe 카테고리 이름 기준. 정면 카메라의 raw (비반전) 피드를 사용할 때:
36
+ * - 사용자의 오른손 → MediaPipe 'Left'
37
+ * - 사용자의 왼손 → MediaPipe 'Right'
38
+ * null = 필터 없음 (감지된 첫 번째 손 사용)
39
+ */
40
+ constructor(wasmPath?: string, clickThreshold?: number, handednessFilter?: 'Left' | 'Right' | null);
22
41
  init(): Promise<void>;
23
42
  detect(video: HTMLVideoElement, timestampMs: number): DetectionResult | null;
24
43
  private analyze;
@@ -15,9 +15,9 @@ export declare class HandControl extends EventEmitter<HandControlEventMap> {
15
15
  private lastGestureMs;
16
16
  private smoothX;
17
17
  private smoothY;
18
- private readonly threshold;
19
18
  private readonly smoothing;
20
19
  private readonly flipHorizontal;
20
+ private readonly cursorAnchor;
21
21
  private readonly ownedVideo;
22
22
  /** 단축키 바인딩 엔진 — 직접 접근 가능 */
23
23
  readonly mapper: GestureMapper;
package/dist/handface.js CHANGED
@@ -1,37 +1,43 @@
1
- var $ = Object.defineProperty;
2
- var k = (r, e, t) => e in r ? $(r, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : r[e] = t;
3
- var o = (r, e, t) => k(r, typeof e != "symbol" ? e + "" : e, t);
4
- import { FilesetResolver as E, GestureRecognizer as L } from "@mediapipe/tasks-vision";
5
- class M {
1
+ var E = Object.defineProperty;
2
+ var M = (o, e, t) => e in o ? E(o, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : o[e] = t;
3
+ var r = (o, e, t) => M(o, typeof e != "symbol" ? e + "" : e, t);
4
+ import { FilesetResolver as C, GestureRecognizer as L } from "@mediapipe/tasks-vision";
5
+ class _ {
6
6
  constructor() {
7
- o(this, "_listeners", /* @__PURE__ */ new Map());
7
+ r(this, "_listeners", /* @__PURE__ */ new Map());
8
8
  }
9
9
  on(e, t) {
10
10
  return this._listeners.has(e) || this._listeners.set(e, /* @__PURE__ */ new Set()), this._listeners.get(e).add(t), this;
11
11
  }
12
12
  off(e, t) {
13
- var i;
14
- return (i = this._listeners.get(e)) == null || i.delete(t), this;
13
+ var n;
14
+ return (n = this._listeners.get(e)) == null || n.delete(t), this;
15
15
  }
16
16
  emit(e, t) {
17
- var i;
18
- (i = this._listeners.get(e)) == null || i.forEach((n) => n(t));
17
+ var n;
18
+ (n = this._listeners.get(e)) == null || n.forEach((i) => i(t));
19
19
  }
20
20
  removeAllListeners(e) {
21
21
  return e ? this._listeners.delete(e) : this._listeners.clear(), this;
22
22
  }
23
23
  }
24
- function T(r, e) {
25
- return Math.sqrt((r.x - e.x) ** 2 + (r.y - e.y) ** 2);
24
+ function P(o, e) {
25
+ return Math.sqrt((o.x - e.x) ** 2 + (o.y - e.y) ** 2);
26
26
  }
27
- function f(r, e, t) {
28
- return r + t * (e - r);
27
+ function x(o, e, t) {
28
+ return o + t * (e - o);
29
29
  }
30
30
  const b = {
31
+ WRIST: 0,
31
32
  THUMB_TIP: 4,
32
33
  INDEX_TIP: 8,
33
- MIDDLE_TIP: 12
34
- }, C = {
34
+ MIDDLE_TIP: 12,
35
+ // palm center 계산용 (손목 + 각 손가락 MCP)
36
+ INDEX_MCP: 5,
37
+ MIDDLE_MCP: 9,
38
+ RING_MCP: 13,
39
+ PINKY_MCP: 17
40
+ }, g = [b.WRIST, b.INDEX_MCP, b.MIDDLE_MCP, b.RING_MCP, b.PINKY_MCP], I = {
35
41
  Pointing_Up: "pointing",
36
42
  Closed_Fist: "fist",
37
43
  Open_Palm: "openpalm",
@@ -39,38 +45,62 @@ const b = {
39
45
  Thumb_Down: "thumbsdown",
40
46
  Victory: "victory",
41
47
  ILoveYou: "iloveyou"
42
- }, _ = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm", P = "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task";
43
- class z {
44
- constructor(e = _, t = 0.06) {
45
- o(this, "recognizer", null);
46
- this.wasmPath = e, this.clickThreshold = t;
48
+ }, T = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm", D = "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task";
49
+ class S {
50
+ /**
51
+ * @param handednessFilter
52
+ * MediaPipe 카테고리 이름 기준. 정면 카메라의 raw (비반전) 피드를 사용할 때:
53
+ * - 사용자의 오른손 → MediaPipe 'Left'
54
+ * - 사용자의 왼손 → MediaPipe 'Right'
55
+ * null = 필터 없음 (감지된 첫 번째 손 사용)
56
+ */
57
+ constructor(e = T, t = 0.06, n = null) {
58
+ r(this, "recognizer", null);
59
+ this.wasmPath = e, this.clickThreshold = t, this.handednessFilter = n;
47
60
  }
48
61
  async init() {
49
- const e = await E.forVisionTasks(this.wasmPath);
62
+ const e = await C.forVisionTasks(this.wasmPath);
50
63
  this.recognizer = await L.createFromOptions(e, {
51
64
  baseOptions: {
52
- modelAssetPath: P,
65
+ modelAssetPath: D,
53
66
  delegate: "GPU"
54
67
  },
55
- numHands: 1,
68
+ // 핸드니스 필터링을 위해 2개 감지 후 선택
69
+ numHands: this.handednessFilter ? 2 : 1,
56
70
  runningMode: "VIDEO"
57
71
  });
58
72
  }
59
73
  detect(e, t) {
60
74
  if (!this.recognizer) return null;
61
- const i = this.recognizer.recognizeForVideo(e, t);
62
- if (!i.landmarks || i.landmarks.length === 0) return null;
63
- const n = i.gestures[0] ?? [];
64
- return this.analyze(i.landmarks[0], n);
75
+ const n = this.recognizer.recognizeForVideo(e, t);
76
+ if (!n.landmarks || n.landmarks.length === 0) return null;
77
+ let i = 0;
78
+ if (this.handednessFilter) {
79
+ const a = n.handedness.findIndex(
80
+ (c) => {
81
+ var d;
82
+ return ((d = c[0]) == null ? void 0 : d.categoryName) === this.handednessFilter;
83
+ }
84
+ );
85
+ if (a === -1) return null;
86
+ i = a;
87
+ }
88
+ const l = n.gestures[i] ?? [];
89
+ return this.analyze(n.landmarks[i], l);
65
90
  }
66
91
  analyze(e, t) {
67
- const i = e[b.THUMB_TIP], n = e[b.INDEX_TIP], l = e[b.MIDDLE_TIP], a = T(i, l), c = t[0], d = c ? C[c.categoryName] ?? null : null, p = (c == null ? void 0 : c.score) ?? 0;
92
+ const n = e[b.THUMB_TIP], i = e[b.INDEX_TIP], l = e[b.MIDDLE_TIP], a = e[b.WRIST], c = {
93
+ x: g.reduce((u, m) => u + e[m].x, 0) / g.length,
94
+ y: g.reduce((u, m) => u + e[m].y, 0) / g.length
95
+ }, d = P(n, l), p = t[0], h = p ? I[p.categoryName] ?? null : null, f = (p == null ? void 0 : p.score) ?? 0;
68
96
  return {
69
- gesture: a < this.clickThreshold ? "click" : d ?? "none",
70
- gestureName: d,
71
- gestureConfidence: p,
72
- clickPinchDistance: a,
73
- indexTip: { x: n.x, y: n.y }
97
+ gesture: d < this.clickThreshold ? "click" : h ?? "none",
98
+ gestureName: h,
99
+ gestureConfidence: f,
100
+ clickPinchDistance: d,
101
+ indexTip: { x: i.x, y: i.y },
102
+ wrist: { x: a.x, y: a.y },
103
+ palmCenter: c
74
104
  };
75
105
  }
76
106
  destroy() {
@@ -78,8 +108,8 @@ class z {
78
108
  (e = this.recognizer) == null || e.close(), this.recognizer = null;
79
109
  }
80
110
  }
81
- const g = "handface_key_bindings";
82
- function S(r) {
111
+ const w = "handface_key_bindings";
112
+ function A(o) {
83
113
  return {
84
114
  " ": "Space",
85
115
  ArrowUp: "↑",
@@ -95,15 +125,15 @@ function S(r) {
95
125
  PageDown: "PgDn",
96
126
  Home: "Home",
97
127
  End: "End"
98
- }[r] ?? r;
128
+ }[o] ?? o;
99
129
  }
100
- class H {
130
+ class z {
101
131
  constructor() {
102
- o(this, "bindings", /* @__PURE__ */ new Map());
132
+ r(this, "bindings", /* @__PURE__ */ new Map());
103
133
  this.load();
104
134
  }
105
- bind(e, t, i) {
106
- this.bindings.set(e, { gesture: e, key: t, modifiers: i }), this.save();
135
+ bind(e, t, n) {
136
+ this.bindings.set(e, { gesture: e, key: t, modifiers: n }), this.save();
107
137
  }
108
138
  unbind(e) {
109
139
  this.bindings.delete(e), this.save();
@@ -116,60 +146,60 @@ class H {
116
146
  }
117
147
  /** 제스처에 바인딩된 키보드 이벤트 디스패치 */
118
148
  trigger(e) {
119
- var n, l, a, c;
149
+ var i, l, a, c;
120
150
  const t = this.bindings.get(e);
121
151
  if (!t) return;
122
- const i = {
152
+ const n = {
123
153
  key: t.key,
124
154
  bubbles: !0,
125
155
  cancelable: !0,
126
- ctrlKey: ((n = t.modifiers) == null ? void 0 : n.ctrl) ?? !1,
156
+ ctrlKey: ((i = t.modifiers) == null ? void 0 : i.ctrl) ?? !1,
127
157
  altKey: ((l = t.modifiers) == null ? void 0 : l.alt) ?? !1,
128
158
  shiftKey: ((a = t.modifiers) == null ? void 0 : a.shift) ?? !1,
129
159
  metaKey: ((c = t.modifiers) == null ? void 0 : c.meta) ?? !1
130
160
  };
131
- document.dispatchEvent(new KeyboardEvent("keydown", i)), document.dispatchEvent(new KeyboardEvent("keyup", i));
161
+ document.dispatchEvent(new KeyboardEvent("keydown", n)), document.dispatchEvent(new KeyboardEvent("keyup", n));
132
162
  }
133
163
  save() {
134
164
  try {
135
- localStorage.setItem(g, JSON.stringify([...this.bindings.entries()]));
165
+ localStorage.setItem(w, JSON.stringify([...this.bindings.entries()]));
136
166
  } catch {
137
167
  }
138
168
  }
139
169
  load() {
140
170
  try {
141
- const e = localStorage.getItem(g);
171
+ const e = localStorage.getItem(w);
142
172
  e && (this.bindings = new Map(JSON.parse(e)));
143
173
  } catch {
144
174
  }
145
175
  }
146
176
  }
147
- const y = {
177
+ const v = {
148
178
  pointing: { icon: "☝️", label: "Pointing Up", labelKo: "검지 가리키기", builtin: !0, builtinAction: "커서 이동" },
149
- fist: { icon: "✊", label: "Closed Fist", labelKo: "주먹", builtin: !0, builtinAction: " " },
150
- openpalm: { icon: "🖐️", label: "Open Palm", labelKo: "펼친 손", builtin: !0, builtinAction: " 아웃" },
179
+ fist: { icon: "✊", label: "Closed Fist", labelKo: "주먹", builtin: !0, builtinAction: "스크롤 다운" },
180
+ openpalm: { icon: "🖐️", label: "Open Palm", labelKo: "펼친 손", builtin: !0, builtinAction: "스크롤 " },
151
181
  thumbsup: { icon: "👍", label: "Thumbs Up", labelKo: "엄지 위", builtin: !1 },
152
182
  thumbsdown: { icon: "👎", label: "Thumbs Down", labelKo: "엄지 아래", builtin: !1 },
153
183
  victory: { icon: "✌️", label: "Victory", labelKo: "브이", builtin: !1 },
154
184
  iloveyou: { icon: "🤟", label: "I Love You", labelKo: "아이 러브 유", builtin: !1 }
155
- }, s = "hf-", A = ["thumbsup", "thumbsdown", "victory", "iloveyou"], D = ["pointing", "fist", "openpalm"];
156
- class I {
185
+ }, s = "hf-", R = ["thumbsup", "thumbsdown", "victory", "iloveyou"], H = ["pointing", "fist", "openpalm"];
186
+ class K {
157
187
  constructor(e) {
158
- o(this, "root");
159
- o(this, "fab");
160
- o(this, "panel");
161
- o(this, "styleEl");
162
- o(this, "isOpen", !1);
163
- o(this, "capturingGesture", null);
164
- o(this, "captureHandler", null);
165
- o(this, "detectedGesture", null);
188
+ r(this, "root");
189
+ r(this, "fab");
190
+ r(this, "panel");
191
+ r(this, "styleEl");
192
+ r(this, "isOpen", !1);
193
+ r(this, "capturingGesture", null);
194
+ r(this, "captureHandler", null);
195
+ r(this, "detectedGesture", null);
166
196
  this.mapper = e, this.styleEl = this.injectStyles(), this.fab = this.createFab(), this.panel = this.createPanel(), this.root = document.createElement("div"), this.root.setAttribute("data-handface", ""), this.root.appendChild(this.fab), this.root.appendChild(this.panel), document.body.appendChild(this.root), this.fab.addEventListener("click", () => this.toggle());
167
197
  }
168
198
  /** HandControl 에서 매 프레임 호출 — 현재 감지 제스처 표시 */
169
199
  setDetected(e, t) {
170
- this.isOpen && this.detectedGesture !== e && (this.detectedGesture = e, this.panel.querySelectorAll(`.${s}row[data-gesture]`).forEach((i) => {
171
- const n = i.dataset.gesture;
172
- i.classList.toggle(`${s}active`, n === e && t > 0.6);
200
+ this.isOpen && this.detectedGesture !== e && (this.detectedGesture = e, this.panel.querySelectorAll(`.${s}row[data-gesture]`).forEach((n) => {
201
+ const i = n.dataset.gesture;
202
+ n.classList.toggle(`${s}active`, i === e && t > 0.6);
173
203
  }));
174
204
  }
175
205
  destroy() {
@@ -235,46 +265,46 @@ class I {
235
265
  renderBuiltin() {
236
266
  const e = this.panel.querySelector(`.${s}builtin-rows`);
237
267
  e.innerHTML = "", e.appendChild(this.makeReadonlyRow("🤏", "엄지+중지 핀치", "클릭", null));
238
- for (const t of D) {
239
- const i = y[t];
268
+ for (const t of H) {
269
+ const n = v[t];
240
270
  e.appendChild(
241
- this.makeReadonlyRow(i.icon, i.labelKo, i.builtinAction, t)
271
+ this.makeReadonlyRow(n.icon, n.labelKo, n.builtinAction, t)
242
272
  );
243
273
  }
244
274
  }
245
275
  renderBindings() {
246
276
  const e = this.panel.querySelector(`.${s}binding-rows`);
247
277
  e.innerHTML = "";
248
- for (const t of A) {
249
- const i = y[t], n = this.mapper.getBinding(t), l = this.capturingGesture === t;
278
+ for (const t of R) {
279
+ const n = v[t], i = this.mapper.getBinding(t), l = this.capturingGesture === t;
250
280
  e.appendChild(
251
- this.makeBindingRow(t, i.icon, i.labelKo, (n == null ? void 0 : n.key) ?? null, l)
281
+ this.makeBindingRow(t, n.icon, n.labelKo, (i == null ? void 0 : i.key) ?? null, l)
252
282
  );
253
283
  }
254
284
  }
255
- makeReadonlyRow(e, t, i, n) {
285
+ makeReadonlyRow(e, t, n, i) {
256
286
  const l = document.createElement("div");
257
- return l.className = `${s}row`, n && (l.dataset.gesture = n), l.innerHTML = `
287
+ return l.className = `${s}row`, i && (l.dataset.gesture = i), l.innerHTML = `
258
288
  <span class="${s}icon">${e}</span>
259
289
  <span class="${s}name">${t}</span>
260
- <span class="${s}badge">${i}</span>
290
+ <span class="${s}badge">${n}</span>
261
291
  `, l;
262
292
  }
263
- makeBindingRow(e, t, i, n, l) {
293
+ makeBindingRow(e, t, n, i, l) {
264
294
  var d;
265
295
  const a = document.createElement("div");
266
296
  a.className = `${s}row`, a.dataset.gesture = e;
267
- const c = n ? this.buildKeyLabel(this.mapper.getBinding(e)) : null;
297
+ const c = i ? this.buildKeyLabel(this.mapper.getBinding(e)) : null;
268
298
  return l ? (a.innerHTML = `
269
299
  <span class="${s}icon">${t}</span>
270
- <span class="${s}name">${i}</span>
300
+ <span class="${s}name">${n}</span>
271
301
  <span class="${s}capture-hint">단축키 입력...</span>
272
302
  <button class="${s}cancel-btn">취소</button>
273
303
  `, a.querySelector(`.${s}cancel-btn`).addEventListener("click", () => {
274
304
  this.stopCapture(), this.renderRows();
275
305
  })) : (a.innerHTML = `
276
306
  <span class="${s}icon">${t}</span>
277
- <span class="${s}name">${i}</span>
307
+ <span class="${s}name">${n}</span>
278
308
  ${c ? `<span class="${s}key-tag">${c}</span>
279
309
  <button class="${s}bind-btn ${s}clear-btn" data-gesture="${e}" title="제거">✕</button>` : `<span class="${s}no-bind">—</span>`}
280
310
  <button class="${s}bind-btn ${s}edit-btn" data-gesture="${e}" title="단축키 설정">✎</button>
@@ -283,9 +313,9 @@ class I {
283
313
  })), a;
284
314
  }
285
315
  buildKeyLabel(e) {
286
- var i, n, l, a;
316
+ var n, i, l, a;
287
317
  const t = [];
288
- return (i = e.modifiers) != null && i.ctrl && t.push("Ctrl"), (n = e.modifiers) != null && n.alt && t.push("Alt"), (l = e.modifiers) != null && l.shift && t.push("Shift"), (a = e.modifiers) != null && a.meta && t.push("⌘"), t.push(S(e.key)), t.join("+");
318
+ return (n = e.modifiers) != null && n.ctrl && t.push("Ctrl"), (i = e.modifiers) != null && i.alt && t.push("Alt"), (l = e.modifiers) != null && l.shift && t.push("Shift"), (a = e.modifiers) != null && a.meta && t.push("⌘"), t.push(A(e.key)), t.join("+");
289
319
  }
290
320
  // ─────────────────────────────────────────
291
321
  // Styles
@@ -460,30 +490,39 @@ class I {
460
490
  `, document.head.appendChild(e), e;
461
491
  }
462
492
  }
463
- const R = 0.09, K = 600, w = 12, O = 900;
464
- class N extends M {
493
+ const N = 0.09, O = 600, $ = 12, G = 900;
494
+ function F(o) {
495
+ return o === "right" ? "Left" : o === "left" ? "Right" : null;
496
+ }
497
+ class B extends _ {
465
498
  constructor(t = {}) {
466
499
  super();
467
- o(this, "video");
468
- o(this, "detector");
469
- o(this, "rafId", null);
470
- o(this, "stream", null);
471
- o(this, "panel", null);
500
+ r(this, "video");
501
+ r(this, "detector");
502
+ r(this, "rafId", null);
503
+ r(this, "stream", null);
504
+ r(this, "panel", null);
472
505
  // 제스처 상태
473
- o(this, "isClicking", !1);
474
- o(this, "lastClickMs", 0);
506
+ r(this, "isClicking", !1);
507
+ r(this, "lastClickMs", 0);
475
508
  /** GestureName → 마지막 발화 타임스탬프 */
476
- o(this, "lastGestureMs", /* @__PURE__ */ new Map());
477
- // 스무딩된 커서 위치 (검지 끝 추적)
478
- o(this, "smoothX", 0.5);
479
- o(this, "smoothY", 0.5);
480
- o(this, "threshold");
481
- o(this, "smoothing");
482
- o(this, "flipHorizontal");
483
- o(this, "ownedVideo");
509
+ r(this, "lastGestureMs", /* @__PURE__ */ new Map());
510
+ // 스무딩된 커서 위치
511
+ r(this, "smoothX", 0.5);
512
+ r(this, "smoothY", 0.5);
513
+ r(this, "smoothing");
514
+ r(this, "flipHorizontal");
515
+ r(this, "cursorAnchor");
516
+ r(this, "ownedVideo");
484
517
  /** 단축키 바인딩 엔진 — 직접 접근 가능 */
485
- o(this, "mapper", new H());
486
- this.threshold = t.threshold ?? 0.05, this.smoothing = t.smoothing ?? 0.6, this.flipHorizontal = t.flipHorizontal ?? !0, t.video ? (this.video = t.video, this.ownedVideo = !1) : (this.video = this.createHiddenVideo(), this.ownedVideo = !0), this.detector = new z(t.wasmPath, this.threshold);
518
+ r(this, "mapper", new z());
519
+ this.smoothing = t.smoothing ?? 0.6, this.flipHorizontal = t.flipHorizontal ?? !0, this.cursorAnchor = t.cursorAnchor ?? "wrist";
520
+ const n = t.threshold ?? 0.05;
521
+ t.video ? (this.video = t.video, this.ownedVideo = !1) : (this.video = this.createHiddenVideo(), this.ownedVideo = !0), this.detector = new S(
522
+ t.wasmPath,
523
+ n,
524
+ F(t.handedness ?? "right")
525
+ );
487
526
  }
488
527
  /** 카메라 열고 감지 시작 */
489
528
  async start() {
@@ -495,15 +534,15 @@ class N extends M {
495
534
  }
496
535
  /** 감지 중지 및 리소스 해제 */
497
536
  stop() {
498
- var t, i;
499
- this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null), (t = this.stream) == null || t.getTracks().forEach((n) => n.stop()), this.stream = null, this.detector.destroy(), (i = this.panel) == null || i.destroy(), this.panel = null, this.ownedVideo && this.video.remove(), this.removeAllListeners();
537
+ var t, n;
538
+ this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null), (t = this.stream) == null || t.getTracks().forEach((i) => i.stop()), this.stream = null, this.detector.destroy(), (n = this.panel) == null || n.destroy(), this.panel = null, this.ownedVideo && this.video.remove(), this.removeAllListeners();
500
539
  }
501
540
  /**
502
541
  * 플로팅 설정 패널을 생성하고 document.body 에 주입합니다.
503
542
  * 이미 생성된 경우 기존 인스턴스를 반환합니다.
504
543
  */
505
544
  createPanel() {
506
- return this.panel || (this.panel = new I(this.mapper)), this.panel;
545
+ return this.panel || (this.panel = new K(this.mapper)), this.panel;
507
546
  }
508
547
  createHiddenVideo() {
509
548
  const t = document.createElement("video");
@@ -515,46 +554,46 @@ class N extends M {
515
554
  });
516
555
  }
517
556
  tick() {
518
- var u, m;
519
- const t = performance.now(), i = this.detector.detect(this.video, t);
520
- if (!i) return;
521
- const n = this.flipHorizontal ? 1 - i.indexTip.x : i.indexTip.x, l = i.indexTip.y;
522
- this.smoothX = f(this.smoothX, n, 1 - this.smoothing), this.smoothY = f(this.smoothY, l, 1 - this.smoothing);
523
- const a = Math.round(this.smoothX * window.innerWidth), c = Math.round(this.smoothY * window.innerHeight), d = {
557
+ var f, y;
558
+ const t = performance.now(), n = this.detector.detect(this.video, t);
559
+ if (!n) return;
560
+ const i = this.cursorAnchor === "index" ? n.indexTip : this.cursorAnchor === "palm" ? n.palmCenter : n.wrist, l = this.flipHorizontal ? 1 - i.x : i.x, a = i.y;
561
+ this.smoothX = x(this.smoothX, l, 1 - this.smoothing), this.smoothY = x(this.smoothY, a, 1 - this.smoothing);
562
+ const c = Math.round(this.smoothX * window.innerWidth), d = Math.round(this.smoothY * window.innerHeight), p = {
524
563
  x: this.smoothX,
525
564
  y: this.smoothY,
526
- screenX: a,
527
- screenY: c
565
+ screenX: c,
566
+ screenY: d
528
567
  };
529
- if (this.emit("move", d), i.gesture === "click") {
568
+ if (this.emit("move", p), n.gesture === "click") {
530
569
  if (!this.isClicking) {
531
570
  this.isClicking = !0;
532
- const h = Date.now();
533
- h - this.lastClickMs > K && (this.lastClickMs = h, this.emit("click", d));
571
+ const u = Date.now();
572
+ u - this.lastClickMs > O && (this.lastClickMs = u, this.emit("click", p));
534
573
  }
535
- } else i.clickPinchDistance > R && (this.isClicking = !1);
536
- this.isClicking || (i.gestureName === "fist" ? this.emit("scroll", { deltaY: w }) : i.gestureName === "openpalm" && this.emit("scroll", { deltaY: -w }));
537
- const p = i.gestureName;
538
- if (p) {
539
- (u = this.panel) == null || u.setDetected(p, i.gestureConfidence);
540
- const h = Date.now(), x = this.lastGestureMs.get(p) ?? 0;
541
- if (h - x > O) {
542
- this.lastGestureMs.set(p, h);
543
- const v = {
544
- gesture: p,
545
- ...d,
546
- confidence: i.gestureConfidence
574
+ } else n.clickPinchDistance > N && (this.isClicking = !1);
575
+ this.isClicking || (n.gestureName === "fist" ? this.emit("scroll", { deltaY: $ }) : n.gestureName === "openpalm" && this.emit("scroll", { deltaY: -$ }));
576
+ const h = n.gestureName;
577
+ if (h) {
578
+ (f = this.panel) == null || f.setDetected(h, n.gestureConfidence);
579
+ const u = Date.now(), m = this.lastGestureMs.get(h) ?? 0;
580
+ if (u - m > G) {
581
+ this.lastGestureMs.set(h, u);
582
+ const k = {
583
+ gesture: h,
584
+ ...p,
585
+ confidence: n.gestureConfidence
547
586
  };
548
- this.emit(p, v), this.mapper.trigger(p);
587
+ this.emit(h, k), this.mapper.trigger(h);
549
588
  }
550
589
  } else
551
- (m = this.panel) == null || m.setDetected(null, 0);
590
+ (y = this.panel) == null || y.setDetected(null, 0);
552
591
  }
553
592
  }
554
593
  export {
555
- I as ControlPanel,
556
- y as GESTURE_META,
557
- H as GestureMapper,
558
- N as HandControl,
559
- S as formatKeyLabel
594
+ K as ControlPanel,
595
+ v as GESTURE_META,
596
+ z as GestureMapper,
597
+ B as HandControl,
598
+ A as formatKeyLabel
560
599
  };
@@ -1,4 +1,4 @@
1
- (function(l,c){typeof exports=="object"&&typeof module<"u"?c(exports,require("@mediapipe/tasks-vision")):typeof define=="function"&&define.amd?define(["exports","@mediapipe/tasks-vision"],c):(l=typeof globalThis<"u"?globalThis:l||self,c(l.Handface={},l.MediaPipeTasks))})(this,function(l,c){"use strict";var O=Object.defineProperty;var G=(l,c,b)=>c in l?O(l,c,{enumerable:!0,configurable:!0,writable:!0,value:b}):l[c]=b;var o=(l,c,b)=>G(l,typeof c!="symbol"?c+"":c,b);class b{constructor(){o(this,"_listeners",new Map)}on(e,t){return this._listeners.has(e)||this._listeners.set(e,new Set),this._listeners.get(e).add(t),this}off(e,t){var i;return(i=this._listeners.get(e))==null||i.delete(t),this}emit(e,t){var i;(i=this._listeners.get(e))==null||i.forEach(n=>n(t))}removeAllListeners(e){return e?this._listeners.delete(e):this._listeners.clear(),this}}function M(d,e){return Math.sqrt((d.x-e.x)**2+(d.y-e.y)**2)}function w(d,e,t){return d+t*(e-d)}const f={THUMB_TIP:4,INDEX_TIP:8,MIDDLE_TIP:12},L={Pointing_Up:"pointing",Closed_Fist:"fist",Open_Palm:"openpalm",Thumb_Up:"thumbsup",Thumb_Down:"thumbsdown",Victory:"victory",ILoveYou:"iloveyou"},C="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm",_="https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task";class P{constructor(e=C,t=.06){o(this,"recognizer",null);this.wasmPath=e,this.clickThreshold=t}async init(){const e=await c.FilesetResolver.forVisionTasks(this.wasmPath);this.recognizer=await c.GestureRecognizer.createFromOptions(e,{baseOptions:{modelAssetPath:_,delegate:"GPU"},numHands:1,runningMode:"VIDEO"})}detect(e,t){if(!this.recognizer)return null;const i=this.recognizer.recognizeForVideo(e,t);if(!i.landmarks||i.landmarks.length===0)return null;const n=i.gestures[0]??[];return this.analyze(i.landmarks[0],n)}analyze(e,t){const i=e[f.THUMB_TIP],n=e[f.INDEX_TIP],a=e[f.MIDDLE_TIP],r=M(i,a),p=t[0],h=p?L[p.categoryName]??null:null,u=(p==null?void 0:p.score)??0;return{gesture:r<this.clickThreshold?"click":h??"none",gestureName:h,gestureConfidence:u,clickPinchDistance:r,indexTip:{x:n.x,y:n.y}}}destroy(){var e;(e=this.recognizer)==null||e.close(),this.recognizer=null}}const v="handface_key_bindings";function x(d){return{" ":"Space",ArrowUp:"↑",ArrowDown:"↓",ArrowLeft:"←",ArrowRight:"→",Escape:"Esc",Backspace:"⌫",Delete:"Del",Enter:"↵",Tab:"Tab",PageUp:"PgUp",PageDown:"PgDn",Home:"Home",End:"End"}[d]??d}class ${constructor(){o(this,"bindings",new Map);this.load()}bind(e,t,i){this.bindings.set(e,{gesture:e,key:t,modifiers:i}),this.save()}unbind(e){this.bindings.delete(e),this.save()}getBinding(e){return this.bindings.get(e)}getAll(){return[...this.bindings.values()]}trigger(e){var n,a,r,p;const t=this.bindings.get(e);if(!t)return;const i={key:t.key,bubbles:!0,cancelable:!0,ctrlKey:((n=t.modifiers)==null?void 0:n.ctrl)??!1,altKey:((a=t.modifiers)==null?void 0:a.alt)??!1,shiftKey:((r=t.modifiers)==null?void 0:r.shift)??!1,metaKey:((p=t.modifiers)==null?void 0:p.meta)??!1};document.dispatchEvent(new KeyboardEvent("keydown",i)),document.dispatchEvent(new KeyboardEvent("keyup",i))}save(){try{localStorage.setItem(v,JSON.stringify([...this.bindings.entries()]))}catch{}}load(){try{const e=localStorage.getItem(v);e&&(this.bindings=new Map(JSON.parse(e)))}catch{}}}const g={pointing:{icon:"☝️",label:"Pointing Up",labelKo:"검지 가리키기",builtin:!0,builtinAction:"커서 이동"},fist:{icon:"✊",label:"Closed Fist",labelKo:"주먹",builtin:!0,builtinAction:" "},openpalm:{icon:"🖐️",label:"Open Palm",labelKo:"펼친 손",builtin:!0,builtinAction:" 아웃"},thumbsup:{icon:"👍",label:"Thumbs Up",labelKo:"엄지 위",builtin:!1},thumbsdown:{icon:"👎",label:"Thumbs Down",labelKo:"엄지 아래",builtin:!1},victory:{icon:"✌️",label:"Victory",labelKo:"브이",builtin:!1},iloveyou:{icon:"🤟",label:"I Love You",labelKo:"아이 러브 유",builtin:!1}},s="hf-",S=["thumbsup","thumbsdown","victory","iloveyou"],z=["pointing","fist","openpalm"];class k{constructor(e){o(this,"root");o(this,"fab");o(this,"panel");o(this,"styleEl");o(this,"isOpen",!1);o(this,"capturingGesture",null);o(this,"captureHandler",null);o(this,"detectedGesture",null);this.mapper=e,this.styleEl=this.injectStyles(),this.fab=this.createFab(),this.panel=this.createPanel(),this.root=document.createElement("div"),this.root.setAttribute("data-handface",""),this.root.appendChild(this.fab),this.root.appendChild(this.panel),document.body.appendChild(this.root),this.fab.addEventListener("click",()=>this.toggle())}setDetected(e,t){this.isOpen&&this.detectedGesture!==e&&(this.detectedGesture=e,this.panel.querySelectorAll(`.${s}row[data-gesture]`).forEach(i=>{const n=i.dataset.gesture;i.classList.toggle(`${s}active`,n===e&&t>.6)}))}destroy(){this.stopCapture(),this.styleEl.remove(),this.root.remove()}toggle(){this.isOpen?this.close():this.open()}open(){this.isOpen=!0,this.renderRows(),this.panel.classList.add(`${s}open`),this.fab.classList.add(`${s}fab-open`)}close(){this.isOpen=!1,this.stopCapture(),this.panel.classList.remove(`${s}open`),this.fab.classList.remove(`${s}fab-open`)}startCapture(e){this.stopCapture(),this.capturingGesture=e,this.captureHandler=t=>{if(t.preventDefault(),t.stopImmediatePropagation(),["Shift","Control","Alt","Meta"].includes(t.key)){document.addEventListener("keydown",this.captureHandler,{once:!0,capture:!0});return}this.mapper.bind(e,t.key,{ctrl:t.ctrlKey||void 0,alt:t.altKey||void 0,shift:t.shiftKey||void 0,meta:t.metaKey||void 0}),this.capturingGesture=null,this.captureHandler=null,this.renderRows()},document.addEventListener("keydown",this.captureHandler,{once:!0,capture:!0}),this.renderRows()}stopCapture(){this.captureHandler&&(document.removeEventListener("keydown",this.captureHandler,{capture:!0}),this.captureHandler=null,this.capturingGesture=null)}createFab(){const e=document.createElement("button");return e.className=`${s}fab`,e.title="handface 제스처 설정",e.innerHTML="✋",e}createPanel(){const e=document.createElement("div");return e.className=`${s}panel`,e.innerHTML=`
1
+ (function(l,d){typeof exports=="object"&&typeof module<"u"?d(exports,require("@mediapipe/tasks-vision")):typeof define=="function"&&define.amd?define(["exports","@mediapipe/tasks-vision"],d):(l=typeof globalThis<"u"?globalThis:l||self,d(l.Handface={},l.MediaPipeTasks))})(this,function(l,d){"use strict";var U=Object.defineProperty;var F=(l,d,g)=>d in l?U(l,d,{enumerable:!0,configurable:!0,writable:!0,value:g}):l[d]=g;var o=(l,d,g)=>F(l,typeof d!="symbol"?d+"":d,g);class g{constructor(){o(this,"_listeners",new Map)}on(e,t){return this._listeners.has(e)||this._listeners.set(e,new Set),this._listeners.get(e).add(t),this}off(e,t){var n;return(n=this._listeners.get(e))==null||n.delete(t),this}emit(e,t){var n;(n=this._listeners.get(e))==null||n.forEach(i=>i(t))}removeAllListeners(e){return e?this._listeners.delete(e):this._listeners.clear(),this}}function P(c,e){return Math.sqrt((c.x-e.x)**2+(c.y-e.y)**2)}function k(c,e,t){return c+t*(e-c)}const f={WRIST:0,THUMB_TIP:4,INDEX_TIP:8,MIDDLE_TIP:12,INDEX_MCP:5,MIDDLE_MCP:9,RING_MCP:13,PINKY_MCP:17},w=[f.WRIST,f.INDEX_MCP,f.MIDDLE_MCP,f.RING_MCP,f.PINKY_MCP],_={Pointing_Up:"pointing",Closed_Fist:"fist",Open_Palm:"openpalm",Thumb_Up:"thumbsup",Thumb_Down:"thumbsdown",Victory:"victory",ILoveYou:"iloveyou"},I="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm",S="https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task";class A{constructor(e=I,t=.06,n=null){o(this,"recognizer",null);this.wasmPath=e,this.clickThreshold=t,this.handednessFilter=n}async init(){const e=await d.FilesetResolver.forVisionTasks(this.wasmPath);this.recognizer=await d.GestureRecognizer.createFromOptions(e,{baseOptions:{modelAssetPath:S,delegate:"GPU"},numHands:this.handednessFilter?2:1,runningMode:"VIDEO"})}detect(e,t){if(!this.recognizer)return null;const n=this.recognizer.recognizeForVideo(e,t);if(!n.landmarks||n.landmarks.length===0)return null;let i=0;if(this.handednessFilter){const r=n.handedness.findIndex(h=>{var p;return((p=h[0])==null?void 0:p.categoryName)===this.handednessFilter});if(r===-1)return null;i=r}const a=n.gestures[i]??[];return this.analyze(n.landmarks[i],a)}analyze(e,t){const n=e[f.THUMB_TIP],i=e[f.INDEX_TIP],a=e[f.MIDDLE_TIP],r=e[f.WRIST],h={x:w.reduce((b,y)=>b+e[y].x,0)/w.length,y:w.reduce((b,y)=>b+e[y].y,0)/w.length},p=P(n,a),m=t[0],u=m?_[m.categoryName]??null:null,x=(m==null?void 0:m.score)??0;return{gesture:p<this.clickThreshold?"click":u??"none",gestureName:u,gestureConfidence:x,clickPinchDistance:p,indexTip:{x:i.x,y:i.y},wrist:{x:r.x,y:r.y},palmCenter:h}}destroy(){var e;(e=this.recognizer)==null||e.close(),this.recognizer=null}}const E="handface_key_bindings";function M(c){return{" ":"Space",ArrowUp:"↑",ArrowDown:"↓",ArrowLeft:"←",ArrowRight:"→",Escape:"Esc",Backspace:"⌫",Delete:"Del",Enter:"↵",Tab:"Tab",PageUp:"PgUp",PageDown:"PgDn",Home:"Home",End:"End"}[c]??c}class C{constructor(){o(this,"bindings",new Map);this.load()}bind(e,t,n){this.bindings.set(e,{gesture:e,key:t,modifiers:n}),this.save()}unbind(e){this.bindings.delete(e),this.save()}getBinding(e){return this.bindings.get(e)}getAll(){return[...this.bindings.values()]}trigger(e){var i,a,r,h;const t=this.bindings.get(e);if(!t)return;const n={key:t.key,bubbles:!0,cancelable:!0,ctrlKey:((i=t.modifiers)==null?void 0:i.ctrl)??!1,altKey:((a=t.modifiers)==null?void 0:a.alt)??!1,shiftKey:((r=t.modifiers)==null?void 0:r.shift)??!1,metaKey:((h=t.modifiers)==null?void 0:h.meta)??!1};document.dispatchEvent(new KeyboardEvent("keydown",n)),document.dispatchEvent(new KeyboardEvent("keyup",n))}save(){try{localStorage.setItem(E,JSON.stringify([...this.bindings.entries()]))}catch{}}load(){try{const e=localStorage.getItem(E);e&&(this.bindings=new Map(JSON.parse(e)))}catch{}}}const v={pointing:{icon:"☝️",label:"Pointing Up",labelKo:"검지 가리키기",builtin:!0,builtinAction:"커서 이동"},fist:{icon:"✊",label:"Closed Fist",labelKo:"주먹",builtin:!0,builtinAction:"스크롤 다운"},openpalm:{icon:"🖐️",label:"Open Palm",labelKo:"펼친 손",builtin:!0,builtinAction:"스크롤 "},thumbsup:{icon:"👍",label:"Thumbs Up",labelKo:"엄지 위",builtin:!1},thumbsdown:{icon:"👎",label:"Thumbs Down",labelKo:"엄지 아래",builtin:!1},victory:{icon:"✌️",label:"Victory",labelKo:"브이",builtin:!1},iloveyou:{icon:"🤟",label:"I Love You",labelKo:"아이 러브 유",builtin:!1}},s="hf-",D=["thumbsup","thumbsdown","victory","iloveyou"],H=["pointing","fist","openpalm"];class L{constructor(e){o(this,"root");o(this,"fab");o(this,"panel");o(this,"styleEl");o(this,"isOpen",!1);o(this,"capturingGesture",null);o(this,"captureHandler",null);o(this,"detectedGesture",null);this.mapper=e,this.styleEl=this.injectStyles(),this.fab=this.createFab(),this.panel=this.createPanel(),this.root=document.createElement("div"),this.root.setAttribute("data-handface",""),this.root.appendChild(this.fab),this.root.appendChild(this.panel),document.body.appendChild(this.root),this.fab.addEventListener("click",()=>this.toggle())}setDetected(e,t){this.isOpen&&this.detectedGesture!==e&&(this.detectedGesture=e,this.panel.querySelectorAll(`.${s}row[data-gesture]`).forEach(n=>{const i=n.dataset.gesture;n.classList.toggle(`${s}active`,i===e&&t>.6)}))}destroy(){this.stopCapture(),this.styleEl.remove(),this.root.remove()}toggle(){this.isOpen?this.close():this.open()}open(){this.isOpen=!0,this.renderRows(),this.panel.classList.add(`${s}open`),this.fab.classList.add(`${s}fab-open`)}close(){this.isOpen=!1,this.stopCapture(),this.panel.classList.remove(`${s}open`),this.fab.classList.remove(`${s}fab-open`)}startCapture(e){this.stopCapture(),this.capturingGesture=e,this.captureHandler=t=>{if(t.preventDefault(),t.stopImmediatePropagation(),["Shift","Control","Alt","Meta"].includes(t.key)){document.addEventListener("keydown",this.captureHandler,{once:!0,capture:!0});return}this.mapper.bind(e,t.key,{ctrl:t.ctrlKey||void 0,alt:t.altKey||void 0,shift:t.shiftKey||void 0,meta:t.metaKey||void 0}),this.capturingGesture=null,this.captureHandler=null,this.renderRows()},document.addEventListener("keydown",this.captureHandler,{once:!0,capture:!0}),this.renderRows()}stopCapture(){this.captureHandler&&(document.removeEventListener("keydown",this.captureHandler,{capture:!0}),this.captureHandler=null,this.capturingGesture=null)}createFab(){const e=document.createElement("button");return e.className=`${s}fab`,e.title="handface 제스처 설정",e.innerHTML="✋",e}createPanel(){const e=document.createElement("div");return e.className=`${s}panel`,e.innerHTML=`
2
2
  <div class="${s}header">
3
3
  <span class="${s}title">✋ handface</span>
4
4
  <button class="${s}close-btn" title="닫기">✕</button>
@@ -9,22 +9,22 @@
9
9
  <div class="${s}section-label" style="margin-top:10px">단축키 바인딩</div>
10
10
  <div class="${s}binding-rows"></div>
11
11
  </div>
12
- `,e.querySelector(`.${s}close-btn`).addEventListener("click",()=>this.close()),e}renderRows(){this.renderBuiltin(),this.renderBindings()}renderBuiltin(){const e=this.panel.querySelector(`.${s}builtin-rows`);e.innerHTML="",e.appendChild(this.makeReadonlyRow("🤏","엄지+중지 핀치","클릭",null));for(const t of z){const i=g[t];e.appendChild(this.makeReadonlyRow(i.icon,i.labelKo,i.builtinAction,t))}}renderBindings(){const e=this.panel.querySelector(`.${s}binding-rows`);e.innerHTML="";for(const t of S){const i=g[t],n=this.mapper.getBinding(t),a=this.capturingGesture===t;e.appendChild(this.makeBindingRow(t,i.icon,i.labelKo,(n==null?void 0:n.key)??null,a))}}makeReadonlyRow(e,t,i,n){const a=document.createElement("div");return a.className=`${s}row`,n&&(a.dataset.gesture=n),a.innerHTML=`
12
+ `,e.querySelector(`.${s}close-btn`).addEventListener("click",()=>this.close()),e}renderRows(){this.renderBuiltin(),this.renderBindings()}renderBuiltin(){const e=this.panel.querySelector(`.${s}builtin-rows`);e.innerHTML="",e.appendChild(this.makeReadonlyRow("🤏","엄지+중지 핀치","클릭",null));for(const t of H){const n=v[t];e.appendChild(this.makeReadonlyRow(n.icon,n.labelKo,n.builtinAction,t))}}renderBindings(){const e=this.panel.querySelector(`.${s}binding-rows`);e.innerHTML="";for(const t of D){const n=v[t],i=this.mapper.getBinding(t),a=this.capturingGesture===t;e.appendChild(this.makeBindingRow(t,n.icon,n.labelKo,(i==null?void 0:i.key)??null,a))}}makeReadonlyRow(e,t,n,i){const a=document.createElement("div");return a.className=`${s}row`,i&&(a.dataset.gesture=i),a.innerHTML=`
13
13
  <span class="${s}icon">${e}</span>
14
14
  <span class="${s}name">${t}</span>
15
- <span class="${s}badge">${i}</span>
16
- `,a}makeBindingRow(e,t,i,n,a){var h;const r=document.createElement("div");r.className=`${s}row`,r.dataset.gesture=e;const p=n?this.buildKeyLabel(this.mapper.getBinding(e)):null;return a?(r.innerHTML=`
15
+ <span class="${s}badge">${n}</span>
16
+ `,a}makeBindingRow(e,t,n,i,a){var p;const r=document.createElement("div");r.className=`${s}row`,r.dataset.gesture=e;const h=i?this.buildKeyLabel(this.mapper.getBinding(e)):null;return a?(r.innerHTML=`
17
17
  <span class="${s}icon">${t}</span>
18
- <span class="${s}name">${i}</span>
18
+ <span class="${s}name">${n}</span>
19
19
  <span class="${s}capture-hint">단축키 입력...</span>
20
20
  <button class="${s}cancel-btn">취소</button>
21
21
  `,r.querySelector(`.${s}cancel-btn`).addEventListener("click",()=>{this.stopCapture(),this.renderRows()})):(r.innerHTML=`
22
22
  <span class="${s}icon">${t}</span>
23
- <span class="${s}name">${i}</span>
24
- ${p?`<span class="${s}key-tag">${p}</span>
23
+ <span class="${s}name">${n}</span>
24
+ ${h?`<span class="${s}key-tag">${h}</span>
25
25
  <button class="${s}bind-btn ${s}clear-btn" data-gesture="${e}" title="제거">✕</button>`:`<span class="${s}no-bind">—</span>`}
26
26
  <button class="${s}bind-btn ${s}edit-btn" data-gesture="${e}" title="단축키 설정">✎</button>
27
- `,r.querySelector(`.${s}edit-btn`).addEventListener("click",()=>this.startCapture(e)),(h=r.querySelector(`.${s}clear-btn`))==null||h.addEventListener("click",()=>{this.mapper.unbind(e),this.renderRows()})),r}buildKeyLabel(e){var i,n,a,r;const t=[];return(i=e.modifiers)!=null&&i.ctrl&&t.push("Ctrl"),(n=e.modifiers)!=null&&n.alt&&t.push("Alt"),(a=e.modifiers)!=null&&a.shift&&t.push("Shift"),(r=e.modifiers)!=null&&r.meta&&t.push("⌘"),t.push(x(e.key)),t.join("+")}injectStyles(){const e=document.createElement("style");return e.dataset.handface="styles",e.textContent=`
27
+ `,r.querySelector(`.${s}edit-btn`).addEventListener("click",()=>this.startCapture(e)),(p=r.querySelector(`.${s}clear-btn`))==null||p.addEventListener("click",()=>{this.mapper.unbind(e),this.renderRows()})),r}buildKeyLabel(e){var n,i,a,r;const t=[];return(n=e.modifiers)!=null&&n.ctrl&&t.push("Ctrl"),(i=e.modifiers)!=null&&i.alt&&t.push("Alt"),(a=e.modifiers)!=null&&a.shift&&t.push("Shift"),(r=e.modifiers)!=null&&r.meta&&t.push("⌘"),t.push(M(e.key)),t.join("+")}injectStyles(){const e=document.createElement("style");return e.dataset.handface="styles",e.textContent=`
28
28
  .${s}fab {
29
29
  position: fixed;
30
30
  right: 20px;
@@ -189,4 +189,4 @@
189
189
  0%, 100% { opacity: 1; }
190
190
  50% { opacity: 0.3; }
191
191
  }
192
- `,document.head.appendChild(e),e}}const H=.09,A=600,E=12,D=900;class I extends b{constructor(t={}){super();o(this,"video");o(this,"detector");o(this,"rafId",null);o(this,"stream",null);o(this,"panel",null);o(this,"isClicking",!1);o(this,"lastClickMs",0);o(this,"lastGestureMs",new Map);o(this,"smoothX",.5);o(this,"smoothY",.5);o(this,"threshold");o(this,"smoothing");o(this,"flipHorizontal");o(this,"ownedVideo");o(this,"mapper",new $);this.threshold=t.threshold??.05,this.smoothing=t.smoothing??.6,this.flipHorizontal=t.flipHorizontal??!0,t.video?(this.video=t.video,this.ownedVideo=!1):(this.video=this.createHiddenVideo(),this.ownedVideo=!0),this.detector=new P(t.wasmPath,this.threshold)}async start(){await this.detector.init(),this.stream=await navigator.mediaDevices.getUserMedia({video:!0}),this.video.srcObject=this.stream,await new Promise(t=>{this.video.onloadedmetadata=()=>{this.video.play(),t()}}),this.loop()}stop(){var t,i;this.rafId!==null&&(cancelAnimationFrame(this.rafId),this.rafId=null),(t=this.stream)==null||t.getTracks().forEach(n=>n.stop()),this.stream=null,this.detector.destroy(),(i=this.panel)==null||i.destroy(),this.panel=null,this.ownedVideo&&this.video.remove(),this.removeAllListeners()}createPanel(){return this.panel||(this.panel=new k(this.mapper)),this.panel}createHiddenVideo(){const t=document.createElement("video");return t.style.cssText="position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;",document.body.appendChild(t),t}loop(){this.rafId=requestAnimationFrame(()=>{this.tick(),this.rafId!==null&&this.loop()})}tick(){var y,T;const t=performance.now(),i=this.detector.detect(this.video,t);if(!i)return;const n=this.flipHorizontal?1-i.indexTip.x:i.indexTip.x,a=i.indexTip.y;this.smoothX=w(this.smoothX,n,1-this.smoothing),this.smoothY=w(this.smoothY,a,1-this.smoothing);const r=Math.round(this.smoothX*window.innerWidth),p=Math.round(this.smoothY*window.innerHeight),h={x:this.smoothX,y:this.smoothY,screenX:r,screenY:p};if(this.emit("move",h),i.gesture==="click"){if(!this.isClicking){this.isClicking=!0;const m=Date.now();m-this.lastClickMs>A&&(this.lastClickMs=m,this.emit("click",h))}}else i.clickPinchDistance>H&&(this.isClicking=!1);this.isClicking||(i.gestureName==="fist"?this.emit("scroll",{deltaY:E}):i.gestureName==="openpalm"&&this.emit("scroll",{deltaY:-E}));const u=i.gestureName;if(u){(y=this.panel)==null||y.setDetected(u,i.gestureConfidence);const m=Date.now(),R=this.lastGestureMs.get(u)??0;if(m-R>D){this.lastGestureMs.set(u,m);const K={gesture:u,...h,confidence:i.gestureConfidence};this.emit(u,K),this.mapper.trigger(u)}}else(T=this.panel)==null||T.setDetected(null,0)}}l.ControlPanel=k,l.GESTURE_META=g,l.GestureMapper=$,l.HandControl=I,l.formatKeyLabel=x,Object.defineProperty(l,Symbol.toStringTag,{value:"Module"})});
192
+ `,document.head.appendChild(e),e}}const R=.09,z=600,T=12,K=900;function N(c){return c==="right"?"Left":c==="left"?"Right":null}class G extends g{constructor(t={}){super();o(this,"video");o(this,"detector");o(this,"rafId",null);o(this,"stream",null);o(this,"panel",null);o(this,"isClicking",!1);o(this,"lastClickMs",0);o(this,"lastGestureMs",new Map);o(this,"smoothX",.5);o(this,"smoothY",.5);o(this,"smoothing");o(this,"flipHorizontal");o(this,"cursorAnchor");o(this,"ownedVideo");o(this,"mapper",new C);this.smoothing=t.smoothing??.6,this.flipHorizontal=t.flipHorizontal??!0,this.cursorAnchor=t.cursorAnchor??"wrist";const n=t.threshold??.05;t.video?(this.video=t.video,this.ownedVideo=!1):(this.video=this.createHiddenVideo(),this.ownedVideo=!0),this.detector=new A(t.wasmPath,n,N(t.handedness??"right"))}async start(){await this.detector.init(),this.stream=await navigator.mediaDevices.getUserMedia({video:!0}),this.video.srcObject=this.stream,await new Promise(t=>{this.video.onloadedmetadata=()=>{this.video.play(),t()}}),this.loop()}stop(){var t,n;this.rafId!==null&&(cancelAnimationFrame(this.rafId),this.rafId=null),(t=this.stream)==null||t.getTracks().forEach(i=>i.stop()),this.stream=null,this.detector.destroy(),(n=this.panel)==null||n.destroy(),this.panel=null,this.ownedVideo&&this.video.remove(),this.removeAllListeners()}createPanel(){return this.panel||(this.panel=new L(this.mapper)),this.panel}createHiddenVideo(){const t=document.createElement("video");return t.style.cssText="position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;",document.body.appendChild(t),t}loop(){this.rafId=requestAnimationFrame(()=>{this.tick(),this.rafId!==null&&this.loop()})}tick(){var x,$;const t=performance.now(),n=this.detector.detect(this.video,t);if(!n)return;const i=this.cursorAnchor==="index"?n.indexTip:this.cursorAnchor==="palm"?n.palmCenter:n.wrist,a=this.flipHorizontal?1-i.x:i.x,r=i.y;this.smoothX=k(this.smoothX,a,1-this.smoothing),this.smoothY=k(this.smoothY,r,1-this.smoothing);const h=Math.round(this.smoothX*window.innerWidth),p=Math.round(this.smoothY*window.innerHeight),m={x:this.smoothX,y:this.smoothY,screenX:h,screenY:p};if(this.emit("move",m),n.gesture==="click"){if(!this.isClicking){this.isClicking=!0;const b=Date.now();b-this.lastClickMs>z&&(this.lastClickMs=b,this.emit("click",m))}}else n.clickPinchDistance>R&&(this.isClicking=!1);this.isClicking||(n.gestureName==="fist"?this.emit("scroll",{deltaY:T}):n.gestureName==="openpalm"&&this.emit("scroll",{deltaY:-T}));const u=n.gestureName;if(u){(x=this.panel)==null||x.setDetected(u,n.gestureConfidence);const b=Date.now(),y=this.lastGestureMs.get(u)??0;if(b-y>K){this.lastGestureMs.set(u,b);const O={gesture:u,...m,confidence:n.gestureConfidence};this.emit(u,O),this.mapper.trigger(u)}}else($=this.panel)==null||$.setDetected(null,0)}}l.ControlPanel=L,l.GESTURE_META=v,l.GestureMapper=C,l.HandControl=G,l.formatKeyLabel=M,Object.defineProperty(l,Symbol.toStringTag,{value:"Module"})});
package/dist/types.d.ts CHANGED
@@ -71,6 +71,18 @@ export interface HandControlOptions {
71
71
  smoothing?: number;
72
72
  /** MediaPipe wasm 파일 경로 (CDN 기본값 제공) */
73
73
  wasmPath?: string;
74
+ /**
75
+ * 감지할 손 방향 (default 'right')
76
+ * 정면 카메라 raw 피드 기준. 기기/환경에 따라 'left'로 전환 필요할 수 있음.
77
+ */
78
+ handedness?: 'right' | 'left' | 'any';
79
+ /**
80
+ * 커서 추적 기준점 (default 'wrist')
81
+ * - 'wrist' : 손목 (손 전체 위치 기반)
82
+ * - 'palm' : 손바닥 중심
83
+ * - 'index' : 검지 끝 (기존 동작)
84
+ */
85
+ cursorAnchor?: 'wrist' | 'palm' | 'index';
74
86
  }
75
87
  /** 제스처 표시 메타데이터 */
76
88
  export declare const GESTURE_META: Record<GestureName, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whatpull/handface",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Hand gesture to mouse event control library using MediaPipe",
5
5
  "type": "module",
6
6
  "main": "./dist/handface.umd.cjs",