@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.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +692 -0
  3. package/client/dist/Avatar.d.ts +24 -0
  4. package/client/dist/Avatar.d.ts.map +1 -0
  5. package/client/dist/Avatar.js +7 -0
  6. package/client/dist/Avatar.js.map +1 -0
  7. package/client/dist/AvatarClient.d.ts +173 -0
  8. package/client/dist/AvatarClient.d.ts.map +1 -0
  9. package/client/dist/AvatarClient.js +274 -0
  10. package/client/dist/AvatarClient.js.map +1 -0
  11. package/client/dist/pipecat.d.ts +21 -0
  12. package/client/dist/pipecat.d.ts.map +1 -0
  13. package/client/dist/pipecat.js +21 -0
  14. package/client/dist/pipecat.js.map +1 -0
  15. package/client/dist/react.d.ts +16 -0
  16. package/client/dist/react.d.ts.map +1 -0
  17. package/client/dist/react.js +17 -0
  18. package/client/dist/react.js.map +1 -0
  19. package/client/dist/types.d.ts +101 -0
  20. package/client/dist/types.d.ts.map +1 -0
  21. package/client/dist/types.js +31 -0
  22. package/client/dist/types.js.map +1 -0
  23. package/client/dist/useAvatar.d.ts +53 -0
  24. package/client/dist/useAvatar.d.ts.map +1 -0
  25. package/client/dist/useAvatar.js +68 -0
  26. package/client/dist/useAvatar.js.map +1 -0
  27. package/client/src/Avatar.tsx +38 -0
  28. package/client/src/AvatarClient.ts +343 -0
  29. package/client/src/pipecat.ts +38 -0
  30. package/client/src/react.ts +34 -0
  31. package/client/src/types.ts +127 -0
  32. package/client/src/useAvatar.ts +113 -0
  33. package/docs/contract-avatar.md +337 -0
  34. package/docs/contract-protocol.md +401 -0
  35. package/package.json +89 -0
  36. package/src/audio-fallback.js +100 -0
  37. package/src/avatar.d.ts +241 -0
  38. package/src/avatar.js +722 -0
  39. package/src/clips.js +144 -0
  40. package/src/emotions.js +55 -0
  41. package/src/face-core.js +154 -0
  42. package/src/face-myna.js +725 -0
  43. package/src/face-peep.js +767 -0
  44. package/src/face-wren.js +470 -0
  45. package/src/gaze.js +155 -0
  46. package/src/idle.js +535 -0
  47. package/src/interjections.js +578 -0
  48. package/src/line-art.js +111 -0
  49. package/src/params.js +176 -0
  50. package/src/perform.js +105 -0
  51. package/src/visemes.js +230 -0
@@ -0,0 +1,725 @@
1
+ /**
2
+ * Avatar: "myna".
3
+ *
4
+ * The third line-art character, authored against a user-supplied reference
5
+ * asset (a raster illustration): a young contemporary Indian woman — wavy
6
+ * shoulder-length dark hair with paper highlight slashes, thin geometric
7
+ * glasses, orange hoop earrings, a white crew tee under an open orange
8
+ * overshirt. Hand-authored geometry with the reference on screen; nothing is
9
+ * traced. Same idiom as peep and wren — no strokes anywhere, every mark a
10
+ * filled taper — and the same 30-channel contract underneath.
11
+ *
12
+ * What is deliberately different from the siblings, and why:
13
+ * - THE HAIR IS A HORSESHOE, NOT A CLOUD. One closed loop: wavy outer
14
+ * silhouette down to pointed lock tips IN FRONT of both shoulders, then the
15
+ * inner edge back up the face, joined across the forehead by the fringe
16
+ * sweep. The enclosed "hole" (face, neck, chest V) opens downward between
17
+ * the two lock groups, so the region needs no fill-rule tricks.
18
+ * - THE EARS SIT ON THE HAIR. In the reference the mass runs behind the ears,
19
+ * so ears and hoops are drawn in the hair layer, after the mass. The
20
+ * head-layer underlay (same loop at head parallax) backfills the ±3-unit
21
+ * slide between the two layers under yaw — same insurance as peep's, doing
22
+ * one extra job here.
23
+ * - THE ACCENT IS A GARMENT, not trim (peep) or frames (wren): the overshirt
24
+ * is two accent-filled panels over a paper tee. Background leaks behind an
25
+ * orange shirt are loud, which is why the tee runs edge-to-edge under both
26
+ * panels and everything overshoots the frame.
27
+ * - THE MOUTH HAS LIPS. The proven aperture-contour model, but the ring's
28
+ * width profile carries a cupid's-bow dip at the top centre and a fuller
29
+ * lower lip. (An under-lip shadow mark existed through v7; it was deleted —
30
+ * a mark inside the deformation zone either rides every open perfectly or
31
+ * it is a defect, and its ink went to the lip band instead.)
32
+ *
33
+ * v8 calibration note: the expressive gains here (brow travel, corner lift,
34
+ * squint, lip weights, the lid mapping) are authored against a 130 px
35
+ * DOWNSAMPLE of the rest/emotion renders, not the close-up — this face ships
36
+ * in a call tile. Slightly theatrical at full size is correct.
37
+ *
38
+ * Like peep, there is no drawn jaw line to drop, so `jaw` has nothing to
39
+ * move — same documented character decision.
40
+ */
41
+
42
+ import { clamp, lerp } from './params.js';
43
+ import {
44
+ f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
45
+ } from './face-core.js';
46
+ import { taper, taperRing, region } from './line-art.js';
47
+
48
+ export const THEME = {
49
+ // Sampled from the reference card: a warmer, creamier ground than peep's.
50
+ bg0: '#f2ead9',
51
+ bg1: '#e6d9c2',
52
+ ink: '#191919',
53
+ paper: '#ffffff',
54
+ // The overshirt orange — one key, hoops use it too. ~9% duskier than the
55
+ // asset's sample: at tile size the sampled value out-shouted the face.
56
+ accent: '#dc8432',
57
+ mouthIn: '#191919',
58
+ teeth: '#ffffff',
59
+ tongue: '#8d7f79',
60
+ };
61
+
62
+ // Same native 760x950 art space as the siblings; window lifted like wren's
63
+ // because the hair crowns high.
64
+ const VB = { x: 92, y: 50, w: 576, h: 800 };
65
+
66
+ export const META = {
67
+ viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
68
+ mouthCrop: { x: 298, y: 436, w: 164, h: 100 },
69
+ };
70
+
71
+ // --- landmarks --------------------------------------------------------------
72
+ const CX = 380;
73
+ const HEAD_TOP = 150;
74
+
75
+ const EYE = { y: 386, dx: 56, rx: 15, ry: 16 };
76
+ const MOUTH = { cx: CX, cy: 492 };
77
+ const MOUTH_APERTURE = 36;
78
+
79
+ // Lenses: measured off the reference's fully visible left lens, converted by
80
+ // face-height fraction (lens height ≈ 0.22 of hairline→chin; w:h ≈ 1.3;
81
+ // corner radius ≈ 0.38 of lens height — a soft rect verging on superellipse).
82
+ // Centred ON the eye line: the reference pupil sits at lens mid-height, and
83
+ // its "just under the brows" top gap is 0.21 of lens height, which lands the
84
+ // same way here. Lens centres sit 4 units outboard of the eyes so the bridge
85
+ // keeps the reference's narrow 18-unit gap without moving the pupils.
86
+ const LENS = { dx: 60, hw: 51, hh: 40, r: 30 };
87
+
88
+ // Brows: bold, long and clearly arched, with real air between them and the
89
+ // frames — the reference's brows are its strongest single mark. The rest
90
+ // shape is a gentle taper-arc: v7's outer third dropped 17 units in 15 and
91
+ // over a resting smile the kink composited into a smirk that curdled across
92
+ // a long session. The kink is still available — as a browAngle POSE.
93
+ const BROW_L = [[CX - 22, 340], [CX - 40, 327], [CX - 60, 320], [CX - 76, 319],
94
+ [CX - 88, 322], [CX - 97, 327], [CX - 103, 332]];
95
+ // Near-mirrored: the right arch rides 1 unit higher (personality, small
96
+ // enough to survive the mirror-flip levelness check, which is judged on the
97
+ // glasses and eye lines). Anything bigger read as a baked-in head tilt —
98
+ // the mixer's headRoll adds on top of the art and nothing can subtract a
99
+ // drawn lean.
100
+ const BROW_R = [[CX + 22, 340], [CX + 40, 326], [CX + 60, 319], [CX + 76, 318],
101
+ [CX + 88, 321], [CX + 97, 327], [CX + 103, 332]];
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Static art: head.
105
+ //
106
+ // Same three-segment jaw rule as every head here, but the runs are the softest
107
+ // of the three rigs: rounded gonion, full cheek, small chin pad slightly off
108
+ // the midline. Chin at 568 against wren's 572 and peep's 597 — the shorter
109
+ // lower face is most of the "mid twenties" read.
110
+ // ---------------------------------------------------------------------------
111
+ const HEAD = [
112
+ [CX, HEAD_TOP],
113
+ [452, 149], [518, 206], [522, 288],
114
+ [525, 338], [522, 386], [518, 424],
115
+ [516, 456], [510, 482], [498, 508],
116
+ [484, 530], [452, 556], [422, 566],
117
+ [404, 572], [350, 571], [332, 563],
118
+ [306, 551], [282, 529], [266, 505],
119
+ [252, 479], [246, 453], [250, 423],
120
+ [244, 385], [240, 337], [243, 287],
121
+ [247, 205], [308, 148], [CX, HEAD_TOP],
122
+ ];
123
+ // Lighter than wren's ring by ~20% — the reference line is confident but not
124
+ // woodcut. Heavy low on the jaw, nothing at the crown (under hair).
125
+ const HEAD_W = [3, 6, 9.5, 12, 13, 11.5, 9, 6, 4, 3];
126
+
127
+ // Neck: the shared truncated-cone construction, slim; fused into the head
128
+ // layer and overshot past every neckline for the standard parallax reasons.
129
+ const NECK_FILL =
130
+ 'M344 512C336 570 330 620 328 680L328 790L434 790L434 680C432 620 426 570 418 512Z';
131
+ const NECK_L = [[344, 530], [338, 580], [333, 634], [331, 720]];
132
+ const NECK_R = [[418, 528], [424, 578], [429, 632], [431, 720]];
133
+ const JAW_UNDER = [[340, 558], [356, 582], [404, 584], [422, 556]];
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Static art: hair — the wavy lob.
137
+ //
138
+ // One closed horseshoe (see header). Waves are S-curves on the outer edge and
139
+ // pointed tips at the bottom; the fringe sweeps from a part left of centre
140
+ // down across the forehead, bottoming out above the right brow. Interior
141
+ // highlight slashes are separate paper marks on top of the mass.
142
+ // ---------------------------------------------------------------------------
143
+ const HAIR = [
144
+ // left lock tip (in front of the shoulder), then one curl and a smooth
145
+ // outer edge going up — deep or repeated scallops here read as braids,
146
+ // which is what sank the first two drafts
147
+ [252, 712],
148
+ [224, 706], [204, 684], [198, 654],
149
+ [186, 622], [178, 588], [182, 554],
150
+ [172, 520], [168, 480], [176, 444],
151
+ [170, 404], [174, 360], [186, 320],
152
+ [188, 270], [210, 226], [240, 196],
153
+ [258, 158], [296, 130], [336, 114],
154
+ [366, 100], [420, 96], [458, 112],
155
+ [498, 116], [534, 142], [548, 178],
156
+ // outer right, down with the same gentle waves
157
+ [574, 202], [586, 248], [580, 288],
158
+ [594, 326], [594, 382], [582, 418],
159
+ [586, 452], [578, 498], [566, 528],
160
+ [574, 560], [566, 600], [550, 626],
161
+ [556, 648], [540, 674], [516, 686],
162
+ [508, 700], [484, 704], [470, 690],
163
+ // right front lock: inner boundary climbing over the chest to the jaw
164
+ [458, 670], [454, 642], [462, 612],
165
+ [478, 588], [500, 564], [518, 540],
166
+ // right inner edge up the face side to the temple
167
+ [530, 502], [530, 454], [532, 410],
168
+ [532, 382], [524, 358], [510, 346],
169
+ // the fringe hairline, right to left: a HIGH arc — the reference forehead
170
+ // is largely open (brow-to-hairline reads close to brow-to-mouth). The
171
+ // right descent is short, a strand tip dipping to just above the right
172
+ // frame corner; v6 let the arc sag to the brows and the whole upper face
173
+ // went dark
174
+ [500, 344], [494, 322], [488, 298],
175
+ [478, 268], [462, 250], [448, 240],
176
+ [436, 236], [427, 242], [416, 238],
177
+ // …the cleft dips at the part (right of centre, as drawn), then the left
178
+ // branch is one long high swoop to the temple — it must clear the left
179
+ // brow tail entirely, so the descent happens outboard of x≈276
180
+ [398, 236], [376, 242], [352, 252],
181
+ [328, 264], [308, 278], [294, 292],
182
+ [284, 306], [276, 320], [270, 332],
183
+ [264, 344], [258, 356], [256, 366],
184
+ // left inner edge: down the face side, then slanting AWAY from the neck —
185
+ // an edge that hugs the neck makes the side mass a rope
186
+ [250, 392], [244, 432], [248, 478],
187
+ [252, 516], [242, 554], [230, 592],
188
+ [224, 622], [220, 654], [228, 684],
189
+ [236, 700], [244, 708], [252, 712],
190
+ ];
191
+ const HAIR_D = region(HAIR);
192
+
193
+ // Paper highlight slashes: crescent cuts following the flow. These, plus the
194
+ // wavy silhouette, are the contemporary read; without them the mass is a
195
+ // helmet whatever its outline does.
196
+ // v8: fewer, bolder. Eight slivers flickered during nods — the most-played
197
+ // motion in the product — so each side-fall keeps two large crescents instead
198
+ // of three ticks, and every survivor gained width. The silhouette, not the
199
+ // interior, is the identity feature.
200
+ const SHINE = [
201
+ { p: [[330, 150], [300, 168], [280, 196], [272, 228]], w: [2, 7, 2] },
202
+ // the long echo of the fringe sweep — the reference's most prominent slash;
203
+ // it lives in the band ABOVE the hairline, tight to the sweep's direction
204
+ { p: [[468, 220], [446, 196], [416, 176], [382, 166]], w: [1.5, 5.5, 1.5] },
205
+ { p: [[196, 336], [187, 380], [187, 424], [196, 462]], w: [2, 8, 2] },
206
+ { p: [[204, 522], [196, 574], [206, 648], [242, 700]], w: [1.5, 7, 1.5] },
207
+ { p: [[574, 330], [583, 372], [583, 414], [574, 448]], w: [2, 8, 2] },
208
+ { p: [[550, 538], [552, 592], [530, 652], [482, 684]], w: [1.5, 7, 1.5] },
209
+ ];
210
+
211
+ // Ears and hoops: painted ON the hair mass (see header). Small C-shapes; the
212
+ // hoop is a thin accent ring hanging from the lobe — the reference's one
213
+ // piece of jewellery.
214
+ const EAR_L = [[246, 404], [224, 396], [212, 418], [216, 446], [222, 468], [240, 474], [250, 464]];
215
+ const EAR_R = [[514, 404], [536, 396], [548, 418], [544, 446], [538, 468], [520, 474], [510, 464]];
216
+ const EAR_L_IN = [[230, 420], [220, 430], [221, 446], [228, 455]];
217
+ const EAR_R_IN = [[530, 420], [540, 430], [539, 446], [532, 455]];
218
+
219
+ const K = 0.5523;
220
+ function circlePts(cx, cy, r) {
221
+ const k = r * K;
222
+ return [
223
+ [cx + r, cy], [cx + r, cy + k], [cx + k, cy + r], [cx, cy + r],
224
+ [cx - k, cy + r], [cx - r, cy + k], [cx - r, cy], [cx - r, cy - k],
225
+ [cx - k, cy - r], [cx, cy - r], [cx + k, cy - r], [cx + r, cy - k],
226
+ [cx + r, cy],
227
+ ];
228
+ }
229
+ const HOOP_L = taperRing(circlePts(236, 488, 12), [4.5, 5, 4.5]);
230
+ const HOOP_R = taperRing(circlePts(524, 488, 12), [4.5, 5, 4.5]);
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Static art: glasses — thin geometric rounded rectangles in ink.
234
+ //
235
+ // Wider than tall, corners genuinely rounded, thin ring. Temples run outward
236
+ // and vanish under the hair's inner edge. Drawn after the eyes so the frame
237
+ // passes over the bean at gaze extremes rather than under it.
238
+ // ---------------------------------------------------------------------------
239
+ function roundRectPts(cx, cy, hw, hh, r) {
240
+ const k = r * K;
241
+ const L = cx - hw, R = cx + hw, T = cy - hh, B = cy + hh;
242
+ return [
243
+ [R, cy],
244
+ [R, cy + (hh - r) / 2], [R, B - r - k * 0.2], [R, B - r],
245
+ [R, B - r + k], [R - r + k, B], [R - r, B],
246
+ [R - r - (hw - r) / 2, B], [L + r + (hw - r) / 2, B], [L + r, B],
247
+ [L + r - k, B], [L, B - r + k], [L, B - r],
248
+ [L, B - r - k * 0.2], [L, cy + (hh - r) / 2], [L, cy],
249
+ [L, cy - (hh - r) / 2], [L, T + r + k * 0.2], [L, T + r],
250
+ [L, T + r - k], [L + r - k, T], [L + r, T],
251
+ [L + r + (hw - r) / 2, T], [R - r - (hw - r) / 2, T], [R - r, T],
252
+ [R - r + k, T], [R, T + r - k], [R, T + r],
253
+ [R, T + r + k * 0.2], [R, cy - (hh - r) / 2], [R, cy],
254
+ ];
255
+ }
256
+ const LENS_L = roundRectPts(CX - LENS.dx, EYE.y, LENS.hw, LENS.hh, LENS.r);
257
+ const LENS_R = roundRectPts(CX + LENS.dx, EYE.y, LENS.hw, LENS.hh, LENS.r);
258
+ const RING_W = [3, 3.4, 3, 3.4, 3];
259
+ // Bridge at the upper third and temples off the top outer corners, sweeping
260
+ // up toward the ears — both as drawn in the reference.
261
+ const BRIDGE = [[CX - 9, 371], [CX - 3, 366], [CX + 3, 366], [CX + 9, 371]];
262
+ // Attach on the top-outer corner arc (the rounded corner pulls the rim in to
263
+ // ~x±108 at this height), sweeping up to vanish under the hair's inner edge.
264
+ const TEMPLE_L = [[CX - 108, 364], [CX - 124, 357], [CX - 141, 352], [CX - 158, 350]];
265
+ const TEMPLE_R = [[CX + 108, 364], [CX + 124, 357], [CX + 141, 352], [CX + 158, 350]];
266
+
267
+ /** The nose: a tiny soft hook, tip only — the glasses own the bridge. It sits
268
+ * lower than v6 (the reference's nose mark tops out at ~55% of eye→mouth,
269
+ * not 39%), which is also what gives the taller lenses their clearance. */
270
+ const NOSE_D = taper(
271
+ [[CX + 3, 439], [CX - 2, 448], [CX - 6, 456], [CX - 4, 462],
272
+ [CX + 1, 465], [CX + 7, 463], [CX + 10, 458]],
273
+ [1.5, 5.5, 2]
274
+ );
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Static art: torso — white crew tee under an open orange overshirt.
278
+ //
279
+ // Three fills in order: the tee (paper, edge-to-edge under everything), then
280
+ // the two overshirt panels (accent) whose inner edges make the open V. Both
281
+ // panels keep the house two-run shoulder: outer arm edge, hard acromion turn,
282
+ // near-horizontal trapezius shelf. Everything overshoots the frame.
283
+ // ---------------------------------------------------------------------------
284
+ const TEE = [
285
+ [288, 960],
286
+ [290, 860], [292, 760], [296, 688],
287
+ [300, 656], [318, 636], [352, 626],
288
+ [364, 623], [396, 623], [408, 625],
289
+ [442, 634], [460, 658], [464, 688],
290
+ [468, 760], [470, 860], [472, 960],
291
+ [410, 960], [348, 960], [288, 960],
292
+ ];
293
+ const TEE_NECK = [[318, 640], [338, 652], [358, 658], [380, 660],
294
+ [402, 657], [422, 650], [440, 638]];
295
+
296
+ // The shoulder line sits high — chin to trapezius is ~75 units. v1 had it 60
297
+ // lower and the neck read as a column; the reference's neck is short.
298
+ const PANEL_L = [
299
+ [302, 960],
300
+ [306, 890], [308, 810], [314, 742],
301
+ [318, 710], [322, 676], [318, 638],
302
+ [298, 634], [264, 644], [232, 656],
303
+ [186, 672], [132, 702], [106, 748],
304
+ [72, 814], [60, 882], [58, 960],
305
+ [180, 960], [240, 960], [302, 960],
306
+ ];
307
+ const PANEL_R = [
308
+ [454, 960],
309
+ [450, 890], [448, 810], [442, 742],
310
+ [438, 710], [434, 676], [438, 636],
311
+ [458, 632], [492, 642], [524, 654],
312
+ [570, 670], [624, 700], [650, 746],
313
+ [684, 812], [696, 882], [698, 960],
314
+ [620, 960], [540, 960], [454, 960],
315
+ ];
316
+
317
+ // Ink runs for the panels: outer silhouette from under the collar over the
318
+ // shoulder and down the arm; inner run down the open front edge.
319
+ const PANEL_L_OUT = [
320
+ [318, 638],
321
+ [298, 634], [264, 644], [232, 656],
322
+ [186, 672], [132, 702], [106, 748],
323
+ [72, 814], [60, 882], [58, 950],
324
+ ];
325
+ const PANEL_L_IN = [[318, 638], [322, 676], [318, 710], [314, 742],
326
+ [308, 810], [306, 890], [304, 950]];
327
+ const PANEL_R_OUT = [
328
+ [438, 636],
329
+ [458, 632], [492, 642], [524, 654],
330
+ [570, 670], [624, 700], [650, 746],
331
+ [684, 812], [696, 882], [698, 950],
332
+ ];
333
+ const PANEL_R_IN = [[438, 636], [434, 676], [438, 710], [442, 742],
334
+ [448, 810], [450, 890], [452, 950]];
335
+ const PANEL_W = [7, 8, 7, 8, 7, 6];
336
+
337
+ // The collar: a down-pointing folded flap hugging each side of the neck —
338
+ // v1's up-pointing lapel triangles read as horns. The lower vertex is a
339
+ // single sharp point so the notch against the shoulder reads as tailoring,
340
+ // not a blob. Not mirrored; the reference's collar is not symmetric and
341
+ // neither is any worn one.
342
+ const COLLAR_L = [[338, 600], [316, 604], [298, 624], [288, 648],
343
+ [296, 672], [310, 686], [324, 662],
344
+ [330, 640], [335, 618], [338, 600]];
345
+ const COLLAR_R = [[424, 598], [446, 602], [464, 622], [474, 646],
346
+ [466, 670], [452, 684], [438, 660],
347
+ [432, 638], [427, 616], [424, 598]];
348
+
349
+ // Folds where the cloth is actually pulled, tapered to nothing at both ends:
350
+ // out of the collar roll toward each armpit, and off the shoulder seam down
351
+ // the sleeve. Few and curved — v6 scattered short parallel ticks and they
352
+ // read as scratches on the garment, not drape.
353
+ const CREASES = [
354
+ { p: [[300, 692], [284, 722], [272, 756], [268, 788]], w: [1, 4.5, 1] },
355
+ { p: [[150, 700], [168, 742], [178, 788], [180, 830]], w: [1.5, 5, 1.5] },
356
+ { p: [[118, 742], [134, 776], [143, 810], [144, 838]], w: [1, 3.5, 1] },
357
+ { p: [[464, 690], [478, 722], [488, 758], [491, 792]], w: [1, 4.5, 1] },
358
+ { p: [[610, 698], [594, 740], [585, 786], [586, 828]], w: [1.5, 5, 1.5] },
359
+ { p: [[644, 752], [630, 786], [621, 816], [619, 842]], w: [1, 3.5, 1] },
360
+ ];
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Layers: the house four, same order, same roles.
364
+ // ---------------------------------------------------------------------------
365
+ const PARALLAX = { head: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
366
+ const LAYERS = ['head', 'body', 'features', 'hair'];
367
+ const PIVOT = { x: CX, y: 700 };
368
+
369
+ const POSE = {
370
+ leanTravel: 23, leanPivot: { x: PIVOT.x, y: 560 },
371
+ shrugLift: 30, shrugTiltDeg: 1.7, shrugPivot: { x: PIVOT.x, y: 800 },
372
+ // Under peep's 28 for the same reason as wren: a big hair mass at full
373
+ // parallax slides like a wig.
374
+ yawPx: 26, pitchPx: 16,
375
+ pivot: PIVOT,
376
+ breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
377
+ turnPx: 16,
378
+ layers: LAYERS, parallax: PARALLAX,
379
+ torsoLayers: ['body'],
380
+ units: 1,
381
+ };
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Generators: mouth — the proven aperture-contour model with myna's lips.
385
+ //
386
+ // See face-peep.js for the full derivation (aperture-not-centreline, the 0.18
387
+ // ramp, 3:1 downward opening, the 0.75 cubic solve). Myna's additions: a
388
+ // 9-stop ring profile whose top centre dips (the cupid's bow) and whose lower
389
+ // centre swells (the fuller lip), and an under-lip shadow drawn off the
390
+ // contour bottom in apply().
391
+ // ---------------------------------------------------------------------------
392
+ // v8: both bands up ~15% from v7 — at a 130 px tile the closed smile was
393
+ // merely *present*; the floor raise is what makes it read as smiling there.
394
+ const LIP_UP = 10.5; // upper lip band thickness at its fullest
395
+ const LIP_LO = 15; // lower lip band at centre
396
+
397
+ function mouthGeometry(p) {
398
+ const cx = MOUTH.cx;
399
+ const cy = MOUTH.cy;
400
+ const open = clamp(p.mouthOpen);
401
+ const round = clamp(p.mouthRound);
402
+ const tuck = clamp(p.mouthTuck);
403
+ const press = clamp(p.mouthPress);
404
+
405
+ const w = (28 + clamp(p.mouthWidth) * 30) * (1 - 0.36 * round);
406
+
407
+ // Press changes SHAPE, not just weight. A bilabial closure (viseme A,
408
+ // press .55) must render as a pressed band — corners neutralized, bow
409
+ // scallop ironed flat, thickness nearly kept — or A is indistinguishable
410
+ // from idle X and lipsync reads mushy however good the timing is. The
411
+ // pressed band is the anchor viewers use to verify sync.
412
+ const t = 1 - 0.18 * press;
413
+ // Nine stops around the ring: s=0 left corner, 0.25 top centre, 0.5 right
414
+ // corner, 0.75 bottom centre. The dip at 0.25 is the bow notch (a close-up
415
+ // detail, deliberately light); press irons it up to band thickness.
416
+ const bow = lerp(5, LIP_UP * 0.92, clamp(press * 1.6));
417
+ const lo = LIP_LO * t * (1 + 0.35 * tuck);
418
+ const profile = [
419
+ 3 * t, LIP_UP * t, bow * t, LIP_UP * t, 3.5 * t,
420
+ 11.5 * t, lo, 11.5 * t, 3 * t,
421
+ ];
422
+ const halfUp = (LIP_UP * t) / 2;
423
+ const halfLo = lo / 2;
424
+
425
+ const h = open * MOUTH_APERTURE;
426
+ const k = clamp(open / 0.18);
427
+
428
+ // Corner travel 28 (was 23): the tile swallows less. Press straightens the
429
+ // band by pulling the corner lift toward zero.
430
+ const cLift = 1 - 0.85 * press;
431
+ const yL = cy - (6 + p.mouthCornerL * 28) * cLift;
432
+ const yR = cy - (6 + p.mouthCornerR * 28) * cLift;
433
+
434
+ const apTop = cy - h * 0.25;
435
+ let apBot = cy + h * 0.75;
436
+ if (tuck > 0) apBot = Math.max(apTop + 6, apBot - tuck * (h * 0.6 + 4));
437
+
438
+ const cornerMid = (yL + yR) / 8;
439
+ const topY = (apTop - k * halfUp - cornerMid) / 0.75;
440
+ const botY = (apBot + k * halfLo - cornerMid) / 0.75;
441
+
442
+ const contour = [
443
+ [cx - w, yL],
444
+ [cx - w * 0.55, topY], [cx + w * 0.55, topY], [cx + w, yR],
445
+ [cx + w * 0.55, botY], [cx - w * 0.55, botY], [cx - w, yL],
446
+ ];
447
+
448
+ const innerTop = cornerMid + 0.75 * topY + halfUp;
449
+ const innerBot = cornerMid + 0.75 * botY - halfLo;
450
+
451
+ return { contour, profile, cx, cy, w, h, topY, botY, innerTop, innerBot, open, tuck };
452
+ }
453
+
454
+ /** peep's dental-arch teeth, myna's width factors. */
455
+ function teethPath(m, amt, lower) {
456
+ if (amt < 0.01) return '';
457
+ const gap = m.innerBot - m.innerTop;
458
+ if (gap < 2) return '';
459
+ const tw = m.w * (lower ? 0.6 : 0.76);
460
+ const cap = lower ? 0.5 : 0.5 + 0.35 * m.tuck;
461
+ const th = Math.min(amt * (lower ? 13 : 20), gap * cap);
462
+
463
+ if (lower) {
464
+ const base = m.innerBot + 8;
465
+ const edge = m.innerBot - th;
466
+ const end = m.innerBot - th * 0.35;
467
+ return (
468
+ `M${f(m.cx - tw)} ${f(base)}L${f(m.cx + tw)} ${f(base)}` +
469
+ `L${f(m.cx + tw * 0.92)} ${f(end)}` +
470
+ `Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
471
+ );
472
+ }
473
+ const top = m.innerTop - 8;
474
+ const edge = m.innerTop + th;
475
+ const end = m.innerTop + th * 0.35;
476
+ return (
477
+ `M${f(m.cx - tw)} ${f(top)}L${f(m.cx + tw)} ${f(top)}` +
478
+ `L${f(m.cx + tw * 0.92)} ${f(end)}` +
479
+ `Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
480
+ );
481
+ }
482
+
483
+ // Eyes: the bean model, near-round — the reference's pupils are big and dark
484
+ // and do most of the face's expressive work with the lashes.
485
+
486
+ /** Top-lid height for a given lid value — shared by bean and lash so they can
487
+ * never disagree. The mapping is a 0.6-power, not linear: the mixer's rest
488
+ * lid is 0.12 (params.js: real neutral eyes graze the iris) and on beans
489
+ * this big a linear map spent only 7% of the travel by then — rest rendered
490
+ * as a wide-eyed stare, a long-session comfort failure. The power curve
491
+ * spends ~28% by 0.12 (a visible flat on the bean top: attentive-relaxed,
492
+ * not sleepy) while a true wide (curious sends lid −0.10 → clamps to ~0)
493
+ * still rounds fully open, and closed is unchanged. */
494
+ function lidTopY(cy, lid) {
495
+ return lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, Math.pow(clamp(lid), 0.6));
496
+ }
497
+
498
+ function eyePath(cx, cy, lid, squint, tiltDeg) {
499
+ const L = clamp(lid);
500
+ const topY = lidTopY(cy, lid);
501
+ // Squint gain .95 (was .7): the smile-squint is most of what separates warm
502
+ // from neutral at tile size.
503
+ const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.95;
504
+ const rx = EYE.rx;
505
+ const a = (tiltDeg * Math.PI) / 180;
506
+ const ca = Math.cos(a), sa = Math.sin(a);
507
+ const R = (x, y) => {
508
+ const dx = x - cx, dy = y - cy;
509
+ return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
510
+ };
511
+ return (
512
+ `M${R(cx - rx, cy)}` +
513
+ `C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
514
+ `C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
515
+ );
516
+ }
517
+
518
+ /**
519
+ * The upper lash line: lies ON the bean's top edge and ends in a small
520
+ * outward flick at the outer corner. The body samples the bean's own top
521
+ * cubic (controls at ±rx/2, topY, so y(u) = 3u(1−u)·(topY−cy)) rather than
522
+ * approximating it — v6 floated a straight slash near the lens top and it
523
+ * read as a second, angrier brow inside the glasses. Same lid value as the
524
+ * bean, so a blink carries the lash down with the closing lid; the flick is
525
+ * anchored at the corner, which the lid never moves.
526
+ * `dir` is −1 for the left eye (flick outward-left), +1 for the right.
527
+ */
528
+ function lashPath(cx, cy, lid, dir) {
529
+ const topY = lidTopY(cy, lid);
530
+ const rx = EYE.rx;
531
+ const at = (u) => {
532
+ const v = 1 - u;
533
+ const x = v * v * v * -rx + 3 * v * v * u * -rx * 0.5
534
+ + 3 * v * u * u * rx * 0.5 + u * u * u * rx;
535
+ // Proud of the edge by 1.5 so the liner shows against the dark bean.
536
+ return [cx + dir * x, cy + 3 * v * u * (topY - cy) - 1.5];
537
+ };
538
+ const pts = [
539
+ at(0.3), at(0.5), at(0.7), at(0.88), at(1),
540
+ [cx + dir * (rx + 5), cy - 4.5],
541
+ [cx + dir * (rx + 9), cy - 9],
542
+ ];
543
+ return taper(pts, [2.5, 4, 3, 1.5], 6);
544
+ }
545
+
546
+ // Brows: the point-list deformation. Travel is ASYMMETRIC by design:
547
+ // - Raises land at 20 (v7: 16) — the minimal-face lesson says small deltas
548
+ // vanish, and at a 130 px tile the emotion poses collapsed below this.
549
+ // - Descent is capped at 8.5 and weighted toward the inner half (corrugator
550
+ // pull). The cap is the frame-clearance envelope: the arch points that sit
551
+ // over the lens's top bar (y≈319–322 at rest) must keep ≥3.5 units of air
552
+ // above the bar's ink at browRaise −1, or the brow merges with the frame
553
+ // and brows-down — concentration — silently dies. The inner weighting also
554
+ // keeps the outer tail off the frame's corner arc. What travel can't carry,
555
+ // thickness does: a knitted brow bulks up 30% at full descent.
556
+ function browPath(pts, raise, angle, inner) {
557
+ const n = pts.length - 1;
558
+ const up = Math.max(0, raise);
559
+ const dn = Math.max(0, -raise);
560
+ const out = pts.map(([x, y], i) => {
561
+ const u = i / n;
562
+ return [x, y - up * 20 + dn * 8.5 * (1 - 0.45 * u)
563
+ - inner * 15 * (1 - u) - angle * 16 * u];
564
+ });
565
+ const wk = 1 + 0.3 * dn;
566
+ return taper(out, [3 * wk, 12 * wk, 4 * wk], 6);
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // Static markup
571
+ // ---------------------------------------------------------------------------
572
+ const HEAD_FILL = region(HEAD);
573
+ const HEAD_RING = taperRing(HEAD, HEAD_W, 10);
574
+
575
+ function markup(id, t) {
576
+ const ink = (d) => `<path d="${d}" fill="${t.ink}"/>`;
577
+ const paper = (d) => `<path d="${d}" fill="${t.paper}"/>`;
578
+ return `
579
+ <svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
580
+ preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
581
+ <defs>
582
+ <radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
583
+ <stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
584
+ </radialGradient>
585
+ <clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
586
+ </defs>
587
+
588
+ <rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
589
+
590
+ <!-- head and neck; hair underlay at head parallax is the anti-sliver
591
+ insurance AND the backfill behind the hair-layer ears (see header). -->
592
+ <g id="${id}-head">
593
+ <path d="${NECK_FILL}" fill="${t.paper}"/>
594
+ ${ink(taper(NECK_L, [3, 7, 5]))}
595
+ ${ink(taper(NECK_R, [3, 7, 5]))}
596
+ <path d="${HEAD_FILL}" fill="${t.paper}"/>
597
+ ${ink(HEAD_RING)}
598
+ ${ink(taper(JAW_UNDER, [2, 5, 2]))}
599
+ <path d="${HAIR_D}" fill="${t.ink}"/>
600
+ </g>
601
+
602
+ <!-- tee, then the two overshirt panels and their tailoring -->
603
+ <g id="${id}-body">
604
+ ${paper(region(TEE))}
605
+ ${ink(taper(TEE_NECK, [3, 5.5, 3]))}
606
+ <path d="${region(PANEL_L)}" fill="${t.accent}"/>
607
+ <path d="${region(PANEL_R)}" fill="${t.accent}"/>
608
+ ${ink(taper(PANEL_L_OUT, PANEL_W))}
609
+ ${ink(taper(PANEL_R_OUT, PANEL_W))}
610
+ ${ink(taper(PANEL_L_IN, [6, 7, 5]))}
611
+ ${ink(taper(PANEL_R_IN, [6, 7, 5]))}
612
+ <path d="${region(COLLAR_L)}" fill="${t.accent}"/>
613
+ <path d="${region(COLLAR_R)}" fill="${t.accent}"/>
614
+ ${ink(taperRing(COLLAR_L, [3, 4.5, 3], 6))}
615
+ ${ink(taperRing(COLLAR_R, [3, 4.5, 3], 6))}
616
+ ${CREASES.map((c) => ink(taper(c.p, c.w))).join('\n ')}
617
+ </g>
618
+
619
+ <!-- features: brows, eyes+lashes, nose, glasses, mouth -->
620
+ <g id="${id}-features">
621
+ <path id="${id}-browL" fill="${t.ink}"/>
622
+ <path id="${id}-browR" fill="${t.ink}"/>
623
+ <g id="${id}-eyes">
624
+ <path id="${id}-eyeL" fill="${t.ink}"/>
625
+ <path id="${id}-eyeR" fill="${t.ink}"/>
626
+ <path id="${id}-lashL" fill="${t.ink}"/>
627
+ <path id="${id}-lashR" fill="${t.ink}"/>
628
+ </g>
629
+ <path d="${NOSE_D}" fill="${t.ink}"/>
630
+ ${ink(taperRing(LENS_L, RING_W, 6))}
631
+ ${ink(taperRing(LENS_R, RING_W, 6))}
632
+ ${ink(taper(BRIDGE, [3, 4, 3]))}
633
+ ${ink(taper(TEMPLE_L, [3, 3.5, 4]))}
634
+ ${ink(taper(TEMPLE_R, [3, 3.5, 4]))}
635
+ <g id="${id}-mouth">
636
+ <path id="${id}-mouthIn" fill="${t.mouthIn}"/>
637
+ <g clip-path="url(#${id}-clipMouth)">
638
+ <ellipse id="${id}-tongue" fill="${t.tongue}"/>
639
+ <path id="${id}-teeth" fill="${t.teeth}"/>
640
+ <path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
641
+ </g>
642
+ <path id="${id}-lips" fill="${t.ink}"/>
643
+ </g>
644
+ </g>
645
+
646
+ <!-- hair: the mass, its highlights, then ears and hoops on top -->
647
+ <g id="${id}-hair">
648
+ <path d="${HAIR_D}" fill="${t.ink}"/>
649
+ ${SHINE.map((s) => paper(taper(s.p, s.w, 6))).join('\n ')}
650
+ <path d="${region(EAR_L)}" fill="${t.paper}"/>
651
+ <path d="${region(EAR_R)}" fill="${t.paper}"/>
652
+ ${ink(taper(EAR_L, [3, 6, 3]))}
653
+ ${ink(taper(EAR_R, [3, 6, 3]))}
654
+ ${ink(taper(EAR_L_IN, [2, 4, 2]))}
655
+ ${ink(taper(EAR_R_IN, [2, 4, 2]))}
656
+ <path d="${HOOP_L}" fill="${t.accent}"/>
657
+ <path d="${HOOP_R}" fill="${t.accent}"/>
658
+ </g>
659
+ </svg>`;
660
+ }
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // Renderer — the invariant apply() shape; only the travels and the extra lip
664
+ // marks are myna's own.
665
+ // ---------------------------------------------------------------------------
666
+ let uid = 0;
667
+
668
+ export function createFace(mount, theme = {}) {
669
+ const t = Object.assign({}, THEME, theme);
670
+ const id = `myna${++uid}`;
671
+ const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
672
+
673
+ const el = {
674
+ head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
675
+ browL: $('browL'), browR: $('browR'),
676
+ eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
677
+ lashL: $('lashL'), lashR: $('lashR'),
678
+ mouthIn: $('mouthIn'), lips: $('lips'),
679
+ clipMouth: $('clipMouthP'),
680
+ teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
681
+ };
682
+
683
+ function apply(p) {
684
+ poseTransforms(p, set, el, POSE);
685
+
686
+ // Re-derived for the big lenses: outboard the bean edge at full travel is
687
+ // still ~24 units clear of the rim (56+14+15 vs the 109-unit interior
688
+ // edge), inboard ~16; vertically the 38-unit half-interior leaves ~15.
689
+ // Past these the pupil starts reading pressed against the frame.
690
+ set(el.eyes, 'transform', `translate(${f(p.pupilX * 14)} ${f(p.pupilY * 10)})`);
691
+
692
+ const lidFollow = Math.max(0, p.pupilY) * 0.22;
693
+ const lidL = p.lidL + lidFollow;
694
+ const lidR = p.lidR + lidFollow;
695
+ // Both beans exactly on EYE.y: a ±1 stagger here read as head tilt at
696
+ // rest, and rest must be level (see BROW_R).
697
+ set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y, lidL, p.squintL, -3));
698
+ set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y, lidR, p.squintR, 3));
699
+ set(el.lashL, 'd', lashPath(CX - EYE.dx, EYE.y, lidL, -1));
700
+ set(el.lashR, 'd', lashPath(CX + EYE.dx, EYE.y, lidR, 1));
701
+
702
+ set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
703
+ set(el.browR, 'd', browPath(BROW_R, p.browRaiseR, p.browAngleR, p.browInnerR));
704
+
705
+ const m = mouthGeometry(p);
706
+ const contour = region(m.contour);
707
+ set(el.mouthIn, 'd', contour);
708
+ set(el.clipMouth, 'd', contour);
709
+ set(el.lips, 'd', taperRing(m.contour, m.profile, 12));
710
+ // Keyed off the drawn gap, not mouthOpen — grey is a rendering fault on a
711
+ // two-value face (peep's F bug).
712
+ set(el.mouthIn, 'opacity', f(clamp((m.innerBot - m.innerTop) / 3)));
713
+
714
+ pairedTeeth(p, set, el, teethPath, m);
715
+
716
+ const tg = clamp(p.tongue);
717
+ set(el.tongue, 'cx', f(m.cx));
718
+ set(el.tongue, 'cy', f(m.innerBot + 6 - tg * ((m.innerBot - m.innerTop) * 0.8 + 6)));
719
+ set(el.tongue, 'rx', f(m.w * 0.58));
720
+ set(el.tongue, 'ry', '8');
721
+ set(el.tongue, 'opacity', tg > 0.02 ? '1' : '0');
722
+ }
723
+
724
+ return faceApi(mount, svg, apply, t);
725
+ }