@whatpull/handface 0.1.0 → 0.2.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 CHANGED
@@ -2,9 +2,16 @@
2
2
 
3
3
  > MediaPipe 기반 핸드 제스처 → 마우스 이벤트 제어 웹 라이브러리
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/@whatpull/handface?color=cb3837&logo=npm)](https://www.npmjs.com/package/@whatpull/handface)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@whatpull/handface?color=cb3837)](https://www.npmjs.com/package/@whatpull/handface)
7
+ [![license](https://img.shields.io/github/license/whatpull/handface)](https://github.com/whatpull/handface/blob/main/LICENSE)
8
+ [![demo](https://img.shields.io/badge/demo-live-6e40c9?logo=github)](https://whatpull.github.io/handface/)
9
+
5
10
  손동작으로 마우스 클릭, 커서 이동, 스크롤을 제어할 수 있는 **프레임워크 독립(framework agnostic)** npm 라이브러리입니다.
6
11
  React, Vue, Svelte, 바닐라 JS 어디서든 동일하게 동작합니다.
7
12
 
13
+ **[라이브 데모 →](https://whatpull.github.io/handface/)**
14
+
8
15
  ---
9
16
 
10
17
  ## 설치
@@ -76,8 +83,8 @@ control.stop();
76
83
  | 이벤트 | 제스처 | 페이로드 |
77
84
  |--------|--------|---------|
78
85
  | `move` | 검지 손가락 이동 | `{ x, y, screenX, screenY }` |
79
- | `click` | 엄지+검지 핀치 | `{ x, y, screenX, screenY }` |
80
- | `scroll` | 검지+중지 수직 이동 | `{ deltaY }` |
86
+ | `click` | 엄지+중지 핀치 | `{ x, y, screenX, screenY }` |
87
+ | `scroll` | 주먹(줌 인) / 펼친 손(줌 아웃) | `{ deltaY }` |
81
88
  | `rightclick` | (예정) | - |
82
89
  | `drag` / `dragstart` / `dragend` | (예정) | - |
83
90
 
@@ -86,8 +93,9 @@ control.stop();
86
93
  | 제스처 | 동작 |
87
94
  |--------|------|
88
95
  | ☝️ 검지만 펴기 | 커서 이동 (`move`) |
89
- | 🤏 엄지+검지 붙이기 | 클릭 (`click`) |
90
- | ✌️ 검지+중지 위아래 | 스크롤 (`scroll`) |
96
+ | 🤏 엄지+중지 붙이기 | 클릭 (`click`) |
97
+ | 손가락 모두 오무림 | (`scroll` deltaY > 0) |
98
+ | 🖐️ 손가락 모두 펼침 | 줌 아웃 (`scroll` deltaY < 0) |
91
99
 
92
100
  ---
93
101
 
@@ -146,6 +154,19 @@ npm run typecheck # TypeScript 타입 체크
146
154
 
147
155
  ## 개발 로그
148
156
 
157
+ ### v0.2.0 — 2026-04-11
158
+
159
+ **제스처 설정 패널 + 개발자 제스처 이벤트 API**
160
+
161
+ - `GestureRecognizer` 기반으로 교체 (MediaPipe 7가지 내장 제스처: 👍👎✌️🤟✊🖐️☝️)
162
+ - `HandControl.createPanel()` — 우측 하단 플로팅 ✋ 버튼 + 설정 패널 주입
163
+ - 일반 사용자: 패널에서 제스처 → 단축키 바인딩 (localStorage 영구 저장)
164
+ - 개발자 API: `control.on('thumbsup' | 'victory' | 'iloveyou' | ...)` 제스처 이벤트 구독
165
+ - `GestureMapper` 클래스 — `bind()` / `unbind()` / `trigger()` 직접 제어
166
+ - 데모 업데이트: 커스텀 제스처로 크리스탈 색상 변화 연동
167
+
168
+ ---
169
+
149
170
  ### v0.1.0 — 2026-04-11
150
171
 
151
172
  **초기 구현**
@@ -162,4 +183,4 @@ npm run typecheck # TypeScript 타입 체크
162
183
 
163
184
  ## 라이선스
164
185
 
165
- MIT
186
+ Apache 2.0
@@ -0,0 +1,33 @@
1
+ import { GestureMapper } from './GestureMapper';
2
+ import { GestureName } from './types';
3
+
4
+ /** 핸드페이스 플로팅 설정 패널 */
5
+ export declare class ControlPanel {
6
+ private readonly mapper;
7
+ private root;
8
+ private fab;
9
+ private panel;
10
+ private styleEl;
11
+ private isOpen;
12
+ private capturingGesture;
13
+ private captureHandler;
14
+ private detectedGesture;
15
+ constructor(mapper: GestureMapper);
16
+ /** HandControl 에서 매 프레임 호출 — 현재 감지 제스처 표시 */
17
+ setDetected(gesture: GestureName | null, confidence: number): void;
18
+ destroy(): void;
19
+ private toggle;
20
+ private open;
21
+ private close;
22
+ private startCapture;
23
+ private stopCapture;
24
+ private createFab;
25
+ private createPanel;
26
+ private renderRows;
27
+ private renderBuiltin;
28
+ private renderBindings;
29
+ private makeReadonlyRow;
30
+ private makeBindingRow;
31
+ private buildKeyLabel;
32
+ private injectStyles;
33
+ }
@@ -1,21 +1,24 @@
1
- export type GestureType = 'pinch' | 'point' | 'two-finger' | 'open' | 'none';
1
+ import { GestureName } from './types';
2
+
3
+ export type DetectedGesture = GestureName | 'click' | 'none';
2
4
  export interface DetectionResult {
3
- gesture: GestureType;
4
- pinchDistance: number;
5
+ /** 최우선 결정된 제스처 (click 핀치 > GestureRecognizer 결과) */
6
+ gesture: DetectedGesture;
7
+ /** GestureRecognizer 원본 결과 (null = None 또는 미감지) */
8
+ gestureName: GestureName | null;
9
+ gestureConfidence: number;
10
+ /** 클릭 감지용 엄지 ↔ 중지 거리 */
11
+ clickPinchDistance: number;
5
12
  indexTip: {
6
13
  x: number;
7
14
  y: number;
8
15
  };
9
- middleTip: {
10
- x: number;
11
- y: number;
12
- };
13
16
  }
14
17
  export declare class GestureDetector {
15
18
  private readonly wasmPath;
16
- private readonly pinchThreshold;
17
- private landmarker;
18
- constructor(wasmPath?: string, pinchThreshold?: number);
19
+ private readonly clickThreshold;
20
+ private recognizer;
21
+ constructor(wasmPath?: string, clickThreshold?: number);
19
22
  init(): Promise<void>;
20
23
  detect(video: HTMLVideoElement, timestampMs: number): DetectionResult | null;
21
24
  private analyze;
@@ -0,0 +1,17 @@
1
+ import { GestureName, GestureKeyBinding } from './types';
2
+
3
+ /** KeyboardEvent.key → 화면 표시용 레이블 */
4
+ export declare function formatKeyLabel(key: string): string;
5
+ /** 단축키 바인딩 + 키보드 이벤트 디스패치 엔진 */
6
+ export declare class GestureMapper {
7
+ private bindings;
8
+ constructor();
9
+ bind(gesture: GestureName, key: string, modifiers?: GestureKeyBinding['modifiers']): void;
10
+ unbind(gesture: GestureName): void;
11
+ getBinding(gesture: GestureName): GestureKeyBinding | undefined;
12
+ getAll(): GestureKeyBinding[];
13
+ /** 제스처에 바인딩된 키보드 이벤트 디스패치 */
14
+ trigger(gesture: GestureName): void;
15
+ private save;
16
+ private load;
17
+ }
@@ -1,4 +1,6 @@
1
1
  import { EventEmitter } from './EventEmitter';
2
+ import { GestureMapper } from './GestureMapper';
3
+ import { ControlPanel } from './ControlPanel';
2
4
  import { HandControlEventMap, HandControlOptions } from './types';
3
5
 
4
6
  export declare class HandControl extends EventEmitter<HandControlEventMap> {
@@ -6,20 +8,29 @@ export declare class HandControl extends EventEmitter<HandControlEventMap> {
6
8
  private detector;
7
9
  private rafId;
8
10
  private stream;
9
- private isPinching;
11
+ private panel;
12
+ private isClicking;
10
13
  private lastClickMs;
11
- private prevTwoFingerY;
14
+ /** GestureName → 마지막 발화 타임스탬프 */
15
+ private lastGestureMs;
12
16
  private smoothX;
13
17
  private smoothY;
14
18
  private readonly threshold;
15
19
  private readonly smoothing;
16
20
  private readonly flipHorizontal;
17
21
  private readonly ownedVideo;
22
+ /** 단축키 바인딩 엔진 — 직접 접근 가능 */
23
+ readonly mapper: GestureMapper;
18
24
  constructor(options?: HandControlOptions);
19
25
  /** 카메라 열고 감지 시작 */
20
26
  start(): Promise<void>;
21
27
  /** 감지 중지 및 리소스 해제 */
22
28
  stop(): void;
29
+ /**
30
+ * 플로팅 설정 패널을 생성하고 document.body 에 주입합니다.
31
+ * 이미 생성된 경우 기존 인스턴스를 반환합니다.
32
+ */
33
+ createPanel(): ControlPanel;
23
34
  private createHiddenVideo;
24
35
  private loop;
25
36
  private tick;
package/dist/handface.js CHANGED
@@ -1,112 +1,513 @@
1
- var f = Object.defineProperty;
2
- var u = (n, t, e) => t in n ? f(n, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : n[t] = e;
3
- var s = (n, t, e) => u(n, typeof t != "symbol" ? t + "" : t, e);
4
- import { FilesetResolver as w, HandLandmarker as g } from "@mediapipe/tasks-vision";
5
- class T {
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 {
6
6
  constructor() {
7
- s(this, "_listeners", /* @__PURE__ */ new Map());
7
+ o(this, "_listeners", /* @__PURE__ */ new Map());
8
8
  }
9
- on(t, e) {
10
- return this._listeners.has(t) || this._listeners.set(t, /* @__PURE__ */ new Set()), this._listeners.get(t).add(e), this;
9
+ on(e, t) {
10
+ return this._listeners.has(e) || this._listeners.set(e, /* @__PURE__ */ new Set()), this._listeners.get(e).add(t), this;
11
11
  }
12
- off(t, e) {
12
+ off(e, t) {
13
13
  var i;
14
- return (i = this._listeners.get(t)) == null || i.delete(e), this;
14
+ return (i = this._listeners.get(e)) == null || i.delete(t), this;
15
15
  }
16
- emit(t, e) {
16
+ emit(e, t) {
17
17
  var i;
18
- (i = this._listeners.get(t)) == null || i.forEach((o) => o(e));
18
+ (i = this._listeners.get(e)) == null || i.forEach((n) => n(t));
19
19
  }
20
- removeAllListeners(t) {
21
- return t ? this._listeners.delete(t) : this._listeners.clear(), this;
20
+ removeAllListeners(e) {
21
+ return e ? this._listeners.delete(e) : this._listeners.clear(), this;
22
22
  }
23
23
  }
24
- function _(n, t) {
25
- return Math.sqrt((n.x - t.x) ** 2 + (n.y - t.y) ** 2);
24
+ function T(r, e) {
25
+ return Math.sqrt((r.x - e.x) ** 2 + (r.y - e.y) ** 2);
26
26
  }
27
- function p(n, t, e) {
28
- return n + e * (t - n);
27
+ function f(r, e, t) {
28
+ return r + t * (e - r);
29
29
  }
30
- const d = {
30
+ const b = {
31
31
  THUMB_TIP: 4,
32
- INDEX_MCP: 5,
33
32
  INDEX_TIP: 8,
34
- MIDDLE_TIP: 12,
35
- MIDDLE_MCP: 9
36
- }, v = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm", M = "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
37
- class x {
38
- constructor(t = v, e = 0.06) {
39
- s(this, "landmarker", null);
40
- this.wasmPath = t, this.pinchThreshold = e;
33
+ MIDDLE_TIP: 12
34
+ }, C = {
35
+ Pointing_Up: "pointing",
36
+ Closed_Fist: "fist",
37
+ Open_Palm: "openpalm",
38
+ Thumb_Up: "thumbsup",
39
+ Thumb_Down: "thumbsdown",
40
+ Victory: "victory",
41
+ 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;
41
47
  }
42
48
  async init() {
43
- const t = await w.forVisionTasks(this.wasmPath);
44
- this.landmarker = await g.createFromOptions(t, {
49
+ const e = await E.forVisionTasks(this.wasmPath);
50
+ this.recognizer = await L.createFromOptions(e, {
45
51
  baseOptions: {
46
- modelAssetPath: M,
52
+ modelAssetPath: P,
47
53
  delegate: "GPU"
48
54
  },
49
55
  numHands: 1,
50
56
  runningMode: "VIDEO"
51
57
  });
52
58
  }
53
- detect(t, e) {
54
- if (!this.landmarker) return null;
55
- const { landmarks: i } = this.landmarker.detectForVideo(t, e);
56
- return !i || i.length === 0 ? null : this.analyze(i[0]);
57
- }
58
- analyze(t) {
59
- const e = t[d.THUMB_TIP], i = t[d.INDEX_TIP], o = t[d.MIDDLE_TIP], c = t[d.INDEX_MCP], m = t[d.MIDDLE_MCP], l = _(e, i), a = i.y < c.y, r = o.y < m.y;
60
- let h;
61
- return l < this.pinchThreshold ? h = "pinch" : a && r ? h = "two-finger" : a ? h = "point" : h = "open", {
62
- gesture: h,
63
- pinchDistance: l,
64
- indexTip: { x: i.x, y: i.y },
65
- middleTip: { x: o.x, y: o.y }
59
+ detect(e, t) {
60
+ 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);
65
+ }
66
+ 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;
68
+ 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 }
66
74
  };
67
75
  }
68
76
  destroy() {
69
- var t;
70
- (t = this.landmarker) == null || t.close(), this.landmarker = null;
77
+ var e;
78
+ (e = this.recognizer) == null || e.close(), this.recognizer = null;
79
+ }
80
+ }
81
+ const g = "handface_key_bindings";
82
+ function S(r) {
83
+ return {
84
+ " ": "Space",
85
+ ArrowUp: "↑",
86
+ ArrowDown: "↓",
87
+ ArrowLeft: "←",
88
+ ArrowRight: "→",
89
+ Escape: "Esc",
90
+ Backspace: "⌫",
91
+ Delete: "Del",
92
+ Enter: "↵",
93
+ Tab: "Tab",
94
+ PageUp: "PgUp",
95
+ PageDown: "PgDn",
96
+ Home: "Home",
97
+ End: "End"
98
+ }[r] ?? r;
99
+ }
100
+ class H {
101
+ constructor() {
102
+ o(this, "bindings", /* @__PURE__ */ new Map());
103
+ this.load();
104
+ }
105
+ bind(e, t, i) {
106
+ this.bindings.set(e, { gesture: e, key: t, modifiers: i }), this.save();
107
+ }
108
+ unbind(e) {
109
+ this.bindings.delete(e), this.save();
110
+ }
111
+ getBinding(e) {
112
+ return this.bindings.get(e);
113
+ }
114
+ getAll() {
115
+ return [...this.bindings.values()];
116
+ }
117
+ /** 제스처에 바인딩된 키보드 이벤트 디스패치 */
118
+ trigger(e) {
119
+ var n, l, a, c;
120
+ const t = this.bindings.get(e);
121
+ if (!t) return;
122
+ const i = {
123
+ key: t.key,
124
+ bubbles: !0,
125
+ cancelable: !0,
126
+ ctrlKey: ((n = t.modifiers) == null ? void 0 : n.ctrl) ?? !1,
127
+ altKey: ((l = t.modifiers) == null ? void 0 : l.alt) ?? !1,
128
+ shiftKey: ((a = t.modifiers) == null ? void 0 : a.shift) ?? !1,
129
+ metaKey: ((c = t.modifiers) == null ? void 0 : c.meta) ?? !1
130
+ };
131
+ document.dispatchEvent(new KeyboardEvent("keydown", i)), document.dispatchEvent(new KeyboardEvent("keyup", i));
132
+ }
133
+ save() {
134
+ try {
135
+ localStorage.setItem(g, JSON.stringify([...this.bindings.entries()]));
136
+ } catch {
137
+ }
138
+ }
139
+ load() {
140
+ try {
141
+ const e = localStorage.getItem(g);
142
+ e && (this.bindings = new Map(JSON.parse(e)));
143
+ } catch {
144
+ }
71
145
  }
72
146
  }
73
- const I = 0.09, y = 600;
74
- class k extends T {
75
- constructor(e = {}) {
147
+ const y = {
148
+ 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: "줌 아웃" },
151
+ thumbsup: { icon: "👍", label: "Thumbs Up", labelKo: "엄지 위", builtin: !1 },
152
+ thumbsdown: { icon: "👎", label: "Thumbs Down", labelKo: "엄지 아래", builtin: !1 },
153
+ victory: { icon: "✌️", label: "Victory", labelKo: "브이", builtin: !1 },
154
+ iloveyou: { icon: "🤟", label: "I Love You", labelKo: "아이 러브 유", builtin: !1 }
155
+ }, s = "hf-", A = ["thumbsup", "thumbsdown", "victory", "iloveyou"], D = ["pointing", "fist", "openpalm"];
156
+ class I {
157
+ 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);
166
+ 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
+ }
168
+ /** HandControl 에서 매 프레임 호출 — 현재 감지 제스처 표시 */
169
+ 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);
173
+ }));
174
+ }
175
+ destroy() {
176
+ this.stopCapture(), this.styleEl.remove(), this.root.remove();
177
+ }
178
+ // ─────────────────────────────────────────
179
+ // Panel open / close
180
+ // ─────────────────────────────────────────
181
+ toggle() {
182
+ this.isOpen ? this.close() : this.open();
183
+ }
184
+ open() {
185
+ this.isOpen = !0, this.renderRows(), this.panel.classList.add(`${s}open`), this.fab.classList.add(`${s}fab-open`);
186
+ }
187
+ close() {
188
+ this.isOpen = !1, this.stopCapture(), this.panel.classList.remove(`${s}open`), this.fab.classList.remove(`${s}fab-open`);
189
+ }
190
+ // ─────────────────────────────────────────
191
+ // Key capture
192
+ // ─────────────────────────────────────────
193
+ startCapture(e) {
194
+ this.stopCapture(), this.capturingGesture = e, this.captureHandler = (t) => {
195
+ if (t.preventDefault(), t.stopImmediatePropagation(), ["Shift", "Control", "Alt", "Meta"].includes(t.key)) {
196
+ document.addEventListener("keydown", this.captureHandler, { once: !0, capture: !0 });
197
+ return;
198
+ }
199
+ this.mapper.bind(e, t.key, {
200
+ ctrl: t.ctrlKey || void 0,
201
+ alt: t.altKey || void 0,
202
+ shift: t.shiftKey || void 0,
203
+ meta: t.metaKey || void 0
204
+ }), this.capturingGesture = null, this.captureHandler = null, this.renderRows();
205
+ }, document.addEventListener("keydown", this.captureHandler, { once: !0, capture: !0 }), this.renderRows();
206
+ }
207
+ stopCapture() {
208
+ this.captureHandler && (document.removeEventListener("keydown", this.captureHandler, { capture: !0 }), this.captureHandler = null, this.capturingGesture = null);
209
+ }
210
+ // ─────────────────────────────────────────
211
+ // DOM construction
212
+ // ─────────────────────────────────────────
213
+ createFab() {
214
+ const e = document.createElement("button");
215
+ return e.className = `${s}fab`, e.title = "handface 제스처 설정", e.innerHTML = "✋", e;
216
+ }
217
+ createPanel() {
218
+ const e = document.createElement("div");
219
+ return e.className = `${s}panel`, e.innerHTML = `
220
+ <div class="${s}header">
221
+ <span class="${s}title">✋ handface</span>
222
+ <button class="${s}close-btn" title="닫기">✕</button>
223
+ </div>
224
+ <div class="${s}body">
225
+ <div class="${s}section-label">기본 동작</div>
226
+ <div class="${s}builtin-rows"></div>
227
+ <div class="${s}section-label" style="margin-top:10px">단축키 바인딩</div>
228
+ <div class="${s}binding-rows"></div>
229
+ </div>
230
+ `, e.querySelector(`.${s}close-btn`).addEventListener("click", () => this.close()), e;
231
+ }
232
+ renderRows() {
233
+ this.renderBuiltin(), this.renderBindings();
234
+ }
235
+ renderBuiltin() {
236
+ const e = this.panel.querySelector(`.${s}builtin-rows`);
237
+ e.innerHTML = "", e.appendChild(this.makeReadonlyRow("🤏", "엄지+중지 핀치", "클릭", null));
238
+ for (const t of D) {
239
+ const i = y[t];
240
+ e.appendChild(
241
+ this.makeReadonlyRow(i.icon, i.labelKo, i.builtinAction, t)
242
+ );
243
+ }
244
+ }
245
+ renderBindings() {
246
+ const e = this.panel.querySelector(`.${s}binding-rows`);
247
+ e.innerHTML = "";
248
+ for (const t of A) {
249
+ const i = y[t], n = this.mapper.getBinding(t), l = this.capturingGesture === t;
250
+ e.appendChild(
251
+ this.makeBindingRow(t, i.icon, i.labelKo, (n == null ? void 0 : n.key) ?? null, l)
252
+ );
253
+ }
254
+ }
255
+ makeReadonlyRow(e, t, i, n) {
256
+ const l = document.createElement("div");
257
+ return l.className = `${s}row`, n && (l.dataset.gesture = n), l.innerHTML = `
258
+ <span class="${s}icon">${e}</span>
259
+ <span class="${s}name">${t}</span>
260
+ <span class="${s}badge">${i}</span>
261
+ `, l;
262
+ }
263
+ makeBindingRow(e, t, i, n, l) {
264
+ var d;
265
+ const a = document.createElement("div");
266
+ a.className = `${s}row`, a.dataset.gesture = e;
267
+ const c = n ? this.buildKeyLabel(this.mapper.getBinding(e)) : null;
268
+ return l ? (a.innerHTML = `
269
+ <span class="${s}icon">${t}</span>
270
+ <span class="${s}name">${i}</span>
271
+ <span class="${s}capture-hint">단축키 입력...</span>
272
+ <button class="${s}cancel-btn">취소</button>
273
+ `, a.querySelector(`.${s}cancel-btn`).addEventListener("click", () => {
274
+ this.stopCapture(), this.renderRows();
275
+ })) : (a.innerHTML = `
276
+ <span class="${s}icon">${t}</span>
277
+ <span class="${s}name">${i}</span>
278
+ ${c ? `<span class="${s}key-tag">${c}</span>
279
+ <button class="${s}bind-btn ${s}clear-btn" data-gesture="${e}" title="제거">✕</button>` : `<span class="${s}no-bind">—</span>`}
280
+ <button class="${s}bind-btn ${s}edit-btn" data-gesture="${e}" title="단축키 설정">✎</button>
281
+ `, a.querySelector(`.${s}edit-btn`).addEventListener("click", () => this.startCapture(e)), (d = a.querySelector(`.${s}clear-btn`)) == null || d.addEventListener("click", () => {
282
+ this.mapper.unbind(e), this.renderRows();
283
+ })), a;
284
+ }
285
+ buildKeyLabel(e) {
286
+ var i, n, l, a;
287
+ 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("+");
289
+ }
290
+ // ─────────────────────────────────────────
291
+ // Styles
292
+ // ─────────────────────────────────────────
293
+ injectStyles() {
294
+ const e = document.createElement("style");
295
+ return e.dataset.handface = "styles", e.textContent = `
296
+ .${s}fab {
297
+ position: fixed;
298
+ right: 20px;
299
+ bottom: 20px;
300
+ width: 46px;
301
+ height: 46px;
302
+ border-radius: 50%;
303
+ background: linear-gradient(135deg, #7c6af7, #5b4dd4);
304
+ color: #fff;
305
+ border: none;
306
+ cursor: pointer;
307
+ font-size: 1.25rem;
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ box-shadow: 0 4px 20px rgba(124,106,247,0.45);
312
+ z-index: 999998;
313
+ transition: transform 0.18s, box-shadow 0.18s;
314
+ user-select: none;
315
+ }
316
+ .${s}fab:hover { transform: scale(1.1); box-shadow: 0 6px 28px rgba(124,106,247,0.65); }
317
+ .${s}fab-open { transform: rotate(20deg) scale(1.05); }
318
+
319
+ .${s}panel {
320
+ position: fixed;
321
+ right: 20px;
322
+ bottom: 76px;
323
+ width: 272px;
324
+ background: rgba(10, 10, 20, 0.97);
325
+ border: 1px solid rgba(124,106,247,0.28);
326
+ border-radius: 16px;
327
+ z-index: 999999;
328
+ -webkit-backdrop-filter: blur(24px);
329
+ backdrop-filter: blur(24px);
330
+ box-shadow: 0 12px 48px rgba(0,0,0,0.55);
331
+ color: #ddddf5;
332
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
333
+ font-size: 13px;
334
+ opacity: 0;
335
+ transform: translateY(10px) scale(0.97);
336
+ pointer-events: none;
337
+ transition: opacity 0.2s ease, transform 0.2s ease;
338
+ }
339
+ .${s}open {
340
+ opacity: 1;
341
+ transform: translateY(0) scale(1);
342
+ pointer-events: all;
343
+ }
344
+
345
+ .${s}header {
346
+ display: flex;
347
+ align-items: center;
348
+ justify-content: space-between;
349
+ padding: 13px 16px 11px;
350
+ border-bottom: 1px solid rgba(255,255,255,0.07);
351
+ }
352
+ .${s}title {
353
+ font-weight: 700;
354
+ font-size: 0.85rem;
355
+ letter-spacing: -0.01em;
356
+ }
357
+ .${s}close-btn {
358
+ background: none;
359
+ border: none;
360
+ color: rgba(221,221,245,0.45);
361
+ cursor: pointer;
362
+ font-size: 0.8rem;
363
+ padding: 2px 4px;
364
+ border-radius: 4px;
365
+ transition: color 0.12s;
366
+ }
367
+ .${s}close-btn:hover { color: #ddddf5; }
368
+
369
+ .${s}body {
370
+ padding: 12px 14px 14px;
371
+ }
372
+ .${s}section-label {
373
+ font-size: 0.6rem;
374
+ text-transform: uppercase;
375
+ letter-spacing: 0.11em;
376
+ opacity: 0.35;
377
+ margin-bottom: 7px;
378
+ }
379
+
380
+ .${s}row {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 8px;
384
+ padding: 5px 6px;
385
+ border-radius: 8px;
386
+ margin-bottom: 3px;
387
+ transition: background 0.15s;
388
+ }
389
+ .${s}row.${s}active {
390
+ background: rgba(124,106,247,0.18);
391
+ }
392
+
393
+ .${s}icon { font-size: 1rem; width: 22px; text-align: center; flex-shrink: 0; }
394
+ .${s}name { flex: 1; opacity: 0.8; font-size: 0.76rem; }
395
+
396
+ .${s}badge {
397
+ font-size: 0.65rem;
398
+ background: rgba(124,106,247,0.25);
399
+ color: #9d8dff;
400
+ padding: 2px 7px;
401
+ border-radius: 99px;
402
+ white-space: nowrap;
403
+ }
404
+
405
+ .${s}key-tag {
406
+ font-size: 0.65rem;
407
+ font-family: 'SF Mono', 'Fira Code', monospace;
408
+ background: rgba(78,205,196,0.15);
409
+ color: #4ecdc4;
410
+ padding: 2px 7px;
411
+ border-radius: 6px;
412
+ white-space: nowrap;
413
+ max-width: 80px;
414
+ overflow: hidden;
415
+ text-overflow: ellipsis;
416
+ }
417
+
418
+ .${s}no-bind {
419
+ font-size: 0.72rem;
420
+ opacity: 0.3;
421
+ }
422
+
423
+ .${s}bind-btn {
424
+ background: none;
425
+ border: none;
426
+ cursor: pointer;
427
+ border-radius: 5px;
428
+ padding: 2px 5px;
429
+ font-size: 0.75rem;
430
+ transition: background 0.12s, color 0.12s;
431
+ flex-shrink: 0;
432
+ }
433
+ .${s}edit-btn { color: rgba(221,221,245,0.45); }
434
+ .${s}edit-btn:hover { background: rgba(124,106,247,0.2); color: #9d8dff; }
435
+ .${s}clear-btn { color: rgba(221,221,245,0.3); }
436
+ .${s}clear-btn:hover { background: rgba(255,80,80,0.15); color: #ff6b6b; }
437
+
438
+ .${s}capture-hint {
439
+ flex: 1;
440
+ font-size: 0.7rem;
441
+ color: #ffd166;
442
+ animation: ${s}blink 1s step-end infinite;
443
+ }
444
+ .${s}cancel-btn {
445
+ background: none;
446
+ border: 1px solid rgba(255,80,80,0.3);
447
+ color: rgba(255,100,100,0.7);
448
+ border-radius: 5px;
449
+ padding: 2px 7px;
450
+ font-size: 0.65rem;
451
+ cursor: pointer;
452
+ flex-shrink: 0;
453
+ }
454
+ .${s}cancel-btn:hover { background: rgba(255,80,80,0.1); }
455
+
456
+ @keyframes ${s}blink {
457
+ 0%, 100% { opacity: 1; }
458
+ 50% { opacity: 0.3; }
459
+ }
460
+ `, document.head.appendChild(e), e;
461
+ }
462
+ }
463
+ const R = 0.09, K = 600, w = 12, O = 900;
464
+ class N extends M {
465
+ constructor(t = {}) {
76
466
  super();
77
- s(this, "video");
78
- s(this, "detector");
79
- s(this, "rafId", null);
80
- s(this, "stream", null);
81
- // gesture state
82
- s(this, "isPinching", !1);
83
- s(this, "lastClickMs", 0);
84
- s(this, "prevTwoFingerY", 0);
85
- // smoothed cursor position
86
- s(this, "smoothX", 0.5);
87
- s(this, "smoothY", 0.5);
88
- s(this, "threshold");
89
- s(this, "smoothing");
90
- s(this, "flipHorizontal");
91
- s(this, "ownedVideo");
92
- this.threshold = e.threshold ?? 0.05, this.smoothing = e.smoothing ?? 0.6, this.flipHorizontal = e.flipHorizontal ?? !0, e.video ? (this.video = e.video, this.ownedVideo = !1) : (this.video = this.createHiddenVideo(), this.ownedVideo = !0), this.detector = new x(e.wasmPath, this.threshold);
467
+ o(this, "video");
468
+ o(this, "detector");
469
+ o(this, "rafId", null);
470
+ o(this, "stream", null);
471
+ o(this, "panel", null);
472
+ // 제스처 상태
473
+ o(this, "isClicking", !1);
474
+ o(this, "lastClickMs", 0);
475
+ /** 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");
484
+ /** 단축키 바인딩 엔진 — 직접 접근 가능 */
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);
93
487
  }
94
488
  /** 카메라 열고 감지 시작 */
95
489
  async start() {
96
- await this.detector.init(), this.stream = await navigator.mediaDevices.getUserMedia({ video: !0 }), this.video.srcObject = this.stream, await new Promise((e) => {
490
+ await this.detector.init(), this.stream = await navigator.mediaDevices.getUserMedia({ video: !0 }), this.video.srcObject = this.stream, await new Promise((t) => {
97
491
  this.video.onloadedmetadata = () => {
98
- this.video.play(), e();
492
+ this.video.play(), t();
99
493
  };
100
494
  }), this.loop();
101
495
  }
102
496
  /** 감지 중지 및 리소스 해제 */
103
497
  stop() {
104
- var e;
105
- this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null), (e = this.stream) == null || e.getTracks().forEach((i) => i.stop()), this.stream = null, this.detector.destroy(), this.ownedVideo && this.video.remove(), this.removeAllListeners();
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();
500
+ }
501
+ /**
502
+ * 플로팅 설정 패널을 생성하고 document.body 에 주입합니다.
503
+ * 이미 생성된 경우 기존 인스턴스를 반환합니다.
504
+ */
505
+ createPanel() {
506
+ return this.panel || (this.panel = new I(this.mapper)), this.panel;
106
507
  }
107
508
  createHiddenVideo() {
108
- const e = document.createElement("video");
109
- return e.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;", document.body.appendChild(e), e;
509
+ const t = document.createElement("video");
510
+ return t.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;", document.body.appendChild(t), t;
110
511
  }
111
512
  loop() {
112
513
  this.rafId = requestAnimationFrame(() => {
@@ -114,34 +515,46 @@ class k extends T {
114
515
  });
115
516
  }
116
517
  tick() {
117
- const e = performance.now(), i = this.detector.detect(this.video, e);
518
+ var u, m;
519
+ const t = performance.now(), i = this.detector.detect(this.video, t);
118
520
  if (!i) return;
119
- const o = this.flipHorizontal ? 1 - i.indexTip.x : i.indexTip.x, c = i.indexTip.y;
120
- this.smoothX = p(this.smoothX, o, 1 - this.smoothing), this.smoothY = p(this.smoothY, c, 1 - this.smoothing);
121
- const m = Math.round(this.smoothX * window.innerWidth), l = Math.round(this.smoothY * window.innerHeight), a = {
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 = {
122
524
  x: this.smoothX,
123
525
  y: this.smoothY,
124
- screenX: m,
125
- screenY: l
526
+ screenX: a,
527
+ screenY: c
126
528
  };
127
- if (this.emit("move", a), i.gesture === "pinch") {
128
- if (!this.isPinching) {
129
- this.isPinching = !0;
130
- const r = Date.now();
131
- r - this.lastClickMs > y && (this.lastClickMs = r, this.emit("click", a));
132
- }
133
- } else i.pinchDistance > I && (this.isPinching = !1);
134
- if (i.gesture === "two-finger") {
135
- const r = i.indexTip.y;
136
- if (this.prevTwoFingerY !== 0) {
137
- const h = (r - this.prevTwoFingerY) * 1e3;
138
- this.emit("scroll", { deltaY: h });
139
- }
140
- this.prevTwoFingerY = r;
529
+ if (this.emit("move", d), i.gesture === "click") {
530
+ if (!this.isClicking) {
531
+ this.isClicking = !0;
532
+ const h = Date.now();
533
+ h - this.lastClickMs > K && (this.lastClickMs = h, this.emit("click", d));
534
+ }
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
547
+ };
548
+ this.emit(p, v), this.mapper.trigger(p);
549
+ }
141
550
  } else
142
- this.prevTwoFingerY = 0;
551
+ (m = this.panel) == null || m.setDetected(null, 0);
143
552
  }
144
553
  }
145
554
  export {
146
- k as HandControl
555
+ I as ControlPanel,
556
+ y as GESTURE_META,
557
+ H as GestureMapper,
558
+ N as HandControl,
559
+ S as formatKeyLabel
147
560
  };
@@ -1 +1,192 @@
1
- (function(o,n){typeof exports=="object"&&typeof module<"u"?n(exports,require("@mediapipe/tasks-vision")):typeof define=="function"&&define.amd?define(["exports","@mediapipe/tasks-vision"],n):(o=typeof globalThis<"u"?globalThis:o||self,n(o.Handface={},o.MediaPipeTasks))})(this,function(o,n){"use strict";var I=Object.defineProperty;var P=(o,n,l)=>n in o?I(o,n,{enumerable:!0,configurable:!0,writable:!0,value:l}):o[n]=l;var s=(o,n,l)=>P(o,typeof n!="symbol"?n+"":n,l);class l{constructor(){s(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(h=>h(t))}removeAllListeners(e){return e?this._listeners.delete(e):this._listeners.clear(),this}}function g(r,e){return Math.sqrt((r.x-e.x)**2+(r.y-e.y)**2)}function w(r,e,t){return r+t*(e-r)}const c={THUMB_TIP:4,INDEX_MCP:5,INDEX_TIP:8,MIDDLE_TIP:12,MIDDLE_MCP:9},T="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm",v="https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";class M{constructor(e=T,t=.06){s(this,"landmarker",null);this.wasmPath=e,this.pinchThreshold=t}async init(){const e=await n.FilesetResolver.forVisionTasks(this.wasmPath);this.landmarker=await n.HandLandmarker.createFromOptions(e,{baseOptions:{modelAssetPath:v,delegate:"GPU"},numHands:1,runningMode:"VIDEO"})}detect(e,t){if(!this.landmarker)return null;const{landmarks:i}=this.landmarker.detectForVideo(e,t);return!i||i.length===0?null:this.analyze(i[0])}analyze(e){const t=e[c.THUMB_TIP],i=e[c.INDEX_TIP],h=e[c.MIDDLE_TIP],f=e[c.INDEX_MCP],u=e[c.MIDDLE_MCP],p=g(t,i),m=i.y<f.y,d=h.y<u.y;let a;return p<this.pinchThreshold?a="pinch":m&&d?a="two-finger":m?a="point":a="open",{gesture:a,pinchDistance:p,indexTip:{x:i.x,y:i.y},middleTip:{x:h.x,y:h.y}}}destroy(){var e;(e=this.landmarker)==null||e.close(),this.landmarker=null}}const _=.09,y=600;class x extends l{constructor(t={}){super();s(this,"video");s(this,"detector");s(this,"rafId",null);s(this,"stream",null);s(this,"isPinching",!1);s(this,"lastClickMs",0);s(this,"prevTwoFingerY",0);s(this,"smoothX",.5);s(this,"smoothY",.5);s(this,"threshold");s(this,"smoothing");s(this,"flipHorizontal");s(this,"ownedVideo");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 M(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;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(),this.ownedVideo&&this.video.remove(),this.removeAllListeners()}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(){const t=performance.now(),i=this.detector.detect(this.video,t);if(!i)return;const h=this.flipHorizontal?1-i.indexTip.x:i.indexTip.x,f=i.indexTip.y;this.smoothX=w(this.smoothX,h,1-this.smoothing),this.smoothY=w(this.smoothY,f,1-this.smoothing);const u=Math.round(this.smoothX*window.innerWidth),p=Math.round(this.smoothY*window.innerHeight),m={x:this.smoothX,y:this.smoothY,screenX:u,screenY:p};if(this.emit("move",m),i.gesture==="pinch"){if(!this.isPinching){this.isPinching=!0;const d=Date.now();d-this.lastClickMs>y&&(this.lastClickMs=d,this.emit("click",m))}}else i.pinchDistance>_&&(this.isPinching=!1);if(i.gesture==="two-finger"){const d=i.indexTip.y;if(this.prevTwoFingerY!==0){const a=(d-this.prevTwoFingerY)*1e3;this.emit("scroll",{deltaY:a})}this.prevTwoFingerY=d}else this.prevTwoFingerY=0}}o.HandControl=x,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"})});
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=`
2
+ <div class="${s}header">
3
+ <span class="${s}title">✋ handface</span>
4
+ <button class="${s}close-btn" title="닫기">✕</button>
5
+ </div>
6
+ <div class="${s}body">
7
+ <div class="${s}section-label">기본 동작</div>
8
+ <div class="${s}builtin-rows"></div>
9
+ <div class="${s}section-label" style="margin-top:10px">단축키 바인딩</div>
10
+ <div class="${s}binding-rows"></div>
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=`
13
+ <span class="${s}icon">${e}</span>
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=`
17
+ <span class="${s}icon">${t}</span>
18
+ <span class="${s}name">${i}</span>
19
+ <span class="${s}capture-hint">단축키 입력...</span>
20
+ <button class="${s}cancel-btn">취소</button>
21
+ `,r.querySelector(`.${s}cancel-btn`).addEventListener("click",()=>{this.stopCapture(),this.renderRows()})):(r.innerHTML=`
22
+ <span class="${s}icon">${t}</span>
23
+ <span class="${s}name">${i}</span>
24
+ ${p?`<span class="${s}key-tag">${p}</span>
25
+ <button class="${s}bind-btn ${s}clear-btn" data-gesture="${e}" title="제거">✕</button>`:`<span class="${s}no-bind">—</span>`}
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=`
28
+ .${s}fab {
29
+ position: fixed;
30
+ right: 20px;
31
+ bottom: 20px;
32
+ width: 46px;
33
+ height: 46px;
34
+ border-radius: 50%;
35
+ background: linear-gradient(135deg, #7c6af7, #5b4dd4);
36
+ color: #fff;
37
+ border: none;
38
+ cursor: pointer;
39
+ font-size: 1.25rem;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ box-shadow: 0 4px 20px rgba(124,106,247,0.45);
44
+ z-index: 999998;
45
+ transition: transform 0.18s, box-shadow 0.18s;
46
+ user-select: none;
47
+ }
48
+ .${s}fab:hover { transform: scale(1.1); box-shadow: 0 6px 28px rgba(124,106,247,0.65); }
49
+ .${s}fab-open { transform: rotate(20deg) scale(1.05); }
50
+
51
+ .${s}panel {
52
+ position: fixed;
53
+ right: 20px;
54
+ bottom: 76px;
55
+ width: 272px;
56
+ background: rgba(10, 10, 20, 0.97);
57
+ border: 1px solid rgba(124,106,247,0.28);
58
+ border-radius: 16px;
59
+ z-index: 999999;
60
+ -webkit-backdrop-filter: blur(24px);
61
+ backdrop-filter: blur(24px);
62
+ box-shadow: 0 12px 48px rgba(0,0,0,0.55);
63
+ color: #ddddf5;
64
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
65
+ font-size: 13px;
66
+ opacity: 0;
67
+ transform: translateY(10px) scale(0.97);
68
+ pointer-events: none;
69
+ transition: opacity 0.2s ease, transform 0.2s ease;
70
+ }
71
+ .${s}open {
72
+ opacity: 1;
73
+ transform: translateY(0) scale(1);
74
+ pointer-events: all;
75
+ }
76
+
77
+ .${s}header {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ padding: 13px 16px 11px;
82
+ border-bottom: 1px solid rgba(255,255,255,0.07);
83
+ }
84
+ .${s}title {
85
+ font-weight: 700;
86
+ font-size: 0.85rem;
87
+ letter-spacing: -0.01em;
88
+ }
89
+ .${s}close-btn {
90
+ background: none;
91
+ border: none;
92
+ color: rgba(221,221,245,0.45);
93
+ cursor: pointer;
94
+ font-size: 0.8rem;
95
+ padding: 2px 4px;
96
+ border-radius: 4px;
97
+ transition: color 0.12s;
98
+ }
99
+ .${s}close-btn:hover { color: #ddddf5; }
100
+
101
+ .${s}body {
102
+ padding: 12px 14px 14px;
103
+ }
104
+ .${s}section-label {
105
+ font-size: 0.6rem;
106
+ text-transform: uppercase;
107
+ letter-spacing: 0.11em;
108
+ opacity: 0.35;
109
+ margin-bottom: 7px;
110
+ }
111
+
112
+ .${s}row {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 8px;
116
+ padding: 5px 6px;
117
+ border-radius: 8px;
118
+ margin-bottom: 3px;
119
+ transition: background 0.15s;
120
+ }
121
+ .${s}row.${s}active {
122
+ background: rgba(124,106,247,0.18);
123
+ }
124
+
125
+ .${s}icon { font-size: 1rem; width: 22px; text-align: center; flex-shrink: 0; }
126
+ .${s}name { flex: 1; opacity: 0.8; font-size: 0.76rem; }
127
+
128
+ .${s}badge {
129
+ font-size: 0.65rem;
130
+ background: rgba(124,106,247,0.25);
131
+ color: #9d8dff;
132
+ padding: 2px 7px;
133
+ border-radius: 99px;
134
+ white-space: nowrap;
135
+ }
136
+
137
+ .${s}key-tag {
138
+ font-size: 0.65rem;
139
+ font-family: 'SF Mono', 'Fira Code', monospace;
140
+ background: rgba(78,205,196,0.15);
141
+ color: #4ecdc4;
142
+ padding: 2px 7px;
143
+ border-radius: 6px;
144
+ white-space: nowrap;
145
+ max-width: 80px;
146
+ overflow: hidden;
147
+ text-overflow: ellipsis;
148
+ }
149
+
150
+ .${s}no-bind {
151
+ font-size: 0.72rem;
152
+ opacity: 0.3;
153
+ }
154
+
155
+ .${s}bind-btn {
156
+ background: none;
157
+ border: none;
158
+ cursor: pointer;
159
+ border-radius: 5px;
160
+ padding: 2px 5px;
161
+ font-size: 0.75rem;
162
+ transition: background 0.12s, color 0.12s;
163
+ flex-shrink: 0;
164
+ }
165
+ .${s}edit-btn { color: rgba(221,221,245,0.45); }
166
+ .${s}edit-btn:hover { background: rgba(124,106,247,0.2); color: #9d8dff; }
167
+ .${s}clear-btn { color: rgba(221,221,245,0.3); }
168
+ .${s}clear-btn:hover { background: rgba(255,80,80,0.15); color: #ff6b6b; }
169
+
170
+ .${s}capture-hint {
171
+ flex: 1;
172
+ font-size: 0.7rem;
173
+ color: #ffd166;
174
+ animation: ${s}blink 1s step-end infinite;
175
+ }
176
+ .${s}cancel-btn {
177
+ background: none;
178
+ border: 1px solid rgba(255,80,80,0.3);
179
+ color: rgba(255,100,100,0.7);
180
+ border-radius: 5px;
181
+ padding: 2px 7px;
182
+ font-size: 0.65rem;
183
+ cursor: pointer;
184
+ flex-shrink: 0;
185
+ }
186
+ .${s}cancel-btn:hover { background: rgba(255,80,80,0.1); }
187
+
188
+ @keyframes ${s}blink {
189
+ 0%, 100% { opacity: 1; }
190
+ 50% { opacity: 0.3; }
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"})});
package/dist/index.d.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export { HandControl } from './HandControl';
2
- export type { HandControlOptions, HandControlEventMap, MoveEvent, ClickEvent, ScrollEvent, DragEvent, } from './types';
2
+ export { GestureMapper, formatKeyLabel } from './GestureMapper';
3
+ export { ControlPanel } from './ControlPanel';
4
+ export type { HandControlOptions, HandControlEventMap, MoveEvent, ClickEvent, ScrollEvent, DragEvent, GestureName, GestureEvent, GestureKeyBinding, } from './types';
5
+ export { GESTURE_META } from './types';
package/dist/types.d.ts CHANGED
@@ -24,6 +24,26 @@ export interface DragEvent {
24
24
  screenX: number;
25
25
  screenY: number;
26
26
  }
27
+ /** MediaPipe GestureRecognizer 제스처 이름 */
28
+ export type GestureName = 'pointing' | 'fist' | 'openpalm' | 'thumbsup' | 'thumbsdown' | 'victory' | 'iloveyou';
29
+ export interface GestureEvent {
30
+ gesture: GestureName;
31
+ x: number;
32
+ y: number;
33
+ screenX: number;
34
+ screenY: number;
35
+ confidence: number;
36
+ }
37
+ export interface GestureKeyBinding {
38
+ gesture: GestureName;
39
+ key: string;
40
+ modifiers?: {
41
+ ctrl?: boolean;
42
+ alt?: boolean;
43
+ shift?: boolean;
44
+ meta?: boolean;
45
+ };
46
+ }
27
47
  export type HandControlEventMap = {
28
48
  click: ClickEvent;
29
49
  rightclick: ClickEvent;
@@ -32,6 +52,13 @@ export type HandControlEventMap = {
32
52
  drag: DragEvent;
33
53
  dragstart: ClickEvent;
34
54
  dragend: ClickEvent;
55
+ pointing: GestureEvent;
56
+ fist: GestureEvent;
57
+ openpalm: GestureEvent;
58
+ thumbsup: GestureEvent;
59
+ thumbsdown: GestureEvent;
60
+ victory: GestureEvent;
61
+ iloveyou: GestureEvent;
35
62
  };
36
63
  export interface HandControlOptions {
37
64
  /** 기존 video 엘리먼트를 전달하면 그것을 사용, 없으면 자동 생성 */
@@ -45,3 +72,11 @@ export interface HandControlOptions {
45
72
  /** MediaPipe wasm 파일 경로 (CDN 기본값 제공) */
46
73
  wasmPath?: string;
47
74
  }
75
+ /** 제스처 표시 메타데이터 */
76
+ export declare const GESTURE_META: Record<GestureName, {
77
+ icon: string;
78
+ label: string;
79
+ labelKo: string;
80
+ builtin: boolean;
81
+ builtinAction?: string;
82
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whatpull/handface",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Hand gesture to mouse event control library using MediaPipe",
5
5
  "type": "module",
6
6
  "main": "./dist/handface.umd.cjs",
@@ -12,10 +12,14 @@
12
12
  "require": "./dist/handface.umd.cjs"
13
13
  }
14
14
  },
15
- "files": ["dist"],
15
+ "files": [
16
+ "dist"
17
+ ],
16
18
  "scripts": {
17
19
  "dev": "vite",
18
20
  "build": "vite build",
21
+ "build:demo": "vite build --config vite.config.demo.ts",
22
+ "deploy:demo": "npm run build:demo && gh-pages -d dist-demo",
19
23
  "typecheck": "tsc --noEmit",
20
24
  "preview": "vite preview"
21
25
  },
@@ -24,12 +28,20 @@
24
28
  },
25
29
  "devDependencies": {
26
30
  "@mediapipe/tasks-vision": "^0.10.14",
31
+ "@types/three": "^0.183.1",
32
+ "gh-pages": "^6.3.0",
27
33
  "typescript": "^5.4.0",
28
34
  "vite": "^5.2.0",
29
35
  "vite-plugin-dts": "^3.9.0"
30
36
  },
31
- "keywords": ["mediapipe", "hand-tracking", "gesture", "mouse", "typescript"],
32
- "license": "MIT",
37
+ "keywords": [
38
+ "mediapipe",
39
+ "hand-tracking",
40
+ "gesture",
41
+ "mouse",
42
+ "typescript"
43
+ ],
44
+ "license": "Apache-2.0",
33
45
  "repository": {
34
46
  "type": "git",
35
47
  "url": "https://github.com/whatpull/handface.git"
@@ -37,5 +49,8 @@
37
49
  "homepage": "https://github.com/whatpull/handface#readme",
38
50
  "publishConfig": {
39
51
  "access": "public"
52
+ },
53
+ "dependencies": {
54
+ "three": "^0.183.2"
40
55
  }
41
56
  }