@voqalize/avatar 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +692 -0
- package/client/dist/Avatar.d.ts +24 -0
- package/client/dist/Avatar.d.ts.map +1 -0
- package/client/dist/Avatar.js +7 -0
- package/client/dist/Avatar.js.map +1 -0
- package/client/dist/AvatarClient.d.ts +173 -0
- package/client/dist/AvatarClient.d.ts.map +1 -0
- package/client/dist/AvatarClient.js +274 -0
- package/client/dist/AvatarClient.js.map +1 -0
- package/client/dist/pipecat.d.ts +21 -0
- package/client/dist/pipecat.d.ts.map +1 -0
- package/client/dist/pipecat.js +21 -0
- package/client/dist/pipecat.js.map +1 -0
- package/client/dist/react.d.ts +16 -0
- package/client/dist/react.d.ts.map +1 -0
- package/client/dist/react.js +17 -0
- package/client/dist/react.js.map +1 -0
- package/client/dist/types.d.ts +101 -0
- package/client/dist/types.d.ts.map +1 -0
- package/client/dist/types.js +31 -0
- package/client/dist/types.js.map +1 -0
- package/client/dist/useAvatar.d.ts +53 -0
- package/client/dist/useAvatar.d.ts.map +1 -0
- package/client/dist/useAvatar.js +68 -0
- package/client/dist/useAvatar.js.map +1 -0
- package/client/src/Avatar.tsx +38 -0
- package/client/src/AvatarClient.ts +343 -0
- package/client/src/pipecat.ts +38 -0
- package/client/src/react.ts +34 -0
- package/client/src/types.ts +127 -0
- package/client/src/useAvatar.ts +113 -0
- package/docs/contract-avatar.md +337 -0
- package/docs/contract-protocol.md +401 -0
- package/package.json +89 -0
- package/src/audio-fallback.js +100 -0
- package/src/avatar.d.ts +241 -0
- package/src/avatar.js +722 -0
- package/src/clips.js +144 -0
- package/src/emotions.js +55 -0
- package/src/face-core.js +154 -0
- package/src/face-myna.js +725 -0
- package/src/face-peep.js +767 -0
- package/src/face-wren.js +470 -0
- package/src/gaze.js +155 -0
- package/src/idle.js +535 -0
- package/src/interjections.js +578 -0
- package/src/line-art.js +111 -0
- package/src/params.js +176 -0
- package/src/perform.js +105 -0
- package/src/visemes.js +230 -0
package/src/face-wren.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar: "wren".
|
|
3
|
+
*
|
|
4
|
+
* The second line-art character, and the proof run for the recipe in
|
|
5
|
+
* docs/contract-avatar.md: static art + a POSE spec + feature blocks + META,
|
|
6
|
+
* with the stroke engine imported from line-art.js rather than carried along.
|
|
7
|
+
* Character: female, thirties, a big natural-curl mass, round glasses, a
|
|
8
|
+
* crew-neck tee. Same idiom as peep — no strokes anywhere, ink and paper and
|
|
9
|
+
* one accent — different person.
|
|
10
|
+
*
|
|
11
|
+
* What is deliberately different from peep, and why it is cheap:
|
|
12
|
+
* - THE HAIR IS THE SILHOUETTE. A scalloped cloud four times peep's hair mass,
|
|
13
|
+
* drawn as one closed loop (outer lobes out, hairline lobes back), covering
|
|
14
|
+
* the ears entirely — which is why this file has no ear geometry at all.
|
|
15
|
+
* - THE GLASSES ARE THE ACCENT. peep spends its one colour on the collar; wren
|
|
16
|
+
* wears it on the frames, which makes the two instantly tellable apart at
|
|
17
|
+
* any size where a face reads at all.
|
|
18
|
+
* - CREW NECK, NOT POLO. One band arc instead of blades + placket.
|
|
19
|
+
*
|
|
20
|
+
* What the glasses cost the rig: the eye beans live inside the lens rings, so
|
|
21
|
+
* pupil travel is capped by the frame (9/7 against peep's 11/8) — past that
|
|
22
|
+
* the bean collides with the ring and reads as the eye hitting glass. In
|
|
23
|
+
* exchange the ring is a fixed reference the bean moves against, which makes
|
|
24
|
+
* small gaze shifts MORE legible than on peep, whose beans move against
|
|
25
|
+
* nothing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { clamp, lerp } from './params.js';
|
|
29
|
+
import {
|
|
30
|
+
f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
|
|
31
|
+
} from './face-core.js';
|
|
32
|
+
import { taper, taperRing, region } from './line-art.js';
|
|
33
|
+
|
|
34
|
+
export const THEME = {
|
|
35
|
+
bg0: '#f6f3ee',
|
|
36
|
+
bg1: '#e8e2d8',
|
|
37
|
+
ink: '#1b1b1b',
|
|
38
|
+
paper: '#ffffff',
|
|
39
|
+
accent: '#f97415',
|
|
40
|
+
mouthIn: '#1b1b1b',
|
|
41
|
+
teeth: '#ffffff',
|
|
42
|
+
tongue: '#8d7f79',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Frame: same native 760x950 art space as peep — not a requirement, just no
|
|
46
|
+
// reason to differ — with the window shifted up because the hair cloud crowns
|
|
47
|
+
// ~40 units higher than peep's fade.
|
|
48
|
+
const VB = { x: 92, y: 50, w: 576, h: 800 };
|
|
49
|
+
|
|
50
|
+
export const META = {
|
|
51
|
+
viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
|
|
52
|
+
mouthCrop: { x: 298, y: 432, w: 164, h: 96 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// --- landmarks --------------------------------------------------------------
|
|
56
|
+
const CX = 380;
|
|
57
|
+
const HEAD_TOP = 148;
|
|
58
|
+
const CHIN_Y = 572;
|
|
59
|
+
|
|
60
|
+
const EYE = { y: 386, dx: 55, rx: 15, ry: 17.5 };
|
|
61
|
+
const NOSE_TOP = 408;
|
|
62
|
+
const MOUTH = { cx: CX, cy: 486 };
|
|
63
|
+
const MOUTH_APERTURE = 36;
|
|
64
|
+
|
|
65
|
+
// Brows: shorter and rounder than peep's, sitting clear above the lens rings
|
|
66
|
+
// (lens top is y=355; a full browRaise lifts these 15 units and they still
|
|
67
|
+
// never touch the frames). Drawn control points, per the peep correction —
|
|
68
|
+
// the arch is not recoverable from endpoints.
|
|
69
|
+
const BROW_L = [[CX - 22, 342], [CX - 40, 334], [CX - 58, 330], [CX - 72, 331],
|
|
70
|
+
[CX - 82, 334], [CX - 89, 338], [CX - 93, 343]];
|
|
71
|
+
const BROW_R = [[CX + 24, 340], [CX + 42, 332], [CX + 60, 329], [CX + 73, 330],
|
|
72
|
+
[CX + 82, 333], [CX + 88, 337], [CX + 92, 342]];
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Static art: head.
|
|
76
|
+
//
|
|
77
|
+
// Same three-segment jaw rule as every head in this project (an unbroken
|
|
78
|
+
// ear-to-ear curve is an egg), but the runs are softer: the gonion corner is
|
|
79
|
+
// rounded off and the chin pad is narrower — that softness, not any single
|
|
80
|
+
// feature, is most of what makes this face read as a different person before
|
|
81
|
+
// the hair even loads. Chin a touch left of midline; symmetric heads read
|
|
82
|
+
// as machine output.
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
const HEAD = [
|
|
85
|
+
[CX, HEAD_TOP],
|
|
86
|
+
[452, 147], [520, 204], [524, 288],
|
|
87
|
+
[527, 340], [523, 390], [520, 428],
|
|
88
|
+
[518, 462], [513, 486], [502, 510],
|
|
89
|
+
[488, 532], [456, 558], [426, 568],
|
|
90
|
+
[408, 575], [352, 574], [334, 566],
|
|
91
|
+
[308, 554], [282, 534], [264, 508],
|
|
92
|
+
[250, 482], [244, 458], [248, 426],
|
|
93
|
+
[242, 390], [237, 340], [240, 288],
|
|
94
|
+
[244, 202], [308, 145], [CX, HEAD_TOP],
|
|
95
|
+
];
|
|
96
|
+
const HEAD_W = [3.5, 7, 11, 14, 15, 13.5, 10, 7, 4.5, 3.5];
|
|
97
|
+
|
|
98
|
+
// Neck: peep's truncated-cone construction, narrower. Fused into the head
|
|
99
|
+
// layer and run long past the neckline for the same layer-parallax reasons.
|
|
100
|
+
const NECK_FILL =
|
|
101
|
+
'M336 514C326 575 318 630 316 700L316 790L444 790L444 700C442 630 434 575 424 514Z';
|
|
102
|
+
const NECK_L = [[336, 532], [329, 588], [323, 656], [320, 750]];
|
|
103
|
+
const NECK_R = [[424, 528], [431, 584], [437, 654], [440, 750]];
|
|
104
|
+
const JAW_UNDER = [[336, 560], [354, 586], [408, 588], [428, 558]];
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Static art: hair — the curl cloud.
|
|
108
|
+
//
|
|
109
|
+
// One closed loop: outer silhouette left-to-right over the crown as a chain of
|
|
110
|
+
// LOBES (each segment's controls pushed outward, so consecutive lobes meet at
|
|
111
|
+
// soft cusps — that cusp rhythm is what says "curls" with zero interior
|
|
112
|
+
// marks), then the hairline right-to-left back across the forehead as smaller
|
|
113
|
+
// lobes pointing down. What the loop encloses is the hair band itself: the
|
|
114
|
+
// sides run 40 units wide down to y≈470, which is why wren needs no ears.
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
const HAIR = [
|
|
117
|
+
[252, 468],
|
|
118
|
+
// up the left side, three outer lobes
|
|
119
|
+
[214, 442], [206, 408], [218, 382],
|
|
120
|
+
[188, 352], [190, 306], [218, 284],
|
|
121
|
+
[196, 240], [222, 196], [262, 186],
|
|
122
|
+
// over the crown
|
|
123
|
+
[286, 138], [330, 116], [366, 124],
|
|
124
|
+
[398, 102], [446, 106], [462, 130],
|
|
125
|
+
[508, 138], [534, 170], [526, 196],
|
|
126
|
+
// down the right side
|
|
127
|
+
[560, 226], [562, 268], [546, 290],
|
|
128
|
+
[568, 322], [566, 362], [538, 388],
|
|
129
|
+
[552, 424], [540, 456], [508, 466],
|
|
130
|
+
// transition onto the hairline
|
|
131
|
+
[500, 462], [498, 458], [496, 452],
|
|
132
|
+
// back across the forehead, right to left: smaller lobes, pointing down
|
|
133
|
+
[500, 396], [498, 352], [486, 330],
|
|
134
|
+
[482, 296], [468, 278], [452, 272],
|
|
135
|
+
[430, 252], [396, 246], [380, 256],
|
|
136
|
+
[362, 242], [326, 252], [306, 270],
|
|
137
|
+
[288, 282], [278, 306], [272, 330],
|
|
138
|
+
[262, 352], [258, 400], [258, 452],
|
|
139
|
+
// close the loop at the bottom-left
|
|
140
|
+
[256, 460], [254, 464], [252, 468],
|
|
141
|
+
];
|
|
142
|
+
const HAIR_D = region(HAIR);
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Static art: glasses.
|
|
146
|
+
//
|
|
147
|
+
// Round frames in the ACCENT colour — wren's version of peep's collar trim.
|
|
148
|
+
// Rings are taperRings with a gently uneven profile (first and last width
|
|
149
|
+
// equal, or the ring seams at the 3 o'clock point). The temples run outward
|
|
150
|
+
// and vanish under the hair band, which is what lets them be four points long.
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
const LENS_R = 29;
|
|
153
|
+
const K = 0.5523; // circle-as-four-cubics constant
|
|
154
|
+
|
|
155
|
+
function circlePts(cx, cy, r) {
|
|
156
|
+
const k = r * K;
|
|
157
|
+
return [
|
|
158
|
+
[cx + r, cy], [cx + r, cy + k], [cx + k, cy + r], [cx, cy + r],
|
|
159
|
+
[cx - k, cy + r], [cx - r, cy + k], [cx - r, cy], [cx - r, cy - k],
|
|
160
|
+
[cx - k, cy - r], [cx, cy - r], [cx + k, cy - r], [cx + r, cy - k],
|
|
161
|
+
[cx + r, cy],
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
const LENS_L = circlePts(CX - EYE.dx, EYE.y, LENS_R);
|
|
165
|
+
const LENS_R_ = circlePts(CX + EYE.dx, EYE.y, LENS_R);
|
|
166
|
+
const RING_W = [5, 6, 5, 6.5, 5];
|
|
167
|
+
const BRIDGE = [[CX - 26, 378], [CX - 10, 366], [CX + 10, 366], [CX + 26, 378]];
|
|
168
|
+
const TEMPLE_L = [[CX - 86, 384], [CX - 100, 380], [CX - 112, 378], [CX - 121, 378]];
|
|
169
|
+
const TEMPLE_R = [[CX + 86, 384], [CX + 100, 380], [CX + 112, 378], [CX + 121, 378]];
|
|
170
|
+
|
|
171
|
+
/** The nose: one mark, a shorter hook than peep's — the bridge work is done
|
|
172
|
+
* by the glasses, so the nose only has to land the tip. */
|
|
173
|
+
const NOSE_D = taper(
|
|
174
|
+
[[CX - 3, NOSE_TOP], [CX - 10, 424], [CX - 15, 438], [CX - 9, 448],
|
|
175
|
+
[CX - 3, 455], [CX + 9, 453], [CX + 15, 444]],
|
|
176
|
+
[2, 8, 3]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Static art: torso — a crew-neck tee.
|
|
181
|
+
//
|
|
182
|
+
// Same two-run shoulder construction as peep (outer arm edge, hard turn at
|
|
183
|
+
// the acromion, near-horizontal trapezius shelf), set slightly narrower. The
|
|
184
|
+
// neckline is one band arc with an ink underline — no blades, no placket.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
const TORSO = [
|
|
187
|
+
[60, 950],
|
|
188
|
+
[62, 864], [104, 750], [184, 720],
|
|
189
|
+
[240, 710], [286, 676], [322, 632],
|
|
190
|
+
[334, 662], [426, 664], [440, 628],
|
|
191
|
+
[474, 672], [518, 706], [570, 716],
|
|
192
|
+
[648, 746], [694, 860], [696, 950],
|
|
193
|
+
];
|
|
194
|
+
const TORSO_W = [9, 10, 8, 7.5, 10, 9];
|
|
195
|
+
|
|
196
|
+
const SEAM_L = [[186, 722], [168, 782], [172, 862], [186, 950]];
|
|
197
|
+
const SEAM_R = [[568, 718], [587, 778], [583, 858], [572, 950]];
|
|
198
|
+
|
|
199
|
+
const NECKBAND = [[322, 630], [340, 645], [360, 655], [380, 658],
|
|
200
|
+
[400, 654], [422, 644], [438, 627]];
|
|
201
|
+
|
|
202
|
+
// Two creases, on the right this time — cloth folds where the body pulls it,
|
|
203
|
+
// and mirroring peep's left-side set would make the two shirts read as the
|
|
204
|
+
// same drawing recoloured.
|
|
205
|
+
const CREASES = [
|
|
206
|
+
{ p: [[452, 738], [447, 764], [446, 788], [450, 806]], w: [2, 6, 2] },
|
|
207
|
+
{ p: [[478, 742], [474, 766], [473, 790], [476, 808]], w: [2, 5, 2] },
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Layers: the same four as peep, same order, same parallax.
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
const PARALLAX = { head: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
|
|
214
|
+
const LAYERS = ['head', 'body', 'features', 'hair'];
|
|
215
|
+
const PIVOT = { x: CX, y: 700 };
|
|
216
|
+
|
|
217
|
+
// yawPx a touch under peep's 28: the hair cloud is a much larger mass and at
|
|
218
|
+
// 28 its parallax slide against the head read as a wig shifting.
|
|
219
|
+
const POSE = {
|
|
220
|
+
leanTravel: 23, leanPivot: { x: PIVOT.x, y: 560 },
|
|
221
|
+
shrugLift: 30, shrugTiltDeg: 1.8, shrugPivot: { x: PIVOT.x, y: 800 },
|
|
222
|
+
yawPx: 26, pitchPx: 16,
|
|
223
|
+
pivot: PIVOT,
|
|
224
|
+
// peep's breath and trunk numbers; same construction, same art units, and
|
|
225
|
+
// the two characters should breathe alike. See face-peep.js for the
|
|
226
|
+
// derivation.
|
|
227
|
+
breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
|
|
228
|
+
turnPx: 16,
|
|
229
|
+
layers: LAYERS, parallax: PARALLAX,
|
|
230
|
+
torsoLayers: ['body'],
|
|
231
|
+
units: 1,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Generators: mouth. peep's contour model with wren's landmarks — the model
|
|
236
|
+
// carried over intact; only the sizing constants are this face's own. See
|
|
237
|
+
// face-peep.js for the full derivation commentary (aperture-not-centreline,
|
|
238
|
+
// the 0.18 compensation ramp, 3:1 downward opening, the cubic 0.75 solve).
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
function mouthGeometry(p) {
|
|
241
|
+
const cx = MOUTH.cx;
|
|
242
|
+
const cy = MOUTH.cy;
|
|
243
|
+
const open = clamp(p.mouthOpen);
|
|
244
|
+
const round = clamp(p.mouthRound);
|
|
245
|
+
const tuck = clamp(p.mouthTuck);
|
|
246
|
+
|
|
247
|
+
const w = (24 + clamp(p.mouthWidth) * 30) * (1 - 0.36 * round);
|
|
248
|
+
|
|
249
|
+
const t = 1 - 0.4 * clamp(p.mouthPress);
|
|
250
|
+
const profile = [2.5 * t, 9.5 * t, 3 * t, 10.5 * t * (1 + 0.35 * tuck), 2.5 * t];
|
|
251
|
+
const halfUp = profile[1] / 2;
|
|
252
|
+
const halfLo = profile[3] / 2;
|
|
253
|
+
|
|
254
|
+
const h = open * MOUTH_APERTURE;
|
|
255
|
+
const k = clamp(open / 0.18);
|
|
256
|
+
|
|
257
|
+
const yL = cy - 6 - p.mouthCornerL * 22;
|
|
258
|
+
const yR = cy - 6 - p.mouthCornerR * 22;
|
|
259
|
+
|
|
260
|
+
const apTop = cy - h * 0.25;
|
|
261
|
+
let apBot = cy + h * 0.75;
|
|
262
|
+
if (tuck > 0) apBot = Math.max(apTop + 6, apBot - tuck * (h * 0.6 + 4));
|
|
263
|
+
|
|
264
|
+
const cornerMid = (yL + yR) / 8;
|
|
265
|
+
const topY = (apTop - k * halfUp - cornerMid) / 0.75;
|
|
266
|
+
const botY = (apBot + k * halfLo - cornerMid) / 0.75;
|
|
267
|
+
|
|
268
|
+
const contour = [
|
|
269
|
+
[cx - w, yL],
|
|
270
|
+
[cx - w * 0.55, topY], [cx + w * 0.55, topY], [cx + w, yR],
|
|
271
|
+
[cx + w * 0.55, botY], [cx - w * 0.55, botY], [cx - w, yL],
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
const innerTop = cornerMid + 0.75 * topY + halfUp;
|
|
275
|
+
const innerBot = cornerMid + 0.75 * botY - halfLo;
|
|
276
|
+
|
|
277
|
+
return { contour, profile, cx, cy, w, h, topY, botY, innerTop, innerBot, open, tuck };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** peep's dental-arch teeth, verbatim but for the width factors. */
|
|
281
|
+
function teethPath(m, amt, lower) {
|
|
282
|
+
if (amt < 0.01) return '';
|
|
283
|
+
const gap = m.innerBot - m.innerTop;
|
|
284
|
+
if (gap < 2) return '';
|
|
285
|
+
const tw = m.w * (lower ? 0.6 : 0.76);
|
|
286
|
+
const cap = lower ? 0.5 : 0.5 + 0.35 * m.tuck;
|
|
287
|
+
const th = Math.min(amt * (lower ? 13 : 20), gap * cap);
|
|
288
|
+
|
|
289
|
+
if (lower) {
|
|
290
|
+
const base = m.innerBot + 8;
|
|
291
|
+
const edge = m.innerBot - th;
|
|
292
|
+
const end = m.innerBot - th * 0.35;
|
|
293
|
+
return (
|
|
294
|
+
`M${f(m.cx - tw)} ${f(base)}L${f(m.cx + tw)} ${f(base)}` +
|
|
295
|
+
`L${f(m.cx + tw * 0.92)} ${f(end)}` +
|
|
296
|
+
`Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const top = m.innerTop - 8;
|
|
300
|
+
const edge = m.innerTop + th;
|
|
301
|
+
const end = m.innerTop + th * 0.35;
|
|
302
|
+
return (
|
|
303
|
+
`M${f(m.cx - tw)} ${f(top)}L${f(m.cx + tw)} ${f(top)}` +
|
|
304
|
+
`L${f(m.cx + tw * 0.92)} ${f(end)}` +
|
|
305
|
+
`Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Eyes: peep's bean model, verbatim but for the tilt values (wren's beans sit
|
|
310
|
+
// nearly level — the glasses rings already give the face its geometry, and a
|
|
311
|
+
// strong bean tilt inside a perfect circle reads as misaligned lenses).
|
|
312
|
+
function eyePath(cx, cy, lid, squint, tiltDeg) {
|
|
313
|
+
const L = clamp(lid);
|
|
314
|
+
const topY = lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, L);
|
|
315
|
+
const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.7;
|
|
316
|
+
const rx = EYE.rx;
|
|
317
|
+
const a = (tiltDeg * Math.PI) / 180;
|
|
318
|
+
const ca = Math.cos(a), sa = Math.sin(a);
|
|
319
|
+
const R = (x, y) => {
|
|
320
|
+
const dx = x - cx, dy = y - cy;
|
|
321
|
+
return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
|
|
322
|
+
};
|
|
323
|
+
return (
|
|
324
|
+
`M${R(cx - rx, cy)}` +
|
|
325
|
+
`C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
|
|
326
|
+
`C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Brows: peep's point-list deformation, wren's points and a lighter profile.
|
|
331
|
+
function browPath(pts, raise, angle, inner) {
|
|
332
|
+
const n = pts.length - 1;
|
|
333
|
+
const out = pts.map(([x, y], i) => {
|
|
334
|
+
const u = i / n;
|
|
335
|
+
return [x, y - raise * 15 - inner * 11 * (1 - u) - angle * 12 * u];
|
|
336
|
+
});
|
|
337
|
+
return taper(out, [3, 12, 6], 6);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Static markup
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
const HEAD_FILL = region(HEAD);
|
|
344
|
+
const HEAD_RING = taperRing(HEAD, HEAD_W, 10);
|
|
345
|
+
|
|
346
|
+
function markup(id, t) {
|
|
347
|
+
const ink = (d) => `<path d="${d}" fill="${t.ink}"/>`;
|
|
348
|
+
return `
|
|
349
|
+
<svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
|
|
350
|
+
preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
|
|
351
|
+
<defs>
|
|
352
|
+
<radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
|
|
353
|
+
<stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
|
|
354
|
+
</radialGradient>
|
|
355
|
+
<clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
|
|
356
|
+
</defs>
|
|
357
|
+
|
|
358
|
+
<rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
|
|
359
|
+
|
|
360
|
+
<!-- head and neck. No ears: the hair band covers them (see the hair note).
|
|
361
|
+
Hair underlay at head parallax, same insurance as peep's — the cloud
|
|
362
|
+
abuts the skull with no margin, and the hair layer slides against the
|
|
363
|
+
head under yaw. -->
|
|
364
|
+
<g id="${id}-head">
|
|
365
|
+
<path d="${NECK_FILL}" fill="${t.paper}"/>
|
|
366
|
+
${ink(taper(NECK_L, [3, 8, 6]))}
|
|
367
|
+
${ink(taper(NECK_R, [3, 8, 6]))}
|
|
368
|
+
<path d="${HEAD_FILL}" fill="${t.paper}"/>
|
|
369
|
+
${ink(HEAD_RING)}
|
|
370
|
+
${ink(taper(JAW_UNDER, [2, 5.5, 2]))}
|
|
371
|
+
<path d="${HAIR_D}" fill="${t.ink}"/>
|
|
372
|
+
</g>
|
|
373
|
+
|
|
374
|
+
<!-- tee: silhouette, sleeve seams, neckband, creases -->
|
|
375
|
+
<g id="${id}-body">
|
|
376
|
+
<path d="${region(TORSO)}" fill="${t.paper}"/>
|
|
377
|
+
${ink(taper(TORSO, TORSO_W))}
|
|
378
|
+
${ink(taper(SEAM_L, [6, 7, 5]))}
|
|
379
|
+
${ink(taper(SEAM_R, [6, 7, 5]))}
|
|
380
|
+
${ink(taper(NECKBAND, [5, 8, 5]))}
|
|
381
|
+
${CREASES.map((c) => ink(taper(c.p, c.w))).join('\n ')}
|
|
382
|
+
</g>
|
|
383
|
+
|
|
384
|
+
<!-- features: brows, eyes, nose, glasses, mouth -->
|
|
385
|
+
<g id="${id}-features">
|
|
386
|
+
<path id="${id}-browL" fill="${t.ink}"/>
|
|
387
|
+
<path id="${id}-browR" fill="${t.ink}"/>
|
|
388
|
+
<g id="${id}-eyes">
|
|
389
|
+
<path id="${id}-eyeL" fill="${t.ink}"/>
|
|
390
|
+
<path id="${id}-eyeR" fill="${t.ink}"/>
|
|
391
|
+
</g>
|
|
392
|
+
<path d="${NOSE_D}" fill="${t.ink}"/>
|
|
393
|
+
<path d="${taperRing(LENS_L, RING_W)}" fill="${t.accent}"/>
|
|
394
|
+
<path d="${taperRing(LENS_R_, RING_W)}" fill="${t.accent}"/>
|
|
395
|
+
<path d="${taper(BRIDGE, [4, 5.5, 4])}" fill="${t.accent}"/>
|
|
396
|
+
<path d="${taper(TEMPLE_L, [4, 4.5, 5])}" fill="${t.accent}"/>
|
|
397
|
+
<path d="${taper(TEMPLE_R, [4, 4.5, 5])}" fill="${t.accent}"/>
|
|
398
|
+
<g id="${id}-mouth">
|
|
399
|
+
<path id="${id}-mouthIn" fill="${t.mouthIn}"/>
|
|
400
|
+
<g clip-path="url(#${id}-clipMouth)">
|
|
401
|
+
<ellipse id="${id}-tongue" fill="${t.tongue}"/>
|
|
402
|
+
<path id="${id}-teeth" fill="${t.teeth}"/>
|
|
403
|
+
<path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
|
|
404
|
+
</g>
|
|
405
|
+
<path id="${id}-lips" fill="${t.ink}"/>
|
|
406
|
+
</g>
|
|
407
|
+
</g>
|
|
408
|
+
|
|
409
|
+
<!-- hair -->
|
|
410
|
+
<g id="${id}-hair">
|
|
411
|
+
<path d="${HAIR_D}" fill="${t.ink}"/>
|
|
412
|
+
</g>
|
|
413
|
+
</svg>`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Renderer — peep's apply() shape exactly; only the travels differ.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
let uid = 0;
|
|
420
|
+
|
|
421
|
+
export function createFace(mount, theme = {}) {
|
|
422
|
+
const t = Object.assign({}, THEME, theme);
|
|
423
|
+
const id = `wren${++uid}`;
|
|
424
|
+
const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
|
|
425
|
+
|
|
426
|
+
const el = {
|
|
427
|
+
head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
|
|
428
|
+
browL: $('browL'), browR: $('browR'),
|
|
429
|
+
eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
|
|
430
|
+
mouthIn: $('mouthIn'), lips: $('lips'), clipMouth: $('clipMouthP'),
|
|
431
|
+
teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
function apply(p) {
|
|
435
|
+
poseTransforms(p, set, el, POSE);
|
|
436
|
+
|
|
437
|
+
// Eyes: bean travel capped by the lens rings — see the header. 10 is the
|
|
438
|
+
// most x the frame allows (bean edge 25 against an inner radius of 26);
|
|
439
|
+
// DISTRACTED is the state that needs every unit of it, same lesson as
|
|
440
|
+
// peep's 7→11 raise, hit from the other side.
|
|
441
|
+
set(el.eyes, 'transform', `translate(${f(p.pupilX * 10)} ${f(p.pupilY * 7)})`);
|
|
442
|
+
|
|
443
|
+
const lidFollow = Math.max(0, p.pupilY) * 0.22;
|
|
444
|
+
set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y + 1, p.lidL + lidFollow, p.squintL, -3));
|
|
445
|
+
set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y - 1, p.lidR + lidFollow, p.squintR, 3));
|
|
446
|
+
|
|
447
|
+
set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
|
|
448
|
+
set(el.browR, 'd', browPath(BROW_R, p.browRaiseR, p.browAngleR, p.browInnerR));
|
|
449
|
+
|
|
450
|
+
const m = mouthGeometry(p);
|
|
451
|
+
const contour = region(m.contour);
|
|
452
|
+
set(el.mouthIn, 'd', contour);
|
|
453
|
+
set(el.clipMouth, 'd', contour);
|
|
454
|
+
set(el.lips, 'd', taperRing(m.contour, m.profile, 12));
|
|
455
|
+
// Keyed off the drawn gap, not mouthOpen — a half-opaque interior is GREY,
|
|
456
|
+
// and grey on a two-value face reads as a rendering fault (peep's F bug).
|
|
457
|
+
set(el.mouthIn, 'opacity', f(clamp((m.innerBot - m.innerTop) / 3)));
|
|
458
|
+
|
|
459
|
+
pairedTeeth(p, set, el, teethPath, m);
|
|
460
|
+
|
|
461
|
+
const tg = clamp(p.tongue);
|
|
462
|
+
set(el.tongue, 'cx', f(m.cx));
|
|
463
|
+
set(el.tongue, 'cy', f(m.innerBot + 6 - tg * ((m.innerBot - m.innerTop) * 0.8 + 6)));
|
|
464
|
+
set(el.tongue, 'rx', f(m.w * 0.58));
|
|
465
|
+
set(el.tongue, 'ry', '8');
|
|
466
|
+
set(el.tongue, 'opacity', tg > 0.02 ? '1' : '0');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return faceApi(mount, svg, apply, t);
|
|
470
|
+
}
|
package/src/gaze.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gaze.
|
|
3
|
+
*
|
|
4
|
+
* The server sends a semantic direction; the client does the oculomotor work.
|
|
5
|
+
* Three details do almost all the perceptual heavy lifting, and skipping any
|
|
6
|
+
* one of them makes the face read as a puppet:
|
|
7
|
+
*
|
|
8
|
+
* 1. The eyes get there first. Saccades are near-instant; the head ambles
|
|
9
|
+
* after — accelerating, cruising, braking to a stop (see HEAD_ACCEL).
|
|
10
|
+
* 2. The head only goes part of the way. Real people under-rotate the head
|
|
11
|
+
* and let the eyes carry the remainder.
|
|
12
|
+
* 3. A blink fires on any large shift. Gaze-evoked blinks are involuntary and
|
|
13
|
+
* their absence is uncanny even though nobody can name what's wrong.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const GAZE_TARGETS = {
|
|
17
|
+
// Straight down the barrel of the webcam — the default during conversation.
|
|
18
|
+
USER: { px: 0.00, py: 0.06, hx: 0.00, hy: 0.00 },
|
|
19
|
+
// Still on the user, but the head is cheated aside so an ear favors the
|
|
20
|
+
// speaker — the "I'm trying to hear you" attitude. The trick is that the
|
|
21
|
+
// head-follow and the pupils point OPPOSITE ways: the head turns off-axis
|
|
22
|
+
// and the eyes counter back to the camera, which is what keeps it reading
|
|
23
|
+
// as contact rather than as looking away. CANT_HEAR sits on this target.
|
|
24
|
+
USER_EAR: { px: -0.42, py: 0.05, hx: 0.55, hy: 0.02, roll: 0.55 },
|
|
25
|
+
SCREEN_CENTER: { px: 0.00, py: -0.16, hx: 0.00, hy: -0.10 },
|
|
26
|
+
SCREEN_LEFT: { px: -0.78, py: -0.10, hx: -0.36, hy: -0.05 },
|
|
27
|
+
SCREEN_RIGHT: { px: 0.78, py: -0.10, hx: 0.36, hy: -0.05 },
|
|
28
|
+
SCREEN_TOP: { px: 0.00, py: -0.72, hx: 0.00, hy: -0.30 },
|
|
29
|
+
SCREEN_BOTTOM: { px: 0.00, py: 0.62, hx: 0.00, hy: 0.24 },
|
|
30
|
+
SCREEN_WORK: { px: -0.62, py: 0.18, hx: -0.26, hy: 0.12 },
|
|
31
|
+
NOTES: { px: 0.18, py: 0.72, hx: 0.04, hy: 0.28 },
|
|
32
|
+
// The classic "recalling something" break of eye contact. Keep it for the
|
|
33
|
+
// stylized "let me think" beat — audiences read up-and-away regardless of
|
|
34
|
+
// whether real thinkers do it.
|
|
35
|
+
AWAY_THINKING: { px: -0.58, py: -0.58, hx: -0.20, hy: -0.18, roll: 0.08 },
|
|
36
|
+
AWAY_RIGHT: { px: 0.58, py: -0.52, hx: 0.20, hy: -0.16, roll: -0.06 },
|
|
37
|
+
// Where measured cognitive aversion actually goes: DOWN (39%, more than up
|
|
38
|
+
// or side — docs/research-biomechanics.md §4.2). Down-left, mild enough
|
|
39
|
+
// that the lid follow shades the eyes without sealing them; the head
|
|
40
|
+
// carries a share so the pupils stay inside the aperture at tile size.
|
|
41
|
+
AWAY_DOWN: { px: -0.45, py: 0.42, hx: -0.18, hy: 0.20, roll: 0.04 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const GAZE_NAMES = Object.keys(GAZE_TARGETS);
|
|
45
|
+
|
|
46
|
+
const HEAD_FOLLOW_TAU = 0.34; // roll only — the head lags the eyes badly, on purpose
|
|
47
|
+
const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked blink
|
|
48
|
+
|
|
49
|
+
// Head follow is ballistic, not exponential. An exponential chase has its peak
|
|
50
|
+
// velocity at t=0 and then creeps forever — motion that starts instantly and
|
|
51
|
+
// never quite arrives is what "drifting" looks like. A real orienting head
|
|
52
|
+
// accelerates, cruises, and *brakes to a stop*; the stop is the cue that
|
|
53
|
+
// attention has landed (docs/research-biomechanics.md §1.2 — the head's travel
|
|
54
|
+
// is a discrete arriving move, eyes first, head after). The constants keep the
|
|
55
|
+
// old τ=0.34 amble: a typical 0.3-unit shift completes in ~0.55s, USER_EAR's
|
|
56
|
+
// 0.55-unit swing in ~0.85s. The mixer still low-passes headYaw/headPitch at
|
|
57
|
+
// τ=0.16 downstream, which rounds the hard stop into a short settle — that
|
|
58
|
+
// cascade is deliberate, so do not "help" by softening the brake here too.
|
|
59
|
+
const HEAD_ACCEL = 4.0; // units/s² — sets both launch and braking firmness
|
|
60
|
+
const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach it
|
|
61
|
+
|
|
62
|
+
export class GazeLayer {
|
|
63
|
+
constructor() {
|
|
64
|
+
this.target = GAZE_TARGETS.USER;
|
|
65
|
+
this.name = 'USER';
|
|
66
|
+
this.head = { x: 0, y: 0, roll: 0 };
|
|
67
|
+
this.vel = { x: 0, y: 0 };
|
|
68
|
+
this.onLargeShift = null;
|
|
69
|
+
this.jitter = { x: 0, y: 0 };
|
|
70
|
+
this._nextMicro = 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} name one of GAZE_NAMES
|
|
75
|
+
* @param {{x:number,y:number}} [override] normalized -1..1 escape hatch for
|
|
76
|
+
* when the server knows exact screen coordinates
|
|
77
|
+
*/
|
|
78
|
+
set(name, override) {
|
|
79
|
+
const next = override
|
|
80
|
+
? { px: override.x, py: override.y, hx: override.x * 0.42, hy: override.y * 0.36 }
|
|
81
|
+
: GAZE_TARGETS[name] || GAZE_TARGETS.USER;
|
|
82
|
+
const d = Math.hypot(next.px - this.target.px, next.py - this.target.py);
|
|
83
|
+
this.target = next;
|
|
84
|
+
this.name = override ? 'CUSTOM' : name;
|
|
85
|
+
if (d > BLINK_THRESHOLD && this.onLargeShift) this.onLargeShift();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Micro-saccades. Eyes are never still; a perfectly fixed pupil reads as
|
|
90
|
+
* dead. Small, frequent, and irregular.
|
|
91
|
+
*/
|
|
92
|
+
_micro(t, dt) {
|
|
93
|
+
if (t >= this._nextMicro) {
|
|
94
|
+
this._nextMicro = t + 0.7 + Math.random() * 1.6;
|
|
95
|
+
this.jitter.x = (Math.random() - 0.5) * 0.16;
|
|
96
|
+
this.jitter.y = (Math.random() - 0.5) * 0.11;
|
|
97
|
+
}
|
|
98
|
+
const decay = 1 - Math.exp(-dt / 0.5);
|
|
99
|
+
this.jitter.x -= this.jitter.x * decay;
|
|
100
|
+
this.jitter.y -= this.jitter.y * decay;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
update(t, dt) {
|
|
104
|
+
this._micro(t, dt);
|
|
105
|
+
|
|
106
|
+
// Ballistic head follow: steer velocity toward "full speed at the target,
|
|
107
|
+
// but never faster than can still brake to a stop within the distance
|
|
108
|
+
// left". The braking bound is v² = 2·a·d solved for v, with a half-step
|
|
109
|
+
// correction for discrete time (the −maxA term) so a frame never lands
|
|
110
|
+
// past the target. As d shrinks the bound falls to zero, which *is* the
|
|
111
|
+
// deceleration — no separate easing curve.
|
|
112
|
+
const dx = this.target.hx - this.head.x;
|
|
113
|
+
const dy = this.target.hy - this.head.y;
|
|
114
|
+
const d = Math.hypot(dx, dy);
|
|
115
|
+
const maxA = HEAD_ACCEL * dt;
|
|
116
|
+
const brake = 0.5 * (Math.sqrt(maxA * maxA + 8 * HEAD_ACCEL * d) - maxA);
|
|
117
|
+
const goal = Math.min(HEAD_SPEED, brake);
|
|
118
|
+
let ax = (d ? (dx / d) * goal : 0) - this.vel.x;
|
|
119
|
+
let ay = (d ? (dy / d) * goal : 0) - this.vel.y;
|
|
120
|
+
const a = Math.hypot(ax, ay);
|
|
121
|
+
if (a > maxA) { ax *= maxA / a; ay *= maxA / a; }
|
|
122
|
+
this.vel.x += ax;
|
|
123
|
+
this.vel.y += ay;
|
|
124
|
+
// A dropped-frame dt could step past the target; land on it instead.
|
|
125
|
+
// Judged on the toward-target component so a mid-retarget frame with
|
|
126
|
+
// sideways velocity keeps flying rather than teleporting.
|
|
127
|
+
const along = d ? ((this.vel.x * dx + this.vel.y * dy) / d) * dt : 0;
|
|
128
|
+
if (along >= d && d >= 0) {
|
|
129
|
+
this.head.x = this.target.hx;
|
|
130
|
+
this.head.y = this.target.hy;
|
|
131
|
+
this.vel.x = 0;
|
|
132
|
+
this.vel.y = 0;
|
|
133
|
+
} else {
|
|
134
|
+
this.head.x += this.vel.x * dt;
|
|
135
|
+
this.head.y += this.vel.y * dt;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Roll stays on the exponential: its travels are tiny (≤0.55 and usually
|
|
139
|
+
// ~0.08), far below where the drift tail is visible.
|
|
140
|
+
const k = 1 - Math.exp(-dt / HEAD_FOLLOW_TAU);
|
|
141
|
+
this.head.roll += ((this.target.roll || 0) - this.head.roll) * k;
|
|
142
|
+
|
|
143
|
+
const pupilY = this.target.py + this.jitter.y;
|
|
144
|
+
return {
|
|
145
|
+
pupilX: this.target.px + this.jitter.x,
|
|
146
|
+
pupilY,
|
|
147
|
+
headYaw: this.head.x,
|
|
148
|
+
headPitch: this.head.y,
|
|
149
|
+
headRoll: this.head.roll,
|
|
150
|
+
// The upper lid tracks the eye vertically. Without this, looking down
|
|
151
|
+
// exposes a band of sclera above the iris and the avatar looks startled.
|
|
152
|
+
lidBias: pupilY * 0.34,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|