@whatpull/handface 0.1.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.
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,43 @@
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;
12
+ /** 검지 끝 위치 (cursorAnchor: 'index' 용) */
5
13
  indexTip: {
6
14
  x: number;
7
15
  y: number;
8
16
  };
9
- middleTip: {
17
+ /** 손목 위치 (cursorAnchor: 'wrist' 용) */
18
+ wrist: {
19
+ x: number;
20
+ y: number;
21
+ };
22
+ /** 손바닥 중심 위치 (cursorAnchor: 'palm' 용) */
23
+ palmCenter: {
10
24
  x: number;
11
25
  y: number;
12
26
  };
13
27
  }
14
28
  export declare class GestureDetector {
15
29
  private readonly wasmPath;
16
- private readonly pinchThreshold;
17
- private landmarker;
18
- constructor(wasmPath?: string, pinchThreshold?: number);
30
+ private readonly clickThreshold;
31
+ private readonly handednessFilter;
32
+ private recognizer;
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);
19
41
  init(): Promise<void>;
20
42
  detect(video: HTMLVideoElement, timestampMs: number): DetectionResult | null;
21
43
  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
- private readonly threshold;
15
18
  private readonly smoothing;
16
19
  private readonly flipHorizontal;
20
+ private readonly cursorAnchor;
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,552 @@
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 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
- s(this, "_listeners", /* @__PURE__ */ new Map());
7
+ r(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) {
13
- var i;
14
- return (i = this._listeners.get(t)) == null || i.delete(e), this;
12
+ off(e, t) {
13
+ var n;
14
+ return (n = this._listeners.get(e)) == null || n.delete(t), this;
15
15
  }
16
- emit(t, e) {
17
- var i;
18
- (i = this._listeners.get(t)) == null || i.forEach((o) => o(e));
16
+ emit(e, t) {
17
+ var n;
18
+ (n = this._listeners.get(e)) == null || n.forEach((i) => i(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 P(o, e) {
25
+ return Math.sqrt((o.x - e.x) ** 2 + (o.y - e.y) ** 2);
26
26
  }
27
- function p(n, t, e) {
28
- return n + e * (t - n);
27
+ function x(o, e, t) {
28
+ return o + t * (e - o);
29
29
  }
30
- const d = {
30
+ const b = {
31
+ WRIST: 0,
31
32
  THUMB_TIP: 4,
32
- INDEX_MCP: 5,
33
33
  INDEX_TIP: 8,
34
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;
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 = {
41
+ Pointing_Up: "pointing",
42
+ Closed_Fist: "fist",
43
+ Open_Palm: "openpalm",
44
+ Thumb_Up: "thumbsup",
45
+ Thumb_Down: "thumbsdown",
46
+ Victory: "victory",
47
+ ILoveYou: "iloveyou"
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;
41
60
  }
42
61
  async init() {
43
- const t = await w.forVisionTasks(this.wasmPath);
44
- this.landmarker = await g.createFromOptions(t, {
62
+ const e = await C.forVisionTasks(this.wasmPath);
63
+ this.recognizer = await L.createFromOptions(e, {
45
64
  baseOptions: {
46
- modelAssetPath: M,
65
+ modelAssetPath: D,
47
66
  delegate: "GPU"
48
67
  },
49
- numHands: 1,
68
+ // 핸드니스 필터링을 위해 2개 감지 후 선택
69
+ numHands: this.handednessFilter ? 2 : 1,
50
70
  runningMode: "VIDEO"
51
71
  });
52
72
  }
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,
73
+ detect(e, t) {
74
+ if (!this.recognizer) return null;
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);
90
+ }
91
+ analyze(e, t) {
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;
96
+ return {
97
+ gesture: d < this.clickThreshold ? "click" : h ?? "none",
98
+ gestureName: h,
99
+ gestureConfidence: f,
100
+ clickPinchDistance: d,
64
101
  indexTip: { x: i.x, y: i.y },
65
- middleTip: { x: o.x, y: o.y }
102
+ wrist: { x: a.x, y: a.y },
103
+ palmCenter: c
66
104
  };
67
105
  }
68
106
  destroy() {
69
- var t;
70
- (t = this.landmarker) == null || t.close(), this.landmarker = null;
107
+ var e;
108
+ (e = this.recognizer) == null || e.close(), this.recognizer = null;
109
+ }
110
+ }
111
+ const w = "handface_key_bindings";
112
+ function A(o) {
113
+ return {
114
+ " ": "Space",
115
+ ArrowUp: "↑",
116
+ ArrowDown: "↓",
117
+ ArrowLeft: "←",
118
+ ArrowRight: "→",
119
+ Escape: "Esc",
120
+ Backspace: "⌫",
121
+ Delete: "Del",
122
+ Enter: "↵",
123
+ Tab: "Tab",
124
+ PageUp: "PgUp",
125
+ PageDown: "PgDn",
126
+ Home: "Home",
127
+ End: "End"
128
+ }[o] ?? o;
129
+ }
130
+ class z {
131
+ constructor() {
132
+ r(this, "bindings", /* @__PURE__ */ new Map());
133
+ this.load();
134
+ }
135
+ bind(e, t, n) {
136
+ this.bindings.set(e, { gesture: e, key: t, modifiers: n }), this.save();
137
+ }
138
+ unbind(e) {
139
+ this.bindings.delete(e), this.save();
140
+ }
141
+ getBinding(e) {
142
+ return this.bindings.get(e);
143
+ }
144
+ getAll() {
145
+ return [...this.bindings.values()];
146
+ }
147
+ /** 제스처에 바인딩된 키보드 이벤트 디스패치 */
148
+ trigger(e) {
149
+ var i, l, a, c;
150
+ const t = this.bindings.get(e);
151
+ if (!t) return;
152
+ const n = {
153
+ key: t.key,
154
+ bubbles: !0,
155
+ cancelable: !0,
156
+ ctrlKey: ((i = t.modifiers) == null ? void 0 : i.ctrl) ?? !1,
157
+ altKey: ((l = t.modifiers) == null ? void 0 : l.alt) ?? !1,
158
+ shiftKey: ((a = t.modifiers) == null ? void 0 : a.shift) ?? !1,
159
+ metaKey: ((c = t.modifiers) == null ? void 0 : c.meta) ?? !1
160
+ };
161
+ document.dispatchEvent(new KeyboardEvent("keydown", n)), document.dispatchEvent(new KeyboardEvent("keyup", n));
162
+ }
163
+ save() {
164
+ try {
165
+ localStorage.setItem(w, JSON.stringify([...this.bindings.entries()]));
166
+ } catch {
167
+ }
168
+ }
169
+ load() {
170
+ try {
171
+ const e = localStorage.getItem(w);
172
+ e && (this.bindings = new Map(JSON.parse(e)));
173
+ } catch {
174
+ }
175
+ }
176
+ }
177
+ const v = {
178
+ pointing: { icon: "☝️", label: "Pointing Up", labelKo: "검지 가리키기", builtin: !0, builtinAction: "커서 이동" },
179
+ fist: { icon: "✊", label: "Closed Fist", labelKo: "주먹", builtin: !0, builtinAction: "스크롤 다운" },
180
+ openpalm: { icon: "🖐️", label: "Open Palm", labelKo: "펼친 손", builtin: !0, builtinAction: "스크롤 업" },
181
+ thumbsup: { icon: "👍", label: "Thumbs Up", labelKo: "엄지 위", builtin: !1 },
182
+ thumbsdown: { icon: "👎", label: "Thumbs Down", labelKo: "엄지 아래", builtin: !1 },
183
+ victory: { icon: "✌️", label: "Victory", labelKo: "브이", builtin: !1 },
184
+ iloveyou: { icon: "🤟", label: "I Love You", labelKo: "아이 러브 유", builtin: !1 }
185
+ }, s = "hf-", R = ["thumbsup", "thumbsdown", "victory", "iloveyou"], H = ["pointing", "fist", "openpalm"];
186
+ class K {
187
+ constructor(e) {
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);
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());
197
+ }
198
+ /** HandControl 에서 매 프레임 호출 — 현재 감지 제스처 표시 */
199
+ setDetected(e, t) {
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);
203
+ }));
204
+ }
205
+ destroy() {
206
+ this.stopCapture(), this.styleEl.remove(), this.root.remove();
207
+ }
208
+ // ─────────────────────────────────────────
209
+ // Panel open / close
210
+ // ─────────────────────────────────────────
211
+ toggle() {
212
+ this.isOpen ? this.close() : this.open();
213
+ }
214
+ open() {
215
+ this.isOpen = !0, this.renderRows(), this.panel.classList.add(`${s}open`), this.fab.classList.add(`${s}fab-open`);
216
+ }
217
+ close() {
218
+ this.isOpen = !1, this.stopCapture(), this.panel.classList.remove(`${s}open`), this.fab.classList.remove(`${s}fab-open`);
219
+ }
220
+ // ─────────────────────────────────────────
221
+ // Key capture
222
+ // ─────────────────────────────────────────
223
+ startCapture(e) {
224
+ this.stopCapture(), this.capturingGesture = e, this.captureHandler = (t) => {
225
+ if (t.preventDefault(), t.stopImmediatePropagation(), ["Shift", "Control", "Alt", "Meta"].includes(t.key)) {
226
+ document.addEventListener("keydown", this.captureHandler, { once: !0, capture: !0 });
227
+ return;
228
+ }
229
+ this.mapper.bind(e, t.key, {
230
+ ctrl: t.ctrlKey || void 0,
231
+ alt: t.altKey || void 0,
232
+ shift: t.shiftKey || void 0,
233
+ meta: t.metaKey || void 0
234
+ }), this.capturingGesture = null, this.captureHandler = null, this.renderRows();
235
+ }, document.addEventListener("keydown", this.captureHandler, { once: !0, capture: !0 }), this.renderRows();
236
+ }
237
+ stopCapture() {
238
+ this.captureHandler && (document.removeEventListener("keydown", this.captureHandler, { capture: !0 }), this.captureHandler = null, this.capturingGesture = null);
239
+ }
240
+ // ─────────────────────────────────────────
241
+ // DOM construction
242
+ // ─────────────────────────────────────────
243
+ createFab() {
244
+ const e = document.createElement("button");
245
+ return e.className = `${s}fab`, e.title = "handface 제스처 설정", e.innerHTML = "✋", e;
246
+ }
247
+ createPanel() {
248
+ const e = document.createElement("div");
249
+ return e.className = `${s}panel`, e.innerHTML = `
250
+ <div class="${s}header">
251
+ <span class="${s}title">✋ handface</span>
252
+ <button class="${s}close-btn" title="닫기">✕</button>
253
+ </div>
254
+ <div class="${s}body">
255
+ <div class="${s}section-label">기본 동작</div>
256
+ <div class="${s}builtin-rows"></div>
257
+ <div class="${s}section-label" style="margin-top:10px">단축키 바인딩</div>
258
+ <div class="${s}binding-rows"></div>
259
+ </div>
260
+ `, e.querySelector(`.${s}close-btn`).addEventListener("click", () => this.close()), e;
261
+ }
262
+ renderRows() {
263
+ this.renderBuiltin(), this.renderBindings();
264
+ }
265
+ renderBuiltin() {
266
+ const e = this.panel.querySelector(`.${s}builtin-rows`);
267
+ e.innerHTML = "", e.appendChild(this.makeReadonlyRow("🤏", "엄지+중지 핀치", "클릭", null));
268
+ for (const t of H) {
269
+ const n = v[t];
270
+ e.appendChild(
271
+ this.makeReadonlyRow(n.icon, n.labelKo, n.builtinAction, t)
272
+ );
273
+ }
274
+ }
275
+ renderBindings() {
276
+ const e = this.panel.querySelector(`.${s}binding-rows`);
277
+ e.innerHTML = "";
278
+ for (const t of R) {
279
+ const n = v[t], i = this.mapper.getBinding(t), l = this.capturingGesture === t;
280
+ e.appendChild(
281
+ this.makeBindingRow(t, n.icon, n.labelKo, (i == null ? void 0 : i.key) ?? null, l)
282
+ );
283
+ }
284
+ }
285
+ makeReadonlyRow(e, t, n, i) {
286
+ const l = document.createElement("div");
287
+ return l.className = `${s}row`, i && (l.dataset.gesture = i), l.innerHTML = `
288
+ <span class="${s}icon">${e}</span>
289
+ <span class="${s}name">${t}</span>
290
+ <span class="${s}badge">${n}</span>
291
+ `, l;
292
+ }
293
+ makeBindingRow(e, t, n, i, l) {
294
+ var d;
295
+ const a = document.createElement("div");
296
+ a.className = `${s}row`, a.dataset.gesture = e;
297
+ const c = i ? this.buildKeyLabel(this.mapper.getBinding(e)) : null;
298
+ return l ? (a.innerHTML = `
299
+ <span class="${s}icon">${t}</span>
300
+ <span class="${s}name">${n}</span>
301
+ <span class="${s}capture-hint">단축키 입력...</span>
302
+ <button class="${s}cancel-btn">취소</button>
303
+ `, a.querySelector(`.${s}cancel-btn`).addEventListener("click", () => {
304
+ this.stopCapture(), this.renderRows();
305
+ })) : (a.innerHTML = `
306
+ <span class="${s}icon">${t}</span>
307
+ <span class="${s}name">${n}</span>
308
+ ${c ? `<span class="${s}key-tag">${c}</span>
309
+ <button class="${s}bind-btn ${s}clear-btn" data-gesture="${e}" title="제거">✕</button>` : `<span class="${s}no-bind">—</span>`}
310
+ <button class="${s}bind-btn ${s}edit-btn" data-gesture="${e}" title="단축키 설정">✎</button>
311
+ `, a.querySelector(`.${s}edit-btn`).addEventListener("click", () => this.startCapture(e)), (d = a.querySelector(`.${s}clear-btn`)) == null || d.addEventListener("click", () => {
312
+ this.mapper.unbind(e), this.renderRows();
313
+ })), a;
314
+ }
315
+ buildKeyLabel(e) {
316
+ var n, i, l, a;
317
+ const t = [];
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("+");
319
+ }
320
+ // ─────────────────────────────────────────
321
+ // Styles
322
+ // ─────────────────────────────────────────
323
+ injectStyles() {
324
+ const e = document.createElement("style");
325
+ return e.dataset.handface = "styles", e.textContent = `
326
+ .${s}fab {
327
+ position: fixed;
328
+ right: 20px;
329
+ bottom: 20px;
330
+ width: 46px;
331
+ height: 46px;
332
+ border-radius: 50%;
333
+ background: linear-gradient(135deg, #7c6af7, #5b4dd4);
334
+ color: #fff;
335
+ border: none;
336
+ cursor: pointer;
337
+ font-size: 1.25rem;
338
+ display: flex;
339
+ align-items: center;
340
+ justify-content: center;
341
+ box-shadow: 0 4px 20px rgba(124,106,247,0.45);
342
+ z-index: 999998;
343
+ transition: transform 0.18s, box-shadow 0.18s;
344
+ user-select: none;
345
+ }
346
+ .${s}fab:hover { transform: scale(1.1); box-shadow: 0 6px 28px rgba(124,106,247,0.65); }
347
+ .${s}fab-open { transform: rotate(20deg) scale(1.05); }
348
+
349
+ .${s}panel {
350
+ position: fixed;
351
+ right: 20px;
352
+ bottom: 76px;
353
+ width: 272px;
354
+ background: rgba(10, 10, 20, 0.97);
355
+ border: 1px solid rgba(124,106,247,0.28);
356
+ border-radius: 16px;
357
+ z-index: 999999;
358
+ -webkit-backdrop-filter: blur(24px);
359
+ backdrop-filter: blur(24px);
360
+ box-shadow: 0 12px 48px rgba(0,0,0,0.55);
361
+ color: #ddddf5;
362
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
363
+ font-size: 13px;
364
+ opacity: 0;
365
+ transform: translateY(10px) scale(0.97);
366
+ pointer-events: none;
367
+ transition: opacity 0.2s ease, transform 0.2s ease;
368
+ }
369
+ .${s}open {
370
+ opacity: 1;
371
+ transform: translateY(0) scale(1);
372
+ pointer-events: all;
373
+ }
374
+
375
+ .${s}header {
376
+ display: flex;
377
+ align-items: center;
378
+ justify-content: space-between;
379
+ padding: 13px 16px 11px;
380
+ border-bottom: 1px solid rgba(255,255,255,0.07);
381
+ }
382
+ .${s}title {
383
+ font-weight: 700;
384
+ font-size: 0.85rem;
385
+ letter-spacing: -0.01em;
386
+ }
387
+ .${s}close-btn {
388
+ background: none;
389
+ border: none;
390
+ color: rgba(221,221,245,0.45);
391
+ cursor: pointer;
392
+ font-size: 0.8rem;
393
+ padding: 2px 4px;
394
+ border-radius: 4px;
395
+ transition: color 0.12s;
396
+ }
397
+ .${s}close-btn:hover { color: #ddddf5; }
398
+
399
+ .${s}body {
400
+ padding: 12px 14px 14px;
401
+ }
402
+ .${s}section-label {
403
+ font-size: 0.6rem;
404
+ text-transform: uppercase;
405
+ letter-spacing: 0.11em;
406
+ opacity: 0.35;
407
+ margin-bottom: 7px;
408
+ }
409
+
410
+ .${s}row {
411
+ display: flex;
412
+ align-items: center;
413
+ gap: 8px;
414
+ padding: 5px 6px;
415
+ border-radius: 8px;
416
+ margin-bottom: 3px;
417
+ transition: background 0.15s;
418
+ }
419
+ .${s}row.${s}active {
420
+ background: rgba(124,106,247,0.18);
421
+ }
422
+
423
+ .${s}icon { font-size: 1rem; width: 22px; text-align: center; flex-shrink: 0; }
424
+ .${s}name { flex: 1; opacity: 0.8; font-size: 0.76rem; }
425
+
426
+ .${s}badge {
427
+ font-size: 0.65rem;
428
+ background: rgba(124,106,247,0.25);
429
+ color: #9d8dff;
430
+ padding: 2px 7px;
431
+ border-radius: 99px;
432
+ white-space: nowrap;
433
+ }
434
+
435
+ .${s}key-tag {
436
+ font-size: 0.65rem;
437
+ font-family: 'SF Mono', 'Fira Code', monospace;
438
+ background: rgba(78,205,196,0.15);
439
+ color: #4ecdc4;
440
+ padding: 2px 7px;
441
+ border-radius: 6px;
442
+ white-space: nowrap;
443
+ max-width: 80px;
444
+ overflow: hidden;
445
+ text-overflow: ellipsis;
446
+ }
447
+
448
+ .${s}no-bind {
449
+ font-size: 0.72rem;
450
+ opacity: 0.3;
451
+ }
452
+
453
+ .${s}bind-btn {
454
+ background: none;
455
+ border: none;
456
+ cursor: pointer;
457
+ border-radius: 5px;
458
+ padding: 2px 5px;
459
+ font-size: 0.75rem;
460
+ transition: background 0.12s, color 0.12s;
461
+ flex-shrink: 0;
462
+ }
463
+ .${s}edit-btn { color: rgba(221,221,245,0.45); }
464
+ .${s}edit-btn:hover { background: rgba(124,106,247,0.2); color: #9d8dff; }
465
+ .${s}clear-btn { color: rgba(221,221,245,0.3); }
466
+ .${s}clear-btn:hover { background: rgba(255,80,80,0.15); color: #ff6b6b; }
467
+
468
+ .${s}capture-hint {
469
+ flex: 1;
470
+ font-size: 0.7rem;
471
+ color: #ffd166;
472
+ animation: ${s}blink 1s step-end infinite;
473
+ }
474
+ .${s}cancel-btn {
475
+ background: none;
476
+ border: 1px solid rgba(255,80,80,0.3);
477
+ color: rgba(255,100,100,0.7);
478
+ border-radius: 5px;
479
+ padding: 2px 7px;
480
+ font-size: 0.65rem;
481
+ cursor: pointer;
482
+ flex-shrink: 0;
483
+ }
484
+ .${s}cancel-btn:hover { background: rgba(255,80,80,0.1); }
485
+
486
+ @keyframes ${s}blink {
487
+ 0%, 100% { opacity: 1; }
488
+ 50% { opacity: 0.3; }
489
+ }
490
+ `, document.head.appendChild(e), e;
71
491
  }
72
492
  }
73
- const I = 0.09, y = 600;
74
- class k extends T {
75
- constructor(e = {}) {
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 _ {
498
+ constructor(t = {}) {
76
499
  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);
500
+ r(this, "video");
501
+ r(this, "detector");
502
+ r(this, "rafId", null);
503
+ r(this, "stream", null);
504
+ r(this, "panel", null);
505
+ // 제스처 상태
506
+ r(this, "isClicking", !1);
507
+ r(this, "lastClickMs", 0);
508
+ /** GestureName 마지막 발화 타임스탬프 */
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");
517
+ /** 단축키 바인딩 엔진 — 직접 접근 가능 */
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
+ );
93
526
  }
94
527
  /** 카메라 열고 감지 시작 */
95
528
  async start() {
96
- await this.detector.init(), this.stream = await navigator.mediaDevices.getUserMedia({ video: !0 }), this.video.srcObject = this.stream, await new Promise((e) => {
529
+ await this.detector.init(), this.stream = await navigator.mediaDevices.getUserMedia({ video: !0 }), this.video.srcObject = this.stream, await new Promise((t) => {
97
530
  this.video.onloadedmetadata = () => {
98
- this.video.play(), e();
531
+ this.video.play(), t();
99
532
  };
100
533
  }), this.loop();
101
534
  }
102
535
  /** 감지 중지 및 리소스 해제 */
103
536
  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();
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();
539
+ }
540
+ /**
541
+ * 플로팅 설정 패널을 생성하고 document.body 에 주입합니다.
542
+ * 이미 생성된 경우 기존 인스턴스를 반환합니다.
543
+ */
544
+ createPanel() {
545
+ return this.panel || (this.panel = new K(this.mapper)), this.panel;
106
546
  }
107
547
  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;
548
+ const t = document.createElement("video");
549
+ 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
550
  }
111
551
  loop() {
112
552
  this.rafId = requestAnimationFrame(() => {
@@ -114,34 +554,46 @@ class k extends T {
114
554
  });
115
555
  }
116
556
  tick() {
117
- const e = performance.now(), i = this.detector.detect(this.video, e);
118
- 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 = {
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 = {
122
563
  x: this.smoothX,
123
564
  y: this.smoothY,
124
- screenX: m,
125
- screenY: l
565
+ screenX: c,
566
+ screenY: d
126
567
  };
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;
568
+ if (this.emit("move", p), n.gesture === "click") {
569
+ if (!this.isClicking) {
570
+ this.isClicking = !0;
571
+ const u = Date.now();
572
+ u - this.lastClickMs > O && (this.lastClickMs = u, this.emit("click", p));
573
+ }
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
586
+ };
587
+ this.emit(h, k), this.mapper.trigger(h);
588
+ }
141
589
  } else
142
- this.prevTwoFingerY = 0;
590
+ (y = this.panel) == null || y.setDetected(null, 0);
143
591
  }
144
592
  }
145
593
  export {
146
- k as HandControl
594
+ K as ControlPanel,
595
+ v as GESTURE_META,
596
+ z as GestureMapper,
597
+ B as HandControl,
598
+ A as formatKeyLabel
147
599
  };
@@ -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,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
+ <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 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
+ <span class="${s}icon">${e}</span>
14
+ <span class="${s}name">${t}</span>
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
+ <span class="${s}icon">${t}</span>
18
+ <span class="${s}name">${n}</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">${n}</span>
24
+ ${h?`<span class="${s}key-tag">${h}</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)),(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
+ .${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 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/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 엘리먼트를 전달하면 그것을 사용, 없으면 자동 생성 */
@@ -44,4 +71,24 @@ export interface HandControlOptions {
44
71
  smoothing?: number;
45
72
  /** MediaPipe wasm 파일 경로 (CDN 기본값 제공) */
46
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';
47
86
  }
87
+ /** 제스처 표시 메타데이터 */
88
+ export declare const GESTURE_META: Record<GestureName, {
89
+ icon: string;
90
+ label: string;
91
+ labelKo: string;
92
+ builtin: boolean;
93
+ builtinAction?: string;
94
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whatpull/handface",
3
- "version": "0.1.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",
@@ -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
  }