angular-avatar-player 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/fesm2022/angular-avatar-player.mjs +1664 -0
- package/fesm2022/angular-avatar-player.mjs.map +1 -0
- package/index.d.ts +20 -0
- package/lib/avatar-player.module.d.ts +9 -0
- package/lib/components/avatar-player/avatar-player.component.d.ts +28 -0
- package/lib/components/svg-avatar/svg-avatar.component.d.ts +52 -0
- package/lib/geometry/avatar-animator.d.ts +36 -0
- package/lib/geometry/avatar-renderer.d.ts +64 -0
- package/lib/geometry/avatar.model.d.ts +24 -0
- package/lib/geometry/eye-colors.d.ts +2 -0
- package/lib/geometry/hair-colors.d.ts +2 -0
- package/lib/geometry/index.d.ts +46 -0
- package/lib/geometry/lip-sync-player.d.ts +16 -0
- package/lib/geometry/skin-tones.d.ts +21 -0
- package/lib/geometry/svg-parts/beard-path.d.ts +16 -0
- package/lib/geometry/svg-parts/beard-shapes.d.ts +2 -0
- package/lib/geometry/svg-parts/eye-shapes.d.ts +7 -0
- package/lib/geometry/svg-parts/glasses-shapes.d.ts +2 -0
- package/lib/geometry/svg-parts/hair-shapes.d.ts +7 -0
- package/lib/geometry/svg-parts/mouth-shapes.d.ts +1 -0
- package/lib/geometry/svg-parts/mustache-shapes.d.ts +2 -0
- package/lib/geometry/svg-parts/profession-layers.d.ts +7 -0
- package/lib/geometry/trait-previews.d.ts +2 -0
- package/lib/geometry/visemes.d.ts +19 -0
- package/lib/services/avatar-animation.service.d.ts +14 -0
- package/lib/services/lip-sync.service.d.ts +13 -0
- package/package.json +48 -0
|
@@ -0,0 +1,1664 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Injectable, input, inject, ElementRef, NgZone, signal, computed, effect, ViewEncapsulation, Component, Input, NgModule } from '@angular/core';
|
|
3
|
+
import { CommonModule } from '@angular/common';
|
|
4
|
+
import { DomSanitizer } from '@angular/platform-browser';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* - `base` — main face fill
|
|
8
|
+
* - `ear` — secondary tone for ears
|
|
9
|
+
* - `lip` — lips
|
|
10
|
+
* - `shadow` — a THIRD tone reserved for small linework (nose stroke).
|
|
11
|
+
*
|
|
12
|
+
* `shadow` used to be conflated with `ear`, which made the nose vanish
|
|
13
|
+
* on the two darkest tones: `deep` ear is #3C200E against a #4A2912 face,
|
|
14
|
+
* far too close to read as a line. `shadow` is always a clear step darker
|
|
15
|
+
* than `ear` so it stays legible at the 2px stroke width we use.
|
|
16
|
+
*
|
|
17
|
+
* The style guide allows at most two tones per *region*. The nose is its
|
|
18
|
+
* own region, so a dedicated third value is compliant.
|
|
19
|
+
*/
|
|
20
|
+
const SKIN_TONES = {
|
|
21
|
+
light: { base: '#FDDBB4', ear: '#F5C89A', lip: '#E8967A', shadow: '#E0A473' },
|
|
22
|
+
medium: { base: '#F0AC78', ear: '#E09A62', lip: '#C97050', shadow: '#C67C45' },
|
|
23
|
+
tan: { base: '#D4845A', ear: '#C0724A', lip: '#A8533A', shadow: '#A15532' },
|
|
24
|
+
dark: { base: '#8D5524', ear: '#7A4820', lip: '#6B3A18', shadow: '#5E3312' },
|
|
25
|
+
deep: { base: '#4A2912', ear: '#3C200E', lip: '#5C2E18', shadow: '#291505' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const HAIR_COLORS = {
|
|
29
|
+
black: '#1A1A2E',
|
|
30
|
+
brown: '#6B3A2A',
|
|
31
|
+
blonde: '#E8C96E',
|
|
32
|
+
red: '#C0392B',
|
|
33
|
+
gray: '#9E9E9E',
|
|
34
|
+
white: '#F0F0F0',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const EYE_COLORS = {
|
|
38
|
+
brown: '#6B3A2A',
|
|
39
|
+
blue: '#2980B9',
|
|
40
|
+
green: '#27AE60',
|
|
41
|
+
gray: '#7F8C8D',
|
|
42
|
+
black: '#1A1A2E',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/hair-shapes.ts/svg-parts/hair-shapes.ts
|
|
46
|
+
// 7 haircut styles: short, long, curly, bald, bun, ponytail, mohawk
|
|
47
|
+
// Each provides back (rendered behind face) + front (rendered on top)
|
|
48
|
+
// All shapes: solid fill, no gradients, no strokes > 2.5, flat silhouettes
|
|
49
|
+
// Colors use CSS var: fill="var(--hair-color)"
|
|
50
|
+
// Face ellipse: cx=100 cy=88 rx=52 ry=56 → top of head ~y=32
|
|
51
|
+
const HAIR_SHAPES = {
|
|
52
|
+
short: {
|
|
53
|
+
back: `
|
|
54
|
+
<ellipse cx="100" cy="62" rx="54" ry="34" fill="var(--hair-color)"/>`,
|
|
55
|
+
front: `
|
|
56
|
+
<path d="M48,75 Q48,32 100,28 Q152,32 152,75 Q140,58 100,54 Q60,58 48,75 Z"
|
|
57
|
+
fill="var(--hair-color)"/>`,
|
|
58
|
+
},
|
|
59
|
+
long: {
|
|
60
|
+
back: `
|
|
61
|
+
<path d="M44,70 Q44,28 100,22 Q156,28 156,70 L156,140 Q156,150 148,150
|
|
62
|
+
L52,150 Q44,150 44,140 Z"
|
|
63
|
+
fill="var(--hair-color)"/>`,
|
|
64
|
+
front: `
|
|
65
|
+
<path d="M46,78 Q46,28 100,22 Q154,28 154,78 Q142,54 100,48 Q58,54 46,78 Z"
|
|
66
|
+
fill="var(--hair-color)"/>
|
|
67
|
+
<path d="M46,78 L46,110 Q48,95 52,85 Z" fill="var(--hair-color)"/>
|
|
68
|
+
<path d="M154,78 L154,110 Q152,95 148,85 Z" fill="var(--hair-color)"/>`,
|
|
69
|
+
},
|
|
70
|
+
curly: {
|
|
71
|
+
back: `
|
|
72
|
+
<path d="M42,80 Q38,30 100,22 Q162,30 158,80 L160,135 Q155,145 148,140
|
|
73
|
+
Q142,148 132,142 Q122,150 112,142 Q102,148 92,142 Q82,150 72,142
|
|
74
|
+
Q62,148 52,140 Q45,145 40,135 Z"
|
|
75
|
+
fill="var(--hair-color)"/>`,
|
|
76
|
+
front: `
|
|
77
|
+
<path d="M44,82 Q42,30 100,22 Q158,30 156,82
|
|
78
|
+
Q148,60 130,50 Q100,42 70,50 Q52,60 44,82 Z"
|
|
79
|
+
fill="var(--hair-color)"/>
|
|
80
|
+
<circle cx="44" cy="88" r="8" fill="var(--hair-color)"/>
|
|
81
|
+
<circle cx="156" cy="88" r="8" fill="var(--hair-color)"/>
|
|
82
|
+
<circle cx="48" cy="100" r="7" fill="var(--hair-color)"/>
|
|
83
|
+
<circle cx="152" cy="100" r="7" fill="var(--hair-color)"/>`,
|
|
84
|
+
},
|
|
85
|
+
bald: {
|
|
86
|
+
back: '',
|
|
87
|
+
front: '',
|
|
88
|
+
},
|
|
89
|
+
bun: {
|
|
90
|
+
back: `
|
|
91
|
+
<circle cx="100" cy="24" r="18" fill="var(--hair-color)"/>
|
|
92
|
+
<ellipse cx="100" cy="60" rx="54" ry="32" fill="var(--hair-color)"/>`,
|
|
93
|
+
front: `
|
|
94
|
+
<path d="M48,75 Q48,32 100,28 Q152,32 152,75 Q140,56 100,52 Q60,56 48,75 Z"
|
|
95
|
+
fill="var(--hair-color)"/>`,
|
|
96
|
+
},
|
|
97
|
+
ponytail: {
|
|
98
|
+
back: `
|
|
99
|
+
<ellipse cx="100" cy="62" rx="54" ry="34" fill="var(--hair-color)"/>
|
|
100
|
+
<path d="M130,42 Q158,40 162,60 Q166,90 158,130 Q154,142 148,140
|
|
101
|
+
Q152,120 150,80 Q148,50 130,42 Z"
|
|
102
|
+
fill="var(--hair-color)"/>`,
|
|
103
|
+
front: `
|
|
104
|
+
<path d="M48,75 Q48,32 100,28 Q152,32 152,75 Q140,56 100,52 Q60,56 48,75 Z"
|
|
105
|
+
fill="var(--hair-color)"/>`,
|
|
106
|
+
},
|
|
107
|
+
mohawk: {
|
|
108
|
+
back: '',
|
|
109
|
+
front: `
|
|
110
|
+
<!-- Shaved sides – faint stubble -->
|
|
111
|
+
<path d="M54,70 Q54,42 100,34 Q146,42 146,70 Q136,56 100,52 Q64,56 54,70 Z"
|
|
112
|
+
fill="var(--hair-color)" opacity="0.12"/>
|
|
113
|
+
<!-- Mohawk crest – 5 pointed spikes -->
|
|
114
|
+
<path d="M84,64 Q84,48 86,40
|
|
115
|
+
L80,20 L89,36
|
|
116
|
+
L86,6 L95,30
|
|
117
|
+
L100,2 L105,30
|
|
118
|
+
L114,6 L111,36
|
|
119
|
+
L120,20 L114,40
|
|
120
|
+
Q116,48 116,64
|
|
121
|
+
Q108,54 100,52 Q92,54 84,64 Z"
|
|
122
|
+
fill="var(--hair-color)"/>`,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/eye-shapes.ts/svg-parts/eye-shapes.ts
|
|
127
|
+
// 4 eye styles: round, almond, wide, narrow
|
|
128
|
+
// Each style provides sclera + iris SVG strings
|
|
129
|
+
// Eyes are positioned at cx=74 (left) and cx=126 (right), cy=88
|
|
130
|
+
// Sclera r≈11, iris r≈9, pupil r≈5 — large eyes per style guide
|
|
131
|
+
// Colors use CSS vars: fill="var(--eye-color)"
|
|
132
|
+
const EYE_SHAPES = {
|
|
133
|
+
round: {
|
|
134
|
+
sclera: `
|
|
135
|
+
<circle cx="74" cy="88" r="11" fill="#FFF"/>
|
|
136
|
+
<circle cx="126" cy="88" r="11" fill="#FFF"/>`,
|
|
137
|
+
iris: `
|
|
138
|
+
<circle cx="74" cy="88" r="9" fill="var(--eye-color)"/>
|
|
139
|
+
<circle cx="74" cy="88" r="5" fill="#1A1A2E"/>
|
|
140
|
+
<circle cx="77" cy="85" r="2" fill="#FFF"/>
|
|
141
|
+
<circle cx="126" cy="88" r="9" fill="var(--eye-color)"/>
|
|
142
|
+
<circle cx="126" cy="88" r="5" fill="#1A1A2E"/>
|
|
143
|
+
<circle cx="129" cy="85" r="2" fill="#FFF"/>`,
|
|
144
|
+
},
|
|
145
|
+
almond: {
|
|
146
|
+
sclera: `
|
|
147
|
+
<ellipse cx="74" cy="88" rx="12" ry="9" fill="#FFF"/>
|
|
148
|
+
<ellipse cx="126" cy="88" rx="12" ry="9" fill="#FFF"/>`,
|
|
149
|
+
iris: `
|
|
150
|
+
<circle cx="74" cy="88" r="8" fill="var(--eye-color)"/>
|
|
151
|
+
<circle cx="74" cy="88" r="5" fill="#1A1A2E"/>
|
|
152
|
+
<circle cx="77" cy="85" r="2" fill="#FFF"/>
|
|
153
|
+
<circle cx="126" cy="88" r="8" fill="var(--eye-color)"/>
|
|
154
|
+
<circle cx="126" cy="88" r="5" fill="#1A1A2E"/>
|
|
155
|
+
<circle cx="129" cy="85" r="2" fill="#FFF"/>`,
|
|
156
|
+
},
|
|
157
|
+
wide: {
|
|
158
|
+
sclera: `
|
|
159
|
+
<ellipse cx="74" cy="88" rx="13" ry="11" fill="#FFF"/>
|
|
160
|
+
<ellipse cx="126" cy="88" rx="13" ry="11" fill="#FFF"/>`,
|
|
161
|
+
iris: `
|
|
162
|
+
<circle cx="74" cy="88" r="9" fill="var(--eye-color)"/>
|
|
163
|
+
<circle cx="74" cy="88" r="5" fill="#1A1A2E"/>
|
|
164
|
+
<circle cx="77" cy="85" r="2.5" fill="#FFF"/>
|
|
165
|
+
<circle cx="126" cy="88" r="9" fill="var(--eye-color)"/>
|
|
166
|
+
<circle cx="126" cy="88" r="5" fill="#1A1A2E"/>
|
|
167
|
+
<circle cx="129" cy="85" r="2.5" fill="#FFF"/>`,
|
|
168
|
+
},
|
|
169
|
+
narrow: {
|
|
170
|
+
sclera: `
|
|
171
|
+
<ellipse cx="74" cy="88" rx="12" ry="7" fill="#FFF"/>
|
|
172
|
+
<ellipse cx="126" cy="88" rx="12" ry="7" fill="#FFF"/>`,
|
|
173
|
+
iris: `
|
|
174
|
+
<circle cx="74" cy="88" r="7" fill="var(--eye-color)"/>
|
|
175
|
+
<circle cx="74" cy="88" r="4" fill="#1A1A2E"/>
|
|
176
|
+
<circle cx="77" cy="86" r="1.5" fill="#FFF"/>
|
|
177
|
+
<circle cx="126" cy="88" r="7" fill="var(--eye-color)"/>
|
|
178
|
+
<circle cx="126" cy="88" r="4" fill="#1A1A2E"/>
|
|
179
|
+
<circle cx="129" cy="86" r="1.5" fill="#FFF"/>`,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/mustache-shapes.ts/svg-parts/mustache-shapes.ts
|
|
184
|
+
// 5 mustache styles: none, thin, thick, handlebar, chevron
|
|
185
|
+
// Positioned below nose (~y=112) and above mouth (~y=119)
|
|
186
|
+
// Flat fill only, no gradients, stroke-width ≤ 3
|
|
187
|
+
// Color: fill="var(--hair-color)"
|
|
188
|
+
const MUSTACHE_SHAPES = {
|
|
189
|
+
none: '',
|
|
190
|
+
thin: `
|
|
191
|
+
<!-- Thin pencil mustache -->
|
|
192
|
+
<path d="M82,114 Q91,118 100,115 Q109,118 118,114"
|
|
193
|
+
stroke="var(--hair-color)" stroke-width="3" fill="none"
|
|
194
|
+
stroke-linecap="round"/>`,
|
|
195
|
+
thick: `
|
|
196
|
+
<!-- Thick walrus mustache -->
|
|
197
|
+
<path d="M78,111 Q82,112 88,114 Q94,117 100,115
|
|
198
|
+
Q106,117 112,114 Q118,112 122,111
|
|
199
|
+
L124,113 Q120,120 112,122 Q106,123 100,122
|
|
200
|
+
Q94,123 88,122 Q80,120 76,113 Z"
|
|
201
|
+
fill="var(--hair-color)"/>`,
|
|
202
|
+
handlebar: `
|
|
203
|
+
<!-- Handlebar mustache with curled tips -->
|
|
204
|
+
<path d="M80,112 Q90,118 100,115 Q110,118 120,112
|
|
205
|
+
L122,114 Q112,121 100,119 Q88,121 78,114 Z"
|
|
206
|
+
fill="var(--hair-color)"/>
|
|
207
|
+
<!-- Left curl -->
|
|
208
|
+
<path d="M80,113 Q74,110 72,114 Q70,118 73,122"
|
|
209
|
+
stroke="var(--hair-color)" stroke-width="3.5" fill="none"
|
|
210
|
+
stroke-linecap="round"/>
|
|
211
|
+
<!-- Right curl -->
|
|
212
|
+
<path d="M120,113 Q126,110 128,114 Q130,118 127,122"
|
|
213
|
+
stroke="var(--hair-color)" stroke-width="3.5" fill="none"
|
|
214
|
+
stroke-linecap="round"/>`,
|
|
215
|
+
chevron: `
|
|
216
|
+
<!-- Chevron mustache — wide inverted V shape -->
|
|
217
|
+
<path d="M76,111 L88,112 Q94,118 100,116 Q106,118 112,112
|
|
218
|
+
L124,111 L126,113 Q116,122 100,120
|
|
219
|
+
Q84,122 74,113 Z"
|
|
220
|
+
fill="var(--hair-color)"/>`,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/beard-shapes.ts/svg-parts/beard-shapes.ts
|
|
224
|
+
// 5 beard styles: none, stubble, short, long, goatee
|
|
225
|
+
//
|
|
226
|
+
// The outer edge of both full beards follows the face ellipse
|
|
227
|
+
// (cx=100 cy=88 rx=52 ry=56) rather than a hand-typed curve. An earlier
|
|
228
|
+
// version kept its outer edge near x=49 down to y=118, but the face has
|
|
229
|
+
// already narrowed to x=55 by then, so the beard projected past the jaw
|
|
230
|
+
// as a dark wedge on each side.
|
|
231
|
+
//
|
|
232
|
+
// Face half-width by row, which is what the outer curves are matched to:
|
|
233
|
+
// y=96 -> 51.5 y=110 -> 47.9 y=120 -> 45.0
|
|
234
|
+
// y=130 -> 37.2 y=140 -> 26.0 chin -> 0 (y=144)
|
|
235
|
+
//
|
|
236
|
+
// Flat fill only, no gradients, stroke-width ≤ 2.5
|
|
237
|
+
// Color: fill="var(--hair-color)"
|
|
238
|
+
const BEARD_SHAPES = {
|
|
239
|
+
none: '',
|
|
240
|
+
stubble: `
|
|
241
|
+
<!-- 5 o'clock shadow following the jawline. The previous version ran to
|
|
242
|
+
x=76/124 at y=140, where the face half-width is only ~19 (x 81..119),
|
|
243
|
+
so it overhung the jaw by ~5px on each side and had to be cut back by
|
|
244
|
+
the face clip. It now ends at the chin instead of hanging below it. -->
|
|
245
|
+
<path d="M72,114 Q70,126 80,136 Q90,143 100,144 Q110,143 120,136 Q130,126 128,114
|
|
246
|
+
Q122,126 100,132 Q78,126 72,114 Z"
|
|
247
|
+
fill="var(--hair-color)" opacity="0.2"/>`,
|
|
248
|
+
short: `
|
|
249
|
+
<!-- Jaw-hugging beard. Outer edge is inset 3px inside the face ellipse so
|
|
250
|
+
it never projects past the jaw; inner edge sits above it, leaving a
|
|
251
|
+
band that thins at the cheeks and thickens at the chin. -->
|
|
252
|
+
<path d="M52,96
|
|
253
|
+
C52,108 56,120 64,128
|
|
254
|
+
C72,136 86,143 100,143
|
|
255
|
+
C114,143 128,136 136,128
|
|
256
|
+
C144,120 148,108 148,96
|
|
257
|
+
C146,104 140,114 130,121
|
|
258
|
+
C120,128 110,131 100,131
|
|
259
|
+
C90,131 80,128 70,121
|
|
260
|
+
C60,114 54,104 52,96 Z"
|
|
261
|
+
fill="var(--hair-color)"/>`,
|
|
262
|
+
long: `
|
|
263
|
+
<!-- Same jaw-hugging top, then flowing below the chin to a rounded point. -->
|
|
264
|
+
<path d="M52,96
|
|
265
|
+
C52,110 58,126 70,138
|
|
266
|
+
C80,148 90,160 95,172
|
|
267
|
+
C96,176 104,176 105,172
|
|
268
|
+
C110,160 120,148 130,138
|
|
269
|
+
C142,126 148,110 148,96
|
|
270
|
+
C146,104 140,114 130,121
|
|
271
|
+
C120,128 110,131 100,131
|
|
272
|
+
C90,131 80,128 70,121
|
|
273
|
+
C60,114 54,104 52,96 Z"
|
|
274
|
+
fill="var(--hair-color)"/>`,
|
|
275
|
+
goatee: `
|
|
276
|
+
<!-- Soul patch -->
|
|
277
|
+
<ellipse cx="100" cy="126" rx="4" ry="2.5" fill="var(--hair-color)"/>
|
|
278
|
+
<!-- Compact chin goatee -->
|
|
279
|
+
<path d="M88,130 Q86,140 92,147 Q96,152 100,153 Q104,152 108,147 Q114,140 112,130
|
|
280
|
+
Q108,138 100,142 Q92,138 88,130 Z"
|
|
281
|
+
fill="var(--hair-color)"/>`,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/glasses-shapes.ts/svg-parts/glasses-shapes.ts
|
|
285
|
+
// 5 glasses styles: none, round, rectangular, sunglasses, monocle
|
|
286
|
+
// Positioned over eyes at cy=88, cx=74 (left), cx=126 (right)
|
|
287
|
+
// Stroke-only where possible, flat fills, no gradients
|
|
288
|
+
// stroke-width ≤ 2.5
|
|
289
|
+
const GLASSES_SHAPES = {
|
|
290
|
+
none: '',
|
|
291
|
+
round: `
|
|
292
|
+
<circle cx="74" cy="88" r="16" fill="none" stroke="#1A1A2E" stroke-width="2"/>
|
|
293
|
+
<circle cx="126" cy="88" r="16" fill="none" stroke="#1A1A2E" stroke-width="2"/>
|
|
294
|
+
<line x1="90" y1="88" x2="110" y2="88" stroke="#1A1A2E" stroke-width="2"/>
|
|
295
|
+
<line x1="58" y1="88" x2="48" y2="86" stroke="#1A1A2E" stroke-width="1.5"/>
|
|
296
|
+
<line x1="142" y1="88" x2="152" y2="86" stroke="#1A1A2E" stroke-width="1.5"/>`,
|
|
297
|
+
rectangular: `
|
|
298
|
+
<rect x="58" y="78" width="32" height="20" rx="4" fill="none"
|
|
299
|
+
stroke="#1A1A2E" stroke-width="2"/>
|
|
300
|
+
<rect x="110" y="78" width="32" height="20" rx="4" fill="none"
|
|
301
|
+
stroke="#1A1A2E" stroke-width="2"/>
|
|
302
|
+
<line x1="90" y1="88" x2="110" y2="88" stroke="#1A1A2E" stroke-width="2"/>
|
|
303
|
+
<line x1="58" y1="86" x2="48" y2="84" stroke="#1A1A2E" stroke-width="1.5"/>
|
|
304
|
+
<line x1="142" y1="86" x2="152" y2="84" stroke="#1A1A2E" stroke-width="1.5"/>`,
|
|
305
|
+
sunglasses: `
|
|
306
|
+
<path d="M58,82 Q58,76 66,76 L82,76 Q90,76 90,82 L90,94 Q90,100 82,100
|
|
307
|
+
L66,100 Q58,100 58,94 Z" fill="#1A1A2E"/>
|
|
308
|
+
<path d="M110,82 Q110,76 118,76 L134,76 Q142,76 142,82 L142,94 Q142,100 134,100
|
|
309
|
+
L118,100 Q110,100 110,94 Z" fill="#1A1A2E"/>
|
|
310
|
+
<line x1="90" y1="86" x2="110" y2="86" stroke="#1A1A2E" stroke-width="2.5"/>
|
|
311
|
+
<line x1="58" y1="84" x2="48" y2="82" stroke="#1A1A2E" stroke-width="2"/>
|
|
312
|
+
<line x1="142" y1="84" x2="152" y2="82" stroke="#1A1A2E" stroke-width="2"/>`,
|
|
313
|
+
monocle: `
|
|
314
|
+
<circle cx="126" cy="88" r="16" fill="none" stroke="#C9A84C" stroke-width="2"/>
|
|
315
|
+
<line x1="126" y1="104" x2="120" y2="140" stroke="#C9A84C" stroke-width="1.5"/>`,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/profession-layers.ts/svg-parts/profession-layers.ts
|
|
319
|
+
// 9 professions: none, doctor, engineer, teacher, chef, police, astronaut, artist, business
|
|
320
|
+
// Each provides: body (shoulders/clothing) + accessory (badge/hat/item on top of head)
|
|
321
|
+
// Canvas: 200×200. Face center (100,88). Neck x=88–112, y=135–157.
|
|
322
|
+
// Body renders as layer 1 (below head). Accessory renders as layer 14 (above hair).
|
|
323
|
+
// Flat fills only, no gradients/filters, stroke-width ≤ 2.5
|
|
324
|
+
const PROFESSION_LAYERS = {
|
|
325
|
+
// ─── None: Plain crew-neck T-shirt ──────────────────
|
|
326
|
+
none: {
|
|
327
|
+
body: `
|
|
328
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
329
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
330
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#6B7280"/>
|
|
331
|
+
<!-- Collar line -->
|
|
332
|
+
<path d="M88,145 Q90,150 100,151 Q110,150 112,145"
|
|
333
|
+
fill="none" stroke="#5B6370" stroke-width="1.5" stroke-linecap="round"/>`,
|
|
334
|
+
accessory: '',
|
|
335
|
+
},
|
|
336
|
+
// ─── Doctor: White lab coat over blue scrubs, stethoscope ───
|
|
337
|
+
doctor: {
|
|
338
|
+
body: `
|
|
339
|
+
<!-- Blue scrub underneath -->
|
|
340
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
341
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
342
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#3B82F6"/>
|
|
343
|
+
<!-- V-neck scrub neckline -->
|
|
344
|
+
<path d="M88,140 L96,155 L100,152 L104,155 L112,140"
|
|
345
|
+
fill="#3B82F6" stroke="#2563EB" stroke-width="1"/>
|
|
346
|
+
<!-- White lab coat — open front, over shoulders -->
|
|
347
|
+
<path d="M30,200 Q30,158 55,150 Q70,145 80,142
|
|
348
|
+
L82,155 L90,200 Z" fill="#F8FAFC"/>
|
|
349
|
+
<path d="M170,200 Q170,158 145,150 Q130,145 120,142
|
|
350
|
+
L118,155 L110,200 Z" fill="#F8FAFC"/>
|
|
351
|
+
<!-- Coat lapels -->
|
|
352
|
+
<path d="M80,142 L86,158 L82,155" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.5"/>
|
|
353
|
+
<path d="M120,142 L114,158 L118,155" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.5"/>
|
|
354
|
+
<!-- Breast pocket -->
|
|
355
|
+
<rect x="34" y="168" width="14" height="10" rx="1" fill="none" stroke="#CBD5E1" stroke-width="1"/>
|
|
356
|
+
<!-- Stethoscope — tubes start behind neck, just under jaw (~y=142) -->
|
|
357
|
+
<path d="M95,142 Q80,148 76,156 Q74,166 84,172 Q94,177 100,175"
|
|
358
|
+
fill="none" stroke="#475569" stroke-width="2.5" stroke-linecap="round"/>
|
|
359
|
+
<path d="M105,142 Q120,148 124,156 Q126,166 116,172 Q106,177 100,175"
|
|
360
|
+
fill="none" stroke="#475569" stroke-width="2.5" stroke-linecap="round"/>
|
|
361
|
+
<!-- Stethoscope chest piece (centered on chest) -->
|
|
362
|
+
<circle cx="100" cy="177" r="5" fill="#64748B" stroke="#475569" stroke-width="1.2"/>
|
|
363
|
+
<circle cx="100" cy="177" r="2" fill="#94A3B8"/>`,
|
|
364
|
+
accessory: ``,
|
|
365
|
+
},
|
|
366
|
+
// ─── Engineer: Orange safety vest, hard hat ─────────
|
|
367
|
+
engineer: {
|
|
368
|
+
body: `
|
|
369
|
+
<!-- Blue work shirt -->
|
|
370
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
371
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
372
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#1E3A5F"/>
|
|
373
|
+
<!-- Orange hi-vis vest -->
|
|
374
|
+
<path d="M45,200 Q48,160 65,152 Q78,146 88,144
|
|
375
|
+
L92,200 Z" fill="#F97316"/>
|
|
376
|
+
<path d="M155,200 Q152,160 135,152 Q122,146 112,144
|
|
377
|
+
L108,200 Z" fill="#F97316"/>
|
|
378
|
+
<!-- Reflective stripes -->
|
|
379
|
+
<line x1="55" y1="164" x2="88" y2="152" stroke="#FDE68A" stroke-width="2.5"/>
|
|
380
|
+
<line x1="145" y1="164" x2="112" y2="152" stroke="#FDE68A" stroke-width="2.5"/>
|
|
381
|
+
<line x1="58" y1="178" x2="90" y2="168" stroke="#FDE68A" stroke-width="2.5"/>
|
|
382
|
+
<line x1="142" y1="178" x2="110" y2="168" stroke="#FDE68A" stroke-width="2.5"/>`,
|
|
383
|
+
accessory: `
|
|
384
|
+
<!-- Hair cover (hides any hair poking through) -->
|
|
385
|
+
<ellipse cx="100" cy="52" rx="56" ry="20" fill="#D97706"/>
|
|
386
|
+
<!-- Hard hat — dome -->
|
|
387
|
+
<path d="M50,52 Q50,14 100,8 Q150,14 150,52 Z" fill="#F59E0B"/>
|
|
388
|
+
<!-- Brim -->
|
|
389
|
+
<rect x="44" y="50" width="112" height="9" rx="4" fill="#D97706"/>
|
|
390
|
+
<!-- Brim shadow line -->
|
|
391
|
+
<line x1="48" y1="57" x2="152" y2="57" stroke="#B45309" stroke-width="1"/>
|
|
392
|
+
<!-- Center ridge -->
|
|
393
|
+
<line x1="100" y1="10" x2="100" y2="50" stroke="#EAB308" stroke-width="1" opacity="0.4"/>`,
|
|
394
|
+
},
|
|
395
|
+
// ─── Teacher: Professor with graduation cap, sweater vest, bow tie ─────
|
|
396
|
+
teacher: {
|
|
397
|
+
body: `
|
|
398
|
+
<!-- Light blue dress shirt -->
|
|
399
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
400
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
401
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#DBEAFE"/>
|
|
402
|
+
<!-- Shirt collar -->
|
|
403
|
+
<path d="M88,140 L82,148 L94,152" fill="#BFDBFE" stroke="#93C5FD" stroke-width="0.7"/>
|
|
404
|
+
<path d="M112,140 L118,148 L106,152" fill="#BFDBFE" stroke="#93C5FD" stroke-width="0.7"/>
|
|
405
|
+
<!-- Dark navy sweater vest -->
|
|
406
|
+
<path d="M55,200 Q56,162 65,154 Q78,147 88,144
|
|
407
|
+
L94,152 L100,148 L106,152 L112,144
|
|
408
|
+
Q122,147 135,154 Q144,162 145,200 Z" fill="#1E293B"/>
|
|
409
|
+
<!-- V-neck of sweater vest -->
|
|
410
|
+
<path d="M88,144 L94,152 L100,148 L106,152 L112,144"
|
|
411
|
+
fill="#DBEAFE" stroke="#1E293B" stroke-width="0.8"/>
|
|
412
|
+
<!-- Sweater vest bottom ribbing -->
|
|
413
|
+
<line x1="58" y1="196" x2="142" y2="196" stroke="#334155" stroke-width="1"/>
|
|
414
|
+
<line x1="57" y1="198" x2="143" y2="198" stroke="#334155" stroke-width="0.5"/>
|
|
415
|
+
<!-- Sleeves (shirt showing) -->
|
|
416
|
+
<path d="M30,200 Q30,158 55,150 Q62,148 65,154
|
|
417
|
+
L58,200 Z" fill="#DBEAFE"/>
|
|
418
|
+
<path d="M170,200 Q170,158 145,150 Q138,148 135,154
|
|
419
|
+
L142,200 Z" fill="#DBEAFE"/>
|
|
420
|
+
<!-- Bow tie -->
|
|
421
|
+
<path d="M93,147 L88,143 L88,151 Z" fill="#991B1B"/>
|
|
422
|
+
<path d="M107,147 L112,143 L112,151 Z" fill="#991B1B"/>
|
|
423
|
+
<circle cx="100" cy="147" r="2.5" fill="#B91C1C" stroke="#7F1D1D" stroke-width="0.5"/>`,
|
|
424
|
+
accessory: `
|
|
425
|
+
<!-- Graduation mortarboard cap -->
|
|
426
|
+
<!-- Cap crown (skull cap — covers top of head generously) -->
|
|
427
|
+
<path d="M46,56 Q44,38 62,30 Q80,22 100,22 Q120,22 138,30 Q156,38 154,56
|
|
428
|
+
Q134,62 100,62 Q66,62 46,56 Z" fill="#1E293B" stroke="#0F172A" stroke-width="0.6"/>
|
|
429
|
+
<!-- Flat board — large diamond shape for 3D perspective -->
|
|
430
|
+
<polygon points="100,8 168,34 100,54 32,34" fill="#1E293B" stroke="#0F172A" stroke-width="0.8"/>
|
|
431
|
+
<!-- Board top highlight for depth -->
|
|
432
|
+
<polygon points="100,11 164,34 100,52 36,34" fill="#334155" opacity="0.3"/>
|
|
433
|
+
<!-- Button on top center -->
|
|
434
|
+
<circle cx="100" cy="32" r="3.5" fill="#475569" stroke="#0F172A" stroke-width="0.8"/>
|
|
435
|
+
<!-- Tassel string from center button draping to right -->
|
|
436
|
+
<path d="M100,32 Q124,24 144,32 Q158,44 160,62" fill="none" stroke="#EAB308" stroke-width="2.2" stroke-linecap="round"/>
|
|
437
|
+
<!-- Tassel end fringe -->
|
|
438
|
+
<line x1="158" y1="60" x2="156" y2="70" stroke="#EAB308" stroke-width="1.2"/>
|
|
439
|
+
<line x1="160" y1="62" x2="159" y2="72" stroke="#EAB308" stroke-width="1.2"/>
|
|
440
|
+
<line x1="162" y1="61" x2="162" y2="71" stroke="#EAB308" stroke-width="1.2"/>
|
|
441
|
+
<line x1="159" y1="71" x2="159" y2="77" stroke="#CA8A04" stroke-width="1.5"/>`,
|
|
442
|
+
},
|
|
443
|
+
// ─── Chef: Double-breasted white jacket, tall toque ─
|
|
444
|
+
chef: {
|
|
445
|
+
body: `
|
|
446
|
+
<!-- White chef jacket -->
|
|
447
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
448
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
449
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#FEFEFE"/>
|
|
450
|
+
<!-- Mandarin collar -->
|
|
451
|
+
<rect x="86" y="138" width="28" height="8" rx="3" fill="#F1F5F9" stroke="#E2E8F0" stroke-width="1"/>
|
|
452
|
+
<!-- Double-breasted buttons (left row) -->
|
|
453
|
+
<circle cx="88" cy="162" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
|
|
454
|
+
<circle cx="88" cy="174" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
|
|
455
|
+
<circle cx="88" cy="186" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
|
|
456
|
+
<!-- Right row -->
|
|
457
|
+
<circle cx="96" cy="162" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
|
|
458
|
+
<circle cx="96" cy="174" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
|
|
459
|
+
<circle cx="96" cy="186" r="2" fill="#E2E8F0" stroke="#CBD5E1" stroke-width="0.8"/>
|
|
460
|
+
<!-- Center front seam -->
|
|
461
|
+
<line x1="100" y1="146" x2="100" y2="200" stroke="#E2E8F0" stroke-width="0.8"/>`,
|
|
462
|
+
accessory: `
|
|
463
|
+
<!-- Chef toque blanche — puffy hat, sized to sit inside the 0–200 canvas.
|
|
464
|
+
The previous version ran to y=-48, so most of it was off-canvas and
|
|
465
|
+
read as a shapeless blob. The face top is y=32, so the hat occupies
|
|
466
|
+
y≈2…59: high enough to look tall, never past the top edge. -->
|
|
467
|
+
<!-- Band at the forehead — spans the head width -->
|
|
468
|
+
<rect x="48" y="44" width="104" height="15" rx="4" fill="#FEFEFE" stroke="#E2E8F0" stroke-width="1.2"/>
|
|
469
|
+
<!-- Puffy toque body -->
|
|
470
|
+
<path d="M56,46 C52,38 50,28 52,20
|
|
471
|
+
C54,11 62,6 72,5
|
|
472
|
+
C80,4 88,4 100,4
|
|
473
|
+
C112,4 120,4 128,5
|
|
474
|
+
C138,6 146,11 148,20
|
|
475
|
+
C150,28 148,38 144,46 Z"
|
|
476
|
+
fill="#FEFEFE" stroke="#E2E8F0" stroke-width="1"/>
|
|
477
|
+
<!-- Crown puffs — overlapping rounded bumps, all within the canvas -->
|
|
478
|
+
<ellipse cx="76" cy="18" rx="20" ry="12" fill="#FEFEFE"/>
|
|
479
|
+
<ellipse cx="124" cy="18" rx="20" ry="12" fill="#FEFEFE"/>
|
|
480
|
+
<ellipse cx="100" cy="14" rx="24" ry="13" fill="#FEFEFE"/>
|
|
481
|
+
<!-- Pleat lines -->
|
|
482
|
+
<path d="M76,44 Q70,26 76,8" stroke="#F0F0F0" stroke-width="0.7" fill="none"/>
|
|
483
|
+
<path d="M100,44 L100,4" stroke="#F0F0F0" stroke-width="0.7" fill="none"/>
|
|
484
|
+
<path d="M124,44 Q130,26 124,8" stroke="#F0F0F0" stroke-width="0.7" fill="none"/>`,
|
|
485
|
+
},
|
|
486
|
+
// ─── Police: Navy uniform, cap with badge ───────────
|
|
487
|
+
police: {
|
|
488
|
+
body: `
|
|
489
|
+
<!-- Navy uniform -->
|
|
490
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
491
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
492
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#1E3A8A"/>
|
|
493
|
+
<!-- Collar -->
|
|
494
|
+
<path d="M86,140 L82,148 L92,152 L100,148 L108,152 L118,148 L114,140"
|
|
495
|
+
fill="#1E40AF" stroke="#1E3A8A" stroke-width="0.8"/>
|
|
496
|
+
<!-- Shoulder epaulettes -->
|
|
497
|
+
<rect x="52" y="148" width="18" height="5" rx="2" fill="#1E40AF" stroke="#2563EB" stroke-width="0.8"/>
|
|
498
|
+
<rect x="130" y="148" width="18" height="5" rx="2" fill="#1E40AF" stroke="#2563EB" stroke-width="0.8"/>
|
|
499
|
+
<!-- Badge (left breast) -->
|
|
500
|
+
<g transform="translate(82,168)">
|
|
501
|
+
<polygon points="0,-6 1.5,-2 5.5,-2 2.5,0.5 3.5,4.5 0,2 -3.5,4.5 -2.5,0.5 -5.5,-2 -1.5,-2"
|
|
502
|
+
fill="#EAB308" stroke="#CA8A04" stroke-width="0.5"/>
|
|
503
|
+
</g>
|
|
504
|
+
<!-- Buttons down front -->
|
|
505
|
+
<circle cx="100" cy="175" r="1.5" fill="#D4D4D8"/>
|
|
506
|
+
<circle cx="100" cy="185" r="1.5" fill="#D4D4D8"/>
|
|
507
|
+
<circle cx="100" cy="195" r="1.5" fill="#D4D4D8"/>
|
|
508
|
+
<!-- Breast pockets -->
|
|
509
|
+
<path d="M76,162 L76,172 L88,172" fill="none" stroke="#1E40AF" stroke-width="1"/>
|
|
510
|
+
<path d="M124,162 L124,172 L112,172" fill="none" stroke="#1E40AF" stroke-width="1"/>`,
|
|
511
|
+
accessory: `
|
|
512
|
+
<!-- Hair cover -->
|
|
513
|
+
<ellipse cx="100" cy="54" rx="56" ry="20" fill="#1E3A8A"/>
|
|
514
|
+
<!-- Police cap — crown -->
|
|
515
|
+
<path d="M50,54 L50,40 Q50,20 100,16 Q150,20 150,40 L150,54 Z" fill="#1E3A8A"/>
|
|
516
|
+
<!-- Cap band -->
|
|
517
|
+
<rect x="50" y="46" width="100" height="8" rx="0" fill="#1E40AF"/>
|
|
518
|
+
<!-- Cap badge -->
|
|
519
|
+
<g transform="translate(100,44)">
|
|
520
|
+
<polygon points="0,-6 2,-2 6,-2 3,1 4,5 0,3 -4,5 -3,1 -6,-2 -2,-2"
|
|
521
|
+
fill="#EAB308"/>
|
|
522
|
+
</g>
|
|
523
|
+
<!-- Cap brim (visor) -->
|
|
524
|
+
<path d="M46,54 Q46,62 100,66 Q154,62 154,54 Z" fill="#111827"/>`,
|
|
525
|
+
},
|
|
526
|
+
// ─── Astronaut: Silver/white space suit, glass helmet ──
|
|
527
|
+
astronaut: {
|
|
528
|
+
body: `
|
|
529
|
+
<!-- Neck cover — hides skin-tone neck behind collar -->
|
|
530
|
+
<rect x="82" y="130" width="36" height="20" rx="6" fill="#CFD8DC"/>
|
|
531
|
+
<!-- Bulky space suit -->
|
|
532
|
+
<path d="M22,200 Q22,155 50,147 Q72,140 88,138
|
|
533
|
+
L88,142 Q92,146 100,146 Q108,146 112,142 L112,138
|
|
534
|
+
Q128,140 150,147 Q178,155 178,200 Z" fill="#CFD8DC"/>
|
|
535
|
+
<!-- Suit seam lines -->
|
|
536
|
+
<line x1="60" y1="148" x2="52" y2="200" stroke="#B0BEC5" stroke-width="1.2"/>
|
|
537
|
+
<line x1="140" y1="148" x2="148" y2="200" stroke="#B0BEC5" stroke-width="1.2"/>
|
|
538
|
+
<!-- Chest plate -->
|
|
539
|
+
<rect x="76" y="148" width="48" height="34" rx="8" fill="#B0BEC5" stroke="#90A4AE" stroke-width="1.2"/>
|
|
540
|
+
<!-- Status lights on chest -->
|
|
541
|
+
<circle cx="90" cy="157" r="2.5" fill="#4ADE80"/>
|
|
542
|
+
<circle cx="110" cy="157" r="2.5" fill="#60A5FA"/>
|
|
543
|
+
<!-- Mission patch (left shoulder) -->
|
|
544
|
+
<path d="M42,153 L50,150 L58,153 L50,162 Z" fill="#1E3A5F" stroke="#BBB" stroke-width="0.5"/>
|
|
545
|
+
<path d="M46,154 L50,152 L54,154" fill="none" stroke="#60A5FA" stroke-width="0.8"/>
|
|
546
|
+
<!-- Agency logo patch (right shoulder) -->
|
|
547
|
+
<circle cx="155" cy="159" r="7" fill="#1E3A5F" stroke="#BBB" stroke-width="0.5"/>
|
|
548
|
+
<circle cx="155" cy="159" r="4" fill="none" stroke="#60A5FA" stroke-width="0.8"/>
|
|
549
|
+
<!-- Collar ring -->
|
|
550
|
+
<ellipse cx="100" cy="141" rx="30" ry="9" fill="#607D8B"/>
|
|
551
|
+
<ellipse cx="100" cy="139" rx="28" ry="7" fill="#78909C"/>
|
|
552
|
+
<ellipse cx="100" cy="137" rx="24" ry="5" fill="#90A4AE"/>`,
|
|
553
|
+
accessory: `
|
|
554
|
+
<!-- Helmet dome -->
|
|
555
|
+
<ellipse cx="100" cy="78" rx="60" ry="64" fill="#CFD8DC" opacity="0.30"/>
|
|
556
|
+
<!-- Helmet outer rim -->
|
|
557
|
+
<ellipse cx="100" cy="78" rx="60" ry="64" fill="none"
|
|
558
|
+
stroke="#78909C" stroke-width="4"/>
|
|
559
|
+
<!-- Visor glass reflection -->
|
|
560
|
+
<path d="M64,52 Q74,36 90,40" fill="none" stroke="#FFF" stroke-width="2.5" stroke-linecap="round" opacity="0.40"/>
|
|
561
|
+
<!-- Side bolts -->
|
|
562
|
+
<circle cx="42" cy="88" r="4" fill="#546E7A" stroke="#455A64" stroke-width="1"/>
|
|
563
|
+
<circle cx="158" cy="88" r="4" fill="#546E7A" stroke="#455A64" stroke-width="1"/>
|
|
564
|
+
<!-- Antenna. The helmet crown is at y=14, leaving ~12px of headroom
|
|
565
|
+
inside the 200×200 canvas — keep the tip's outer edge at y >= 2. -->
|
|
566
|
+
<line x1="78" y1="16" x2="78" y2="8" stroke="#78909C" stroke-width="2.5" stroke-linecap="round"/>
|
|
567
|
+
<circle cx="78" cy="5" r="3" fill="#EF4444" stroke="#DC2626" stroke-width="0.8"/>`,
|
|
568
|
+
},
|
|
569
|
+
// ─── Artist: Striped shirt, apron, beret + brush ─────
|
|
570
|
+
artist: {
|
|
571
|
+
body: `
|
|
572
|
+
<!-- Dark striped undershirt -->
|
|
573
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
574
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
575
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#333A45"/>
|
|
576
|
+
<!-- Horizontal stripes on shirt -->
|
|
577
|
+
<line x1="42" y1="168" x2="158" y2="168" stroke="#4E5563" stroke-width="2"/>
|
|
578
|
+
<line x1="36" y1="180" x2="164" y2="180" stroke="#4E5563" stroke-width="2"/>
|
|
579
|
+
<line x1="34" y1="192" x2="166" y2="192" stroke="#4E5563" stroke-width="2"/>
|
|
580
|
+
<!-- Canvas-colored apron over shirt -->
|
|
581
|
+
<path d="M72,152 L72,200 L128,200 L128,152
|
|
582
|
+
Q128,148 120,148 L100,148 L80,148 Q72,148 72,152 Z" fill="#E8DCCA"/>
|
|
583
|
+
<!-- Apron neck strap (V-shape) -->
|
|
584
|
+
<path d="M88,140 L80,148" stroke="#D4C4A8" stroke-width="2" stroke-linecap="round"/>
|
|
585
|
+
<path d="M112,140 L120,148" stroke="#D4C4A8" stroke-width="2" stroke-linecap="round"/>
|
|
586
|
+
<!-- Apron pocket -->
|
|
587
|
+
<rect x="84" y="170" width="32" height="16" rx="2" fill="none" stroke="#C4B498" stroke-width="1"/>
|
|
588
|
+
<!-- Paintbrush in pocket -->
|
|
589
|
+
<line x1="92" y1="163" x2="92" y2="172" stroke="#8B5E3C" stroke-width="2" stroke-linecap="round"/>
|
|
590
|
+
<rect x="90" y="160" width="4" height="5" rx="1" fill="#3B82F6"/>
|
|
591
|
+
<!-- Paint smudge on apron — natural drip shapes -->
|
|
592
|
+
<path d="M110,175 Q112,173 114,176 Q113,178 110,177 Z" fill="#EF4444" opacity="0.6"/>
|
|
593
|
+
<path d="M78,188 Q80,186 83,188 Q82,191 79,190 Z" fill="#7C3AED" opacity="0.5"/>
|
|
594
|
+
<circle cx="120" cy="190" r="2" fill="#FBBF24" opacity="0.5"/>`,
|
|
595
|
+
accessory: `
|
|
596
|
+
<!-- Hair cover -->
|
|
597
|
+
<ellipse cx="100" cy="54" rx="56" ry="20" fill="#DC2626"/>
|
|
598
|
+
<!-- Beret — classic flat shape sitting on head -->
|
|
599
|
+
<path d="M50,54 Q50,36 70,28 Q86,22 100,22 Q120,22 140,30 Q156,38 156,54 Z" fill="#DC2626"/>
|
|
600
|
+
<!-- Beret puff — floppy top drooping to the right -->
|
|
601
|
+
<path d="M100,22 Q120,14 145,18 Q164,24 162,38 Q160,48 156,54
|
|
602
|
+
Q152,40 130,30 Q110,22 100,22 Z" fill="#EF4444"/>
|
|
603
|
+
<!-- Band (rim that hugs the forehead) -->
|
|
604
|
+
<path d="M50,54 Q50,60 100,62 Q150,60 156,54" fill="none"
|
|
605
|
+
stroke="#B91C1C" stroke-width="2.5"/>
|
|
606
|
+
<!-- Nub on top -->
|
|
607
|
+
<circle cx="138" cy="20" r="3.5" fill="#B91C1C"/>`,
|
|
608
|
+
},
|
|
609
|
+
// ─── Business: Black suit, white shirt, red tie ─────
|
|
610
|
+
business: {
|
|
611
|
+
body: `
|
|
612
|
+
<!-- White dress shirt -->
|
|
613
|
+
<path d="M30,200 Q30,158 55,150 Q75,143 88,140
|
|
614
|
+
L88,145 Q90,148 100,148 Q110,148 112,145 L112,140
|
|
615
|
+
Q125,143 145,150 Q170,158 170,200 Z" fill="#F8FAFC"/>
|
|
616
|
+
<!-- Shirt collar -->
|
|
617
|
+
<path d="M86,140 L80,150 L94,150 Z" fill="#F1F5F9" stroke="#E2E8F0" stroke-width="0.5"/>
|
|
618
|
+
<path d="M114,140 L120,150 L106,150 Z" fill="#F1F5F9" stroke="#E2E8F0" stroke-width="0.5"/>
|
|
619
|
+
<!-- Suit jacket -->
|
|
620
|
+
<path d="M30,200 Q30,158 55,150 Q66,146 76,143
|
|
621
|
+
L82,152 L88,200 Z" fill="#333A45"/>
|
|
622
|
+
<path d="M170,200 Q170,158 145,150 Q134,146 124,143
|
|
623
|
+
L118,152 L112,200 Z" fill="#333A45"/>
|
|
624
|
+
<!-- Lapels (V-shape) -->
|
|
625
|
+
<path d="M76,143 L86,158 L82,152" fill="#3E4652" stroke="#4E5563" stroke-width="0.5"/>
|
|
626
|
+
<path d="M124,143 L114,158 L118,152" fill="#3E4652" stroke="#4E5563" stroke-width="0.5"/>
|
|
627
|
+
<!-- Suit button -->
|
|
628
|
+
<circle cx="88" cy="175" r="2" fill="#3F3F46"/>
|
|
629
|
+
<circle cx="112" cy="175" r="2" fill="#3F3F46"/>
|
|
630
|
+
<!-- Necktie -->
|
|
631
|
+
<path d="M96,148 L100,150 L104,148 L100,146 Z" fill="#DC2626"/>
|
|
632
|
+
<path d="M97,150 L100,152 L103,150 L103.5,150" fill="#DC2626"/>
|
|
633
|
+
<polygon points="100,152 95,200 105,200" fill="#DC2626"/>
|
|
634
|
+
<!-- Tie knot -->
|
|
635
|
+
<path d="M97,148 L100,151 L103,148" fill="#B91C1C"/>
|
|
636
|
+
<!-- Tie stripe detail -->
|
|
637
|
+
<line x1="97" y1="165" x2="103" y2="162" stroke="#991B1B" stroke-width="1"/>
|
|
638
|
+
<line x1="97" y1="175" x2="103" y2="172" stroke="#991B1B" stroke-width="1"/>
|
|
639
|
+
<line x1="97" y1="185" x2="103" y2="182" stroke="#991B1B" stroke-width="1"/>`,
|
|
640
|
+
accessory: ``,
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// libs/avatar-player/src/lib/geometry/trait-previews.ts/trait-previews.ts
|
|
645
|
+
//
|
|
646
|
+
// Small SVG thumbnails for the creator's pickers.
|
|
647
|
+
//
|
|
648
|
+
// The UI was showing an emoji per option while TraitOption already had an
|
|
649
|
+
// unused `svgPreview` field. Emoji neither match the artwork nor tell you what
|
|
650
|
+
// "handlebar" looks like. These build the real shape from the same part data
|
|
651
|
+
// the renderer uses, so a preview can never drift from the result.
|
|
652
|
+
//
|
|
653
|
+
// Everything is drawn with literal hex colours, not CSS custom properties:
|
|
654
|
+
// the thumbnails are injected with innerHTML and must stand on their own.
|
|
655
|
+
const SKIN = SKIN_TONES.medium.base;
|
|
656
|
+
const SKIN_DARK = SKIN_TONES.medium.ear;
|
|
657
|
+
const HAIR = HAIR_COLORS.brown;
|
|
658
|
+
const EYE = EYE_COLORS.brown;
|
|
659
|
+
/**
|
|
660
|
+
* Part data references `var(--hair-color)` and friends. Substitute literals so
|
|
661
|
+
* a thumbnail renders correctly outside the avatar's own stylesheet.
|
|
662
|
+
*/
|
|
663
|
+
function inlineVars(svg) {
|
|
664
|
+
return svg
|
|
665
|
+
.replace(/var\(--hair-color\)/g, HAIR)
|
|
666
|
+
.replace(/var\(--eye-color\)/g, EYE)
|
|
667
|
+
.replace(/var\(--skin-base\)/g, SKIN)
|
|
668
|
+
.replace(/var\(--skin-ear\)/g, SKIN_DARK)
|
|
669
|
+
.replace(/var\(--skin-shadow\)/g, SKIN_DARK)
|
|
670
|
+
.replace(/var\(--lip-color\)/g, SKIN_TONES.medium.lip);
|
|
671
|
+
}
|
|
672
|
+
/** Crop window around the head. Parts live in 0–200 space centred on x=100. */
|
|
673
|
+
const VIEWBOX = '32 18 136 136';
|
|
674
|
+
function wrap(inner, viewBox = VIEWBOX) {
|
|
675
|
+
return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" ` +
|
|
676
|
+
`preserveAspectRatio="xMidYMid meet">${inner}</svg>`);
|
|
677
|
+
}
|
|
678
|
+
const head = (rx = 52, ry = 56, cy = 88) => `<ellipse cx="100" cy="${cy}" rx="${rx}" ry="${ry}" fill="${SKIN}"/>`;
|
|
679
|
+
const ears = () => `<ellipse cx="48" cy="90" rx="8" ry="10" fill="${SKIN_DARK}"/>` +
|
|
680
|
+
`<ellipse cx="152" cy="90" rx="8" ry="10" fill="${SKIN_DARK}"/>`;
|
|
681
|
+
const eyes = () => {
|
|
682
|
+
const e = EYE_SHAPES.round;
|
|
683
|
+
return `<g>${inlineVars(e.sclera)}</g><g>${inlineVars(e.iris)}</g>`;
|
|
684
|
+
};
|
|
685
|
+
const brows = () => `<rect x="63" y="72" width="22" height="5" rx="3" fill="${HAIR}"/>` +
|
|
686
|
+
`<rect x="115" y="72" width="22" height="5" rx="3" fill="${HAIR}"/>`;
|
|
687
|
+
const noseMouth = () => `<path d="M97,105 Q100,112 103,105" stroke="${SKIN_DARK}" stroke-width="2" fill="none" stroke-linecap="round"/>` +
|
|
688
|
+
`<path d="M84,120 Q100,132 116,120" stroke="${SKIN_TONES.medium.lip}" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
|
|
689
|
+
/** Neutral bust used behind profession thumbnails. */
|
|
690
|
+
const bust = () => `<path d="M30,200 Q30,150 100,150 Q170,150 170,200 Z" fill="#8A93A5"/>`;
|
|
691
|
+
function buildTraitPreview(kind, value) {
|
|
692
|
+
switch (kind) {
|
|
693
|
+
case 'gender': {
|
|
694
|
+
const woman = value === 'woman';
|
|
695
|
+
const face = woman
|
|
696
|
+
? `<ellipse cx="100" cy="86" rx="49" ry="55" fill="${SKIN}"/>` +
|
|
697
|
+
`<g class="lashes">${[0, 1, 2, 3, 4, 5, 6, 7]
|
|
698
|
+
.map((i) => {
|
|
699
|
+
const xs = [65, 70, 76, 82, 117, 122, 128, 134][i];
|
|
700
|
+
return `<path d="M${xs},78 l-3,-4" stroke="${HAIR}" stroke-width="1.5" stroke-linecap="round"/>`;
|
|
701
|
+
})
|
|
702
|
+
.join('')}</g>`
|
|
703
|
+
: head();
|
|
704
|
+
return wrap(ears() + face + eyes() + brows() + noseMouth());
|
|
705
|
+
}
|
|
706
|
+
case 'haircut': {
|
|
707
|
+
const h = HAIR_SHAPES[value];
|
|
708
|
+
if (!h)
|
|
709
|
+
return wrap(head());
|
|
710
|
+
return wrap(ears() + `<g>${inlineVars(h.back)}</g>` + head() + `<g>${inlineVars(h.front)}</g>`);
|
|
711
|
+
}
|
|
712
|
+
case 'eyeStyle': {
|
|
713
|
+
const e = EYE_SHAPES[value];
|
|
714
|
+
if (!e)
|
|
715
|
+
return wrap(head());
|
|
716
|
+
return wrap(head() + `<g>${inlineVars(e.sclera)}</g><g>${inlineVars(e.iris)}</g>`);
|
|
717
|
+
}
|
|
718
|
+
case 'mustache': {
|
|
719
|
+
const m = MUSTACHE_SHAPES[value];
|
|
720
|
+
return wrap(ears() + head() + eyes() + brows() + noseMouth() + (m ? `<g>${inlineVars(m)}</g>` : ''));
|
|
721
|
+
}
|
|
722
|
+
case 'beard': {
|
|
723
|
+
const b = BEARD_SHAPES[value];
|
|
724
|
+
return wrap(ears() + head() + eyes() + brows() + noseMouth() + (b ? `<g>${inlineVars(b)}</g>` : ''));
|
|
725
|
+
}
|
|
726
|
+
case 'glasses': {
|
|
727
|
+
const g = GLASSES_SHAPES[value];
|
|
728
|
+
return wrap(ears() + head() + eyes() + brows() + noseMouth() + (g ? `<g>${inlineVars(g)}</g>` : ''));
|
|
729
|
+
}
|
|
730
|
+
case 'profession': {
|
|
731
|
+
const p = PROFESSION_LAYERS[value];
|
|
732
|
+
const body = p?.body ? inlineVars(p.body) : bust();
|
|
733
|
+
const acc = p?.accessory ? inlineVars(p.accessory) : '';
|
|
734
|
+
// Profession art is drawn on the full 0–200 canvas, so use the whole
|
|
735
|
+
// thing rather than the head crop the face previews use.
|
|
736
|
+
return wrap(body + ears() + head() + eyes() + brows() + noseMouth() + acc, '0 0 200 200');
|
|
737
|
+
}
|
|
738
|
+
default:
|
|
739
|
+
return wrap(head());
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// libs/avatar-player/src/lib/geometry/visemes.ts/visemes.ts
|
|
744
|
+
//
|
|
745
|
+
// Pure text -> viseme mapping, shared by the runtime lip-sync service and the
|
|
746
|
+
// offline generator that bakes an animated mouth into an exported SVG.
|
|
747
|
+
//
|
|
748
|
+
// Deliberately not a real phoneme model: every unmapped consonant falls back
|
|
749
|
+
// to the "spread" viseme, so the result reads as "mouth is moving" rather
|
|
750
|
+
// than accurate speech. Do not present it as phonetics.
|
|
751
|
+
/** Silence. */
|
|
752
|
+
const VISEME_SILENCE = 0;
|
|
753
|
+
/** A / I — wide open */
|
|
754
|
+
const VISEME_WIDE = 1;
|
|
755
|
+
/** O / U — rounded */
|
|
756
|
+
const VISEME_ROUND = 2;
|
|
757
|
+
/** E — spread */
|
|
758
|
+
const VISEME_SPREAD = 3;
|
|
759
|
+
/** M / B / P — pressed */
|
|
760
|
+
const VISEME_PRESSED = 4;
|
|
761
|
+
/** F / V — teeth on lip */
|
|
762
|
+
const VISEME_TEETH = 5;
|
|
763
|
+
/** How long each viseme is held, in ms. */
|
|
764
|
+
const MS_PER_VISEME = 80;
|
|
765
|
+
/**
|
|
766
|
+
* Map text characters to viseme ids 0–5.
|
|
767
|
+
* 0=silence, 1=A/I wide, 2=O/U round, 3=E spread, 4=M/B/P pressed, 5=F/V teeth
|
|
768
|
+
*/
|
|
769
|
+
function textToVisemes(text) {
|
|
770
|
+
return text
|
|
771
|
+
.toLowerCase()
|
|
772
|
+
.split('')
|
|
773
|
+
.map((char) => {
|
|
774
|
+
if ('ai'.includes(char))
|
|
775
|
+
return VISEME_WIDE;
|
|
776
|
+
if ('ou'.includes(char))
|
|
777
|
+
return VISEME_ROUND;
|
|
778
|
+
if ('e'.includes(char))
|
|
779
|
+
return VISEME_SPREAD;
|
|
780
|
+
if ('mbp'.includes(char))
|
|
781
|
+
return VISEME_PRESSED;
|
|
782
|
+
if ('fv'.includes(char))
|
|
783
|
+
return VISEME_TEETH;
|
|
784
|
+
if (char === ' ')
|
|
785
|
+
return VISEME_SILENCE;
|
|
786
|
+
return VISEME_SPREAD; // default consonant
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/mouth-shapes.ts/svg-parts/mouth-shapes.ts
|
|
791
|
+
// 6 viseme paths for lip-sync animation
|
|
792
|
+
// All paths fit within the face area (x: 82–118, y: 118–136)
|
|
793
|
+
// stroke="var(--lip-color)" stroke-width="3" fill="none" stroke-linecap="round"
|
|
794
|
+
const MOUTH_SHAPES = {
|
|
795
|
+
0: 'M84,120 Q100,132 116,120', // silence — gentle smile
|
|
796
|
+
1: 'M84,118 Q100,136 116,118', // A/I — wide open
|
|
797
|
+
2: 'M90,118 Q100,130 110,118', // O/U — round
|
|
798
|
+
3: 'M82,122 Q100,128 118,122', // E — spread
|
|
799
|
+
4: 'M86,122 Q100,124 114,122', // M/B/P — pressed
|
|
800
|
+
5: 'M84,120 Q92,126 100,122 Q108,126 116,120', // F/V — teeth-lip
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
// libs/avatar-player/src/lib/geometry/svg-parts/beard-path.ts/svg-parts/beard-path.ts
|
|
804
|
+
//
|
|
805
|
+
// Facial hair is authored as artwork, then FITTED to the head at render
|
|
806
|
+
// time.
|
|
807
|
+
//
|
|
808
|
+
// The shapes in beard-shapes.ts / mustache-shapes.ts were drawn against the
|
|
809
|
+
// man's face (cx=100 cy=88 rx=52 ry=56). A woman's head is a different
|
|
810
|
+
// ellipse, so the same path either juts past her jaw or leaves a gap.
|
|
811
|
+
//
|
|
812
|
+
// The first three attempts generated new geometry instead, which produced
|
|
813
|
+
// uniform bands that lost the hand-drawn character. This keeps the original
|
|
814
|
+
// silhouettes and rescales them by the ratio between the two faces, so:
|
|
815
|
+
// - a man is unchanged, exactly, by construction (the ratios are 1)
|
|
816
|
+
// - a woman gets the same shape at the same relative position on a
|
|
817
|
+
// proportionally smaller head
|
|
818
|
+
//
|
|
819
|
+
// The rescale is applied to the NUMBERS in the path, never as a CSS
|
|
820
|
+
// transform. An SVG transform attribute would be resolved against the
|
|
821
|
+
// element's bounding box under `transform-box: fill-box` and fling the
|
|
822
|
+
// beard across the face.
|
|
823
|
+
/** The two head shapes the renderer supports. */
|
|
824
|
+
const MAN_FACE = { cx: 100, cy: 88, rx: 52, ry: 56 };
|
|
825
|
+
const WOMAN_FACE = { cx: 100, cy: 86, rx: 49, ry: 55 };
|
|
826
|
+
function faceFor(gender) {
|
|
827
|
+
return gender === 'woman' ? WOMAN_FACE : MAN_FACE;
|
|
828
|
+
}
|
|
829
|
+
/** Half-width of the head at a given y. Returns 0 at or past the chin. */
|
|
830
|
+
function faceHalfWidthAt(face, y) {
|
|
831
|
+
const t = (y - face.cy) / face.ry;
|
|
832
|
+
if (t <= -1 || t >= 1)
|
|
833
|
+
return 0;
|
|
834
|
+
return face.rx * Math.sqrt(1 - t * t);
|
|
835
|
+
}
|
|
836
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
837
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
838
|
+
/** How far the beard's outer edge deliberately overshoots the face. */
|
|
839
|
+
const OVERHANG = 1;
|
|
840
|
+
/** Closed path through points, smoothed with quadratic midpoints. */
|
|
841
|
+
function smoothClosedPath(pts) {
|
|
842
|
+
if (pts.length < 3)
|
|
843
|
+
return '';
|
|
844
|
+
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
845
|
+
let d = `M${r2(pts[0][0])},${r2(pts[0][1])}`;
|
|
846
|
+
for (let i = 1; i <= pts.length; i++) {
|
|
847
|
+
const cur = pts[i % pts.length];
|
|
848
|
+
const m = mid(cur, pts[(i + 1) % pts.length]);
|
|
849
|
+
d += ` Q${r2(cur[0])},${r2(cur[1])} ${r2(m[0])},${r2(m[1])}`;
|
|
850
|
+
}
|
|
851
|
+
return d + ' Z';
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Rewrite every coordinate in a `d` attribute from the man's face into the
|
|
855
|
+
* target face. Safe because these shapes use only absolute M/L/Q/C/Z — no
|
|
856
|
+
* relative commands, no arcs, so all numbers come in x,y pairs.
|
|
857
|
+
*/
|
|
858
|
+
function fitPathData(d, face) {
|
|
859
|
+
const sx = face.rx / MAN_FACE.rx;
|
|
860
|
+
const sy = face.ry / MAN_FACE.ry;
|
|
861
|
+
if (sx === 1 && sy === 1 && face.cx === MAN_FACE.cx && face.cy === MAN_FACE.cy) {
|
|
862
|
+
return d; // man's face — leave the artwork byte-for-byte alone
|
|
863
|
+
}
|
|
864
|
+
let i = 0;
|
|
865
|
+
return d.replace(/-?\d+(\.\d+)?/g, (m) => {
|
|
866
|
+
const v = parseFloat(m);
|
|
867
|
+
const isX = i % 2 === 0;
|
|
868
|
+
i++;
|
|
869
|
+
const scaled = isX
|
|
870
|
+
? face.cx + (v - MAN_FACE.cx) * sx
|
|
871
|
+
: face.cy + (v - MAN_FACE.cy) * sy;
|
|
872
|
+
return String(r2(scaled));
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/** Fit every `d` and every ellipse in a piece of SVG markup to a head. */
|
|
876
|
+
function fitToFace(markup, face) {
|
|
877
|
+
if (!markup)
|
|
878
|
+
return '';
|
|
879
|
+
const sx = face.rx / MAN_FACE.rx;
|
|
880
|
+
const sy = face.ry / MAN_FACE.ry;
|
|
881
|
+
return markup
|
|
882
|
+
.replace(/d="([^"]*)"/g, (_, d) => `d="${fitPathData(d, face)}"`)
|
|
883
|
+
.replace(/<ellipse([^>]*)\/>/g, (whole, attrs) => {
|
|
884
|
+
const out = attrs
|
|
885
|
+
.replace(/cx="(-?\d+(?:\.\d+)?)"/, (_, v) => `cx="${r2(face.cx + (parseFloat(v) - MAN_FACE.cx) * sx)}"`)
|
|
886
|
+
.replace(/cy="(-?\d+(?:\.\d+)?)"/, (_, v) => `cy="${r2(face.cy + (parseFloat(v) - MAN_FACE.cy) * sy)}"`)
|
|
887
|
+
.replace(/rx="(-?\d+(?:\.\d+)?)"/, (_, v) => `rx="${r2(parseFloat(v) * sx)}"`)
|
|
888
|
+
.replace(/ry="(-?\d+(?:\.\d+)?)"/, (_, v) => `ry="${r2(parseFloat(v) * sy)}"`);
|
|
889
|
+
return `<ellipse${out}/>`;
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
const BEARD_BANDS = {
|
|
893
|
+
// 5 o'clock shadow: low and faint, hugging the jaw only.
|
|
894
|
+
stubble: { startFrac: 0.36, endFrac: 0.97, inset: 3, thicknessTop: 4, thicknessBottom: 7 },
|
|
895
|
+
// Jaw-hugging crescent, tapering to nothing by the ears.
|
|
896
|
+
short: { startFrac: 0.17, endFrac: 0.99, inset: 2.5, thicknessTop: 4, thicknessBottom: 14 },
|
|
897
|
+
// Same crescent, then a narrow drop. Kept narrow on purpose: a wide drop
|
|
898
|
+
// reads as a bib rather than a beard.
|
|
899
|
+
long: {
|
|
900
|
+
startFrac: 0.17, endFrac: 0.99, inset: 2.5, thicknessTop: 4, thicknessBottom: 15,
|
|
901
|
+
drop: 0.30, dropWidth: 0.26,
|
|
902
|
+
},
|
|
903
|
+
// Chin patch only.
|
|
904
|
+
goatee: { startFrac: 0.60, endFrac: 0.99, inset: 13, thicknessTop: 5, thicknessBottom: 12 },
|
|
905
|
+
};
|
|
906
|
+
function buildBeardSvg(style, face, fill) {
|
|
907
|
+
if (style === 'none')
|
|
908
|
+
return '';
|
|
909
|
+
const o = BEARD_BANDS[style];
|
|
910
|
+
const steps = 26;
|
|
911
|
+
const yTop = face.cy + face.ry * o.startFrac;
|
|
912
|
+
const yJaw = face.cy + face.ry * o.endFrac;
|
|
913
|
+
const chinY = face.cy + face.ry;
|
|
914
|
+
const dropLen = o.drop ? face.ry * o.drop : 0;
|
|
915
|
+
const dropW = o.dropWidth ? face.rx * o.dropWidth : 0;
|
|
916
|
+
/**
|
|
917
|
+
* Outer half-width; past the chin it becomes the drop's taper.
|
|
918
|
+
*
|
|
919
|
+
* The band's outer edge is drawn slightly OUTSIDE the face outline, not
|
|
920
|
+
* inset inside it. Two reasons: the smoothing below passes through the
|
|
921
|
+
* MIDPOINTS between samples rather than through the samples themselves, so
|
|
922
|
+
* an edge placed exactly on the face ends up a sliver inside it and shows
|
|
923
|
+
* as a skin gap. Overhanging by a pixel and letting the face clip trim it
|
|
924
|
+
* back gives contact by construction instead of by tuning.
|
|
925
|
+
*/
|
|
926
|
+
const outerHalf = (y) => {
|
|
927
|
+
if (y <= chinY)
|
|
928
|
+
return Math.max(0, faceHalfWidthAt(face, y) + OVERHANG);
|
|
929
|
+
if (!dropLen)
|
|
930
|
+
return 0;
|
|
931
|
+
const t = (y - chinY) / dropLen;
|
|
932
|
+
return t >= 1 ? 0 : dropW * Math.sqrt(1 - t * t);
|
|
933
|
+
};
|
|
934
|
+
const innerR = [];
|
|
935
|
+
const innerL = [];
|
|
936
|
+
for (let i = 0; i <= steps; i++) {
|
|
937
|
+
const t = i / steps;
|
|
938
|
+
const y = lerp(yTop, yJaw, t);
|
|
939
|
+
// The inner edge follows the same curve, a band-thickness inside it.
|
|
940
|
+
// Deriving it from the face is what closes the gap.
|
|
941
|
+
const hw = Math.max(0, faceHalfWidthAt(face, y) - o.inset - lerp(o.thicknessTop, o.thicknessBottom, t));
|
|
942
|
+
innerR.push([face.cx + hw, y]);
|
|
943
|
+
innerL.push([face.cx - hw, y]);
|
|
944
|
+
}
|
|
945
|
+
const outerR = [];
|
|
946
|
+
const outerL = [];
|
|
947
|
+
for (let i = 0; i <= steps; i++) {
|
|
948
|
+
const y = lerp(yTop, chinY + dropLen, i / steps);
|
|
949
|
+
const hw = outerHalf(y);
|
|
950
|
+
outerR.push([face.cx + hw, y]);
|
|
951
|
+
outerL.push([face.cx - hw, y]);
|
|
952
|
+
}
|
|
953
|
+
const ring = [
|
|
954
|
+
...innerL,
|
|
955
|
+
...[...innerR].reverse(),
|
|
956
|
+
...outerR,
|
|
957
|
+
...[...outerL].reverse(),
|
|
958
|
+
];
|
|
959
|
+
const d = smoothClosedPath(ring);
|
|
960
|
+
if (!d)
|
|
961
|
+
return '';
|
|
962
|
+
// Stubble still relies on opacity rather than a second tone; logged for Phase 5.
|
|
963
|
+
const opacity = style === 'stubble' ? ' opacity="0.22"' : '';
|
|
964
|
+
return `<path d="${d}" fill="${fill}"${opacity}/>`;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// libs/avatar-player/src/lib/geometry/avatar-renderer.ts/avatar-renderer.ts
|
|
968
|
+
//
|
|
969
|
+
// SINGLE SOURCE OF TRUTH for avatar geometry.
|
|
970
|
+
//
|
|
971
|
+
// Both the live preview (SvgAvatarComponent) and the export bundle
|
|
972
|
+
// (AvatarService.buildSvg) call this. Previously each had its own copy
|
|
973
|
+
// of the render logic, and they drifted badly: the exporter ignored gender
|
|
974
|
+
// and omitted every clipPath, so head-wearing professions exported with
|
|
975
|
+
// hair punching through their hats.
|
|
976
|
+
//
|
|
977
|
+
// Nothing in here may read DOM state. Everything the renderer needs is
|
|
978
|
+
// derived from the config (plus an optional viseme), which is what makes
|
|
979
|
+
// the output reproducible and testable.
|
|
980
|
+
/**
|
|
981
|
+
* Professions whose accessory covers the top of the head, so hair must clip.
|
|
982
|
+
*
|
|
983
|
+
* `chef` is here because the toque band sits across the forehead; without it
|
|
984
|
+
* long hair renders straight through the hat.
|
|
985
|
+
*/
|
|
986
|
+
const HAT_PROFESSIONS = new Set([
|
|
987
|
+
'engineer',
|
|
988
|
+
'police',
|
|
989
|
+
'artist',
|
|
990
|
+
'chef',
|
|
991
|
+
]);
|
|
992
|
+
/** Professions with a full helmet, which clips hair AND facial hair. */
|
|
993
|
+
const HELMET_PROFESSIONS = new Set(['astronaut']);
|
|
994
|
+
/** Canonical canvas. */
|
|
995
|
+
const CANVAS = { width: 200, height: 200, viewBox: '0 0 200 200' };
|
|
996
|
+
/**
|
|
997
|
+
* Hat brim clip. A flat cut at y=56 — see the plan: deriving this from the
|
|
998
|
+
* actual hat geometry is Phase 3 work.
|
|
999
|
+
*/
|
|
1000
|
+
const HAT_CLIP_RECT = { x: 0, y: 56, width: 200, height: 144 };
|
|
1001
|
+
/** Helmet aperture. */
|
|
1002
|
+
const HELMET_CLIP_ELLIPSE = { cx: 100, cy: 78, rx: 57, ry: 62 };
|
|
1003
|
+
/**
|
|
1004
|
+
* Build the `<animate>` child for the mouth, or '' when there is nothing to
|
|
1005
|
+
* animate. Consecutive duplicates are collapsed so the animation spends its
|
|
1006
|
+
* time on distinct mouth shapes rather than repeating the same one.
|
|
1007
|
+
*/
|
|
1008
|
+
function mouthAnimation(text) {
|
|
1009
|
+
const visemes = textToVisemes(text);
|
|
1010
|
+
if (visemes.length === 0)
|
|
1011
|
+
return '';
|
|
1012
|
+
// Collapse runs of the same viseme, then always return to silence so the
|
|
1013
|
+
// loop does not snap between two open shapes.
|
|
1014
|
+
const seq = [];
|
|
1015
|
+
for (const v of visemes) {
|
|
1016
|
+
if (seq.length === 0 || seq[seq.length - 1] !== v)
|
|
1017
|
+
seq.push(v);
|
|
1018
|
+
}
|
|
1019
|
+
if (seq[seq.length - 1] !== 0)
|
|
1020
|
+
seq.push(0);
|
|
1021
|
+
const values = seq.map((v) => MOUTH_SHAPES[v] ?? MOUTH_SHAPES[0]);
|
|
1022
|
+
const totalMs = seq.length * MS_PER_VISEME;
|
|
1023
|
+
// calcMode="discrete" steps between the shapes with no interpolation —
|
|
1024
|
+
// which is what lip sync wants, and also avoids interpolating between
|
|
1025
|
+
// paths whose command counts differ (viseme 5 is a three-segment path).
|
|
1026
|
+
return (`<animate attributeName="d" calcMode="discrete" dur="${(totalMs / 1000).toFixed(3)}s"` +
|
|
1027
|
+
` repeatCount="indefinite" values="${values.join(';')}"/>`);
|
|
1028
|
+
}
|
|
1029
|
+
function isAstronaut(config) {
|
|
1030
|
+
return HELMET_PROFESSIONS.has(config.profession);
|
|
1031
|
+
}
|
|
1032
|
+
function hasHat(config) {
|
|
1033
|
+
return HAT_PROFESSIONS.has(config.profession);
|
|
1034
|
+
}
|
|
1035
|
+
/** Clip reference for the hair-back / hair-front layers, or null. */
|
|
1036
|
+
function hairClipUrl(config, idsPrefix = '') {
|
|
1037
|
+
if (isAstronaut(config))
|
|
1038
|
+
return `url(#${idsPrefix}helmet-clip)`;
|
|
1039
|
+
if (hasHat(config))
|
|
1040
|
+
return `url(#${idsPrefix}hat-clip)`;
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
/** Clip reference for ears / mustache / beard, or null. */
|
|
1044
|
+
function helmetClipUrl(config, idsPrefix = '') {
|
|
1045
|
+
return isAstronaut(config) ? `url(#${idsPrefix}helmet-clip)` : null;
|
|
1046
|
+
}
|
|
1047
|
+
/** CSS custom properties that drive every colour in the artwork. */
|
|
1048
|
+
function avatarCssVars(config) {
|
|
1049
|
+
const skin = SKIN_TONES[config.skinTone];
|
|
1050
|
+
return {
|
|
1051
|
+
'--skin-base': skin.base,
|
|
1052
|
+
'--skin-ear': skin.ear,
|
|
1053
|
+
'--skin-shadow': skin.shadow,
|
|
1054
|
+
'--lip-color': skin.lip,
|
|
1055
|
+
'--hair-color': HAIR_COLORS[config.hairColor],
|
|
1056
|
+
'--eye-color': EYE_COLORS[config.eyeColor],
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function cssVarDecls(config) {
|
|
1060
|
+
return Object.entries(avatarCssVars(config))
|
|
1061
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
1062
|
+
.join(';');
|
|
1063
|
+
}
|
|
1064
|
+
/** `clip-path="url(#x)"` only when the value exists — avoids empty attrs. */
|
|
1065
|
+
function clipAttr(url) {
|
|
1066
|
+
return url ? ` clip-path="${url}"` : '';
|
|
1067
|
+
}
|
|
1068
|
+
const LASH_PATHS = [
|
|
1069
|
+
'M65,78 L62,74', 'M70,76 L68,72', 'M76,76 L78,72', 'M82,78 L85,74',
|
|
1070
|
+
'M117,78 L114,74', 'M122,76 L120,72', 'M128,76 L130,72', 'M134,78 L137,74',
|
|
1071
|
+
];
|
|
1072
|
+
/**
|
|
1073
|
+
* The avatar markup WITHOUT the outer `<svg>` element.
|
|
1074
|
+
* The preview component supplies its own root so it can keep Angular
|
|
1075
|
+
* bindings on it; the exporter wraps this in a standalone document.
|
|
1076
|
+
*/
|
|
1077
|
+
function buildAvatarSvgInner(config, opts = {}) {
|
|
1078
|
+
const { viseme = 0, animated = false, idsPrefix = '' } = opts;
|
|
1079
|
+
const isWoman = config.gender === 'woman';
|
|
1080
|
+
const astronaut = isAstronaut(config);
|
|
1081
|
+
const hair = HAIR_SHAPES[config.haircut] ?? { back: '', front: '' };
|
|
1082
|
+
const eyes = EYE_SHAPES[config.eyeStyle] ?? { sclera: '', iris: '' };
|
|
1083
|
+
// Facial hair is authored once and rescaled to whichever head is in use.
|
|
1084
|
+
// A man is untouched (the ratios are exactly 1); a woman gets the same
|
|
1085
|
+
// silhouette at the same relative position on a proportionally smaller
|
|
1086
|
+
// head, which is what the hand-drawn shapes were missing.
|
|
1087
|
+
const facialHead = faceFor(config.gender);
|
|
1088
|
+
const mustache = fitToFace(MUSTACHE_SHAPES[config.mustache] ?? '', facialHead);
|
|
1089
|
+
const beard = buildBeardSvg(config.beard, facialHead, HAIR_COLORS[config.hairColor]);
|
|
1090
|
+
const glasses = GLASSES_SHAPES[config.glasses] ?? '';
|
|
1091
|
+
const profession = PROFESSION_LAYERS[config.profession] ?? { body: '', accessory: '' };
|
|
1092
|
+
const mouthPath = MOUTH_SHAPES[viseme] ?? MOUTH_SHAPES[0];
|
|
1093
|
+
const hairClip = hairClipUrl(config, idsPrefix);
|
|
1094
|
+
const helmetClip = helmetClipUrl(config, idsPrefix);
|
|
1095
|
+
// ── defs ────────────────────────────────────────────────────────────
|
|
1096
|
+
// Face containment for facial hair. The hand-drawn beard and moustache
|
|
1097
|
+
// artwork is not perfectly authored to the jawline — `stubble` overhangs
|
|
1098
|
+
// even on a man — so rescaling alone cannot guarantee they stay on the
|
|
1099
|
+
// head. This clip is derived from the face in use and is the backstop.
|
|
1100
|
+
// The ellipse arcs over the top, then runs straight down past the chin so
|
|
1101
|
+
// a long beard may still fall onto the chest.
|
|
1102
|
+
const faceClipPath = `M${facialHead.cx - facialHead.rx},${facialHead.cy} ` +
|
|
1103
|
+
`A${facialHead.rx},${facialHead.ry} 0 0 1 ${facialHead.cx + facialHead.rx},${facialHead.cy} ` +
|
|
1104
|
+
`L${facialHead.cx + facialHead.rx},192 L${facialHead.cx - facialHead.rx},192 Z`;
|
|
1105
|
+
const defs = `<defs>` +
|
|
1106
|
+
`<clipPath id="${idsPrefix}face-clip"><path d="${faceClipPath}"/></clipPath>` +
|
|
1107
|
+
`<clipPath id="${idsPrefix}hat-clip">` +
|
|
1108
|
+
`<rect x="${HAT_CLIP_RECT.x}" y="${HAT_CLIP_RECT.y}" width="${HAT_CLIP_RECT.width}" height="${HAT_CLIP_RECT.height}"/>` +
|
|
1109
|
+
`</clipPath>` +
|
|
1110
|
+
`<clipPath id="${idsPrefix}helmet-clip">` +
|
|
1111
|
+
`<ellipse cx="${HELMET_CLIP_ELLIPSE.cx}" cy="${HELMET_CLIP_ELLIPSE.cy}" rx="${HELMET_CLIP_ELLIPSE.rx}" ry="${HELMET_CLIP_ELLIPSE.ry}"/>` +
|
|
1112
|
+
`</clipPath>` +
|
|
1113
|
+
`</defs>`;
|
|
1114
|
+
// ── 1. body ──────────────────────────────────────────────────────────
|
|
1115
|
+
const body = `<g class="layer-body">${profession.body}</g>`;
|
|
1116
|
+
// ── 2. neck (hidden for astronaut — the collar covers it) ───────────
|
|
1117
|
+
const neck = astronaut
|
|
1118
|
+
? ''
|
|
1119
|
+
: isWoman
|
|
1120
|
+
? `<rect class="layer-neck" x="90" y="135" width="20" height="22" rx="8" fill="var(--skin-base)"/>`
|
|
1121
|
+
: `<rect class="layer-neck" x="88" y="135" width="24" height="22" rx="8" fill="var(--skin-base)"/>`;
|
|
1122
|
+
// ── 3. ears ─────────────────────────────────────────────────────────
|
|
1123
|
+
const earRx = isWoman ? 7 : 8;
|
|
1124
|
+
const earRy = isWoman ? 9 : 10;
|
|
1125
|
+
const ears = `<g class="layer-ears"${clipAttr(helmetClip)}>` +
|
|
1126
|
+
`<ellipse cx="48" cy="90" rx="${earRx}" ry="${earRy}" fill="var(--skin-ear)"/>` +
|
|
1127
|
+
`<ellipse cx="152" cy="90" rx="${earRx}" ry="${earRy}" fill="var(--skin-ear)"/>` +
|
|
1128
|
+
`</g>`;
|
|
1129
|
+
// ── 4. hair back ────────────────────────────────────────────────────
|
|
1130
|
+
const hairBack = `<g class="layer-hair-back"${clipAttr(hairClip)}>${hair.back}</g>`;
|
|
1131
|
+
// ── 5. face ─────────────────────────────────────────────────────────
|
|
1132
|
+
const face = isWoman
|
|
1133
|
+
? `<ellipse class="layer-face" cx="100" cy="86" rx="49" ry="55" fill="var(--skin-base)"/>`
|
|
1134
|
+
: `<ellipse class="layer-face" cx="100" cy="88" rx="52" ry="56" fill="var(--skin-base)"/>`;
|
|
1135
|
+
// ── 6. eyes ─────────────────────────────────────────────────────────
|
|
1136
|
+
// `.pupils` is driven by --pupil-x/--pupil-y so no post-render DOM
|
|
1137
|
+
// patching is needed; see svg-avatar.component.scss.
|
|
1138
|
+
const eyeGroup = `<g class="layer-eyes">` +
|
|
1139
|
+
`<g>${eyes.sclera}</g>` +
|
|
1140
|
+
`<g class="pupils"><g>${eyes.iris}</g></g>` +
|
|
1141
|
+
`</g>`;
|
|
1142
|
+
// ── 6b. lashes (woman only) ─────────────────────────────────────────
|
|
1143
|
+
const lashes = isWoman
|
|
1144
|
+
? `<g class="layer-lashes">${LASH_PATHS.map((d) => `<path d="${d}" stroke="var(--hair-color)" stroke-width="1.5" stroke-linecap="round" fill="none"/>`).join('')}</g>`
|
|
1145
|
+
: '';
|
|
1146
|
+
// ── 7. eyelids (blink) ──────────────────────────────────────────────
|
|
1147
|
+
// The eyelids are OPAQUE skin-coloured rects painted over the eyes. The
|
|
1148
|
+
// only thing that hides them is `transform: scaleY(0)` from the consuming
|
|
1149
|
+
// stylesheet — so a standalone SVG with no stylesheet ships with its eyes
|
|
1150
|
+
// completely covered. Two defences:
|
|
1151
|
+
// 1. static output omits the layer outright, because a still image has
|
|
1152
|
+
// nothing to blink
|
|
1153
|
+
// 2. animated output carries transform="scale(1,0)" as an inline
|
|
1154
|
+
// presentation attribute, so the eyes are hidden even if the CSS
|
|
1155
|
+
// never arrives. CSS still wins over the attribute, so blinking works.
|
|
1156
|
+
const eyelids = animated
|
|
1157
|
+
? `<g class="layer-eyelids">` +
|
|
1158
|
+
`<rect class="eyelid-left" x="63" y="77" width="22" height="22" rx="11" fill="var(--skin-base)" transform="scale(1,0)"/>` +
|
|
1159
|
+
`<rect class="eyelid-right" x="115" y="77" width="22" height="22" rx="11" fill="var(--skin-base)" transform="scale(1,0)"/>` +
|
|
1160
|
+
`</g>`
|
|
1161
|
+
: '';
|
|
1162
|
+
// ── 8. brows ────────────────────────────────────────────────────────
|
|
1163
|
+
const brows = isWoman
|
|
1164
|
+
? `<g class="layer-brows">` +
|
|
1165
|
+
`<path d="M64,74 Q74,69 84,73" stroke="var(--hair-color)" stroke-width="2" fill="none" stroke-linecap="round"/>` +
|
|
1166
|
+
`<path d="M116,73 Q126,69 136,74" stroke="var(--hair-color)" stroke-width="2" fill="none" stroke-linecap="round"/>` +
|
|
1167
|
+
`</g>`
|
|
1168
|
+
: `<g class="layer-brows">` +
|
|
1169
|
+
`<rect x="63" y="72" width="22" height="5" rx="3" fill="var(--hair-color)"/>` +
|
|
1170
|
+
`<rect x="115" y="72" width="22" height="5" rx="3" fill="var(--hair-color)"/>` +
|
|
1171
|
+
`</g>`;
|
|
1172
|
+
// ── 9. nose — uses --skin-shadow so it stays visible on deep skin ───
|
|
1173
|
+
const nose = isWoman
|
|
1174
|
+
? `<path class="layer-nose" d="M98,104 Q100,110 102,104" stroke="var(--skin-shadow)" stroke-width="1.8" fill="none" stroke-linecap="round"/>`
|
|
1175
|
+
: `<path class="layer-nose" d="M97,105 Q100,112 103,105" stroke="var(--skin-shadow)" stroke-width="2" fill="none" stroke-linecap="round"/>`;
|
|
1176
|
+
// ── 10. mouth ───────────────────────────────────────────────────────
|
|
1177
|
+
// When `speech` is set the mouth carries a self-running SMIL animation,
|
|
1178
|
+
// so the file speaks on its own with no host page involved.
|
|
1179
|
+
const speech = opts.speech;
|
|
1180
|
+
const mouthAnim = speech ? mouthAnimation(speech) : '';
|
|
1181
|
+
const mouthInner = isWoman
|
|
1182
|
+
? `stroke="var(--lip-color)" stroke-width="2.5" fill="var(--lip-color)" fill-opacity="0.35" stroke-linecap="round"`
|
|
1183
|
+
: `stroke="var(--lip-color)" stroke-width="2.5" fill="none" stroke-linecap="round"`;
|
|
1184
|
+
const mouth = `<path class="layer-mouth" d="${mouthPath}" ${mouthInner}>` +
|
|
1185
|
+
mouthAnim +
|
|
1186
|
+
`</path>`;
|
|
1187
|
+
// ── 11-14 ───────────────────────────────────────────────────────────
|
|
1188
|
+
// No CSS transform: the shape is rescaled in the path data itself. The
|
|
1189
|
+
// inner groups carry the face clip and the outer the helmet clip, because
|
|
1190
|
+
// an element can only take one clip-path.
|
|
1191
|
+
const facialHair = `<g class="layer-mustache"${clipAttr(helmetClip)}>` +
|
|
1192
|
+
`<g clip-path="url(#${idsPrefix}face-clip)">${mustache}</g></g>` +
|
|
1193
|
+
`<g class="layer-beard"${clipAttr(helmetClip)}>` +
|
|
1194
|
+
`<g clip-path="url(#${idsPrefix}face-clip)">${beard}</g></g>`;
|
|
1195
|
+
const glassesGroup = `<g class="layer-glasses">${glasses}</g>`;
|
|
1196
|
+
const hairFront = `<g class="layer-hair-front"${clipAttr(hairClip)}>${hair.front}</g>`;
|
|
1197
|
+
const accessory = `<g class="layer-accessory">${profession.accessory}</g>`;
|
|
1198
|
+
const head = `<g class="layer-head"${animated ? ' animate-idle' : ''}>` + [ears, hairBack, face, eyeGroup, lashes, eyelids, brows, nose, mouth, facialHair, glassesGroup, hairFront, accessory].join('') + `</g>`;
|
|
1199
|
+
return defs + body + neck + head;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* A complete, standalone SVG document. This is what gets written to disk
|
|
1203
|
+
* and what the demo viewer renders — it must depend on nothing outside
|
|
1204
|
+
* itself, so the palette is inlined as a `style` attribute on the root.
|
|
1205
|
+
*
|
|
1206
|
+
* `animated` is forced off regardless of what the caller passed. That flag
|
|
1207
|
+
* emits the `.animate-idle` class, which is driven by the consuming
|
|
1208
|
+
* component's stylesheet; a standalone file has no stylesheet, so the class
|
|
1209
|
+
* would be a promise the document cannot keep. `speech` still applies,
|
|
1210
|
+
* because SMIL travels inside the file.
|
|
1211
|
+
*/
|
|
1212
|
+
function buildAvatarSvg(config, opts = {}) {
|
|
1213
|
+
const inner = buildAvatarSvgInner(config, { ...opts, animated: false });
|
|
1214
|
+
return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${CANVAS.viewBox}"` +
|
|
1215
|
+
` style="${cssVarDecls(config)}">` +
|
|
1216
|
+
inner +
|
|
1217
|
+
`</svg>`);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Blink, pupil drift and head-idle driving — framework-free.
|
|
1222
|
+
*
|
|
1223
|
+
* This was the body of an Angular `@Injectable()` service. Nothing in it ever
|
|
1224
|
+
* touched Angular, only `setTimeout` and `requestAnimationFrame`, so it lives
|
|
1225
|
+
* here and the Angular wrapper just delegates. The React player uses it
|
|
1226
|
+
* directly.
|
|
1227
|
+
*
|
|
1228
|
+
* Head idle is deliberately *not* here: it is a pure CSS animation, so it costs
|
|
1229
|
+
* no JavaScript and no framework re-render. See the stylesheet.
|
|
1230
|
+
*/
|
|
1231
|
+
class AvatarAnimator {
|
|
1232
|
+
timers = [];
|
|
1233
|
+
rafs = [];
|
|
1234
|
+
/**
|
|
1235
|
+
* Start random blink animation.
|
|
1236
|
+
* Calls onState with CSS class names: 'blinking' → 'blink-half' → ''
|
|
1237
|
+
* Interval: 2.5–5s random for natural feel.
|
|
1238
|
+
*/
|
|
1239
|
+
startBlink(onState) {
|
|
1240
|
+
const doBlink = () => {
|
|
1241
|
+
onState('blinking');
|
|
1242
|
+
const t1 = setTimeout(() => {
|
|
1243
|
+
onState('blink-half');
|
|
1244
|
+
const t2 = setTimeout(() => {
|
|
1245
|
+
onState('');
|
|
1246
|
+
// Schedule next blink: 2.5s–5s random interval
|
|
1247
|
+
const t3 = setTimeout(doBlink, 2500 + Math.random() * 2500);
|
|
1248
|
+
this.timers.push(t3);
|
|
1249
|
+
}, 50);
|
|
1250
|
+
this.timers.push(t2);
|
|
1251
|
+
}, 80);
|
|
1252
|
+
this.timers.push(t1);
|
|
1253
|
+
};
|
|
1254
|
+
const init = setTimeout(doBlink, 1000 + Math.random() * 1000);
|
|
1255
|
+
this.timers.push(init);
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Start random eye movement (pupil tracking).
|
|
1259
|
+
* Calls onOffset with {x, y} pixel offsets for the pupil group.
|
|
1260
|
+
*/
|
|
1261
|
+
startEyeMovement(onOffset) {
|
|
1262
|
+
const move = () => {
|
|
1263
|
+
const dx = (Math.random() - 0.5) * 8;
|
|
1264
|
+
const dy = (Math.random() - 0.5) * 4;
|
|
1265
|
+
onOffset({ x: dx, y: dy });
|
|
1266
|
+
const t = setTimeout(move, 1800 + Math.random() * 2200);
|
|
1267
|
+
this.timers.push(t);
|
|
1268
|
+
};
|
|
1269
|
+
const init = setTimeout(move, 500);
|
|
1270
|
+
this.timers.push(init);
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Start subtle head idle motion via requestAnimationFrame.
|
|
1274
|
+
* Produces a gentle sine-wave rotation + vertical bob.
|
|
1275
|
+
*/
|
|
1276
|
+
startHeadIdle(onTransform) {
|
|
1277
|
+
const start = performance.now();
|
|
1278
|
+
const tick = (now) => {
|
|
1279
|
+
const t = (now - start) / 1000;
|
|
1280
|
+
const rotZ = Math.sin(t * 0.6) * 1.5;
|
|
1281
|
+
const transY = Math.sin(t * 0.4) * 1.5;
|
|
1282
|
+
onTransform(`rotate(${rotZ}, 100, 150) translate(0, ${transY})`);
|
|
1283
|
+
this.rafs.push(requestAnimationFrame(tick));
|
|
1284
|
+
};
|
|
1285
|
+
this.rafs.push(requestAnimationFrame(tick));
|
|
1286
|
+
}
|
|
1287
|
+
/** Stop all running animations and clear timers/rafs. */
|
|
1288
|
+
stopAll() {
|
|
1289
|
+
this.timers.forEach(clearTimeout);
|
|
1290
|
+
this.rafs.forEach(cancelAnimationFrame);
|
|
1291
|
+
this.timers = [];
|
|
1292
|
+
this.rafs = [];
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Drives visemes in real time — framework-free.
|
|
1298
|
+
*
|
|
1299
|
+
* The text -> viseme mapping itself lives in ./visemes because the offline SVG
|
|
1300
|
+
* generator needs the exact same mapping to bake an animated mouth into an
|
|
1301
|
+
* exported file. Keeping one copy is the whole point — if this drifted from
|
|
1302
|
+
* the generator, exported avatars would mouth different words than the preview
|
|
1303
|
+
* does.
|
|
1304
|
+
*/
|
|
1305
|
+
class LipSyncPlayer {
|
|
1306
|
+
MS_PER_VISEME = MS_PER_VISEME;
|
|
1307
|
+
/** @see textToVisemes */
|
|
1308
|
+
textToVisemes(text) {
|
|
1309
|
+
return textToVisemes(text);
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Play a viseme sequence, calling onViseme(id) each frame.
|
|
1313
|
+
*
|
|
1314
|
+
* `onEnd` fires exactly once when the sequence finishes or is cancelled, so
|
|
1315
|
+
* callers can clear a "speaking" flag reliably. Do NOT infer the end from
|
|
1316
|
+
* onViseme(0): viseme 0 is silence and occurs at every space, so it would
|
|
1317
|
+
* end the utterance at the first word gap.
|
|
1318
|
+
*
|
|
1319
|
+
* Returns a cancel function. Always resets to 0 (silence) on end or cancel.
|
|
1320
|
+
*/
|
|
1321
|
+
play(visemes, onViseme, onEnd) {
|
|
1322
|
+
let i = 0;
|
|
1323
|
+
let cancelled = false;
|
|
1324
|
+
let finished = false;
|
|
1325
|
+
const finish = () => {
|
|
1326
|
+
if (finished)
|
|
1327
|
+
return;
|
|
1328
|
+
finished = true;
|
|
1329
|
+
onViseme(0); // reset to silence
|
|
1330
|
+
onEnd?.();
|
|
1331
|
+
};
|
|
1332
|
+
const tick = () => {
|
|
1333
|
+
if (cancelled || i >= visemes.length) {
|
|
1334
|
+
finish();
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
onViseme(visemes[i++]);
|
|
1338
|
+
setTimeout(tick, this.MS_PER_VISEME);
|
|
1339
|
+
};
|
|
1340
|
+
tick();
|
|
1341
|
+
return () => {
|
|
1342
|
+
cancelled = true;
|
|
1343
|
+
finish();
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Public entry point of the avatar geometry.
|
|
1350
|
+
*
|
|
1351
|
+
* WHY THIS LIVES INSIDE THE ANGULAR LIBRARY
|
|
1352
|
+
*
|
|
1353
|
+
* The code below is plain TypeScript with no framework imports — it could sit in
|
|
1354
|
+
* a package of its own, and that is where it used to live. It lives here
|
|
1355
|
+
* because of a hard limit in ng-packagr:
|
|
1356
|
+
*
|
|
1357
|
+
* - ng-packagr treats EVERY bare specifier as external
|
|
1358
|
+
* (`isExternalDependency`, ng-packagr/lib/flatten/rollup.js), so a
|
|
1359
|
+
* `from 'avatar-core'` import ships unresolved and breaks the tarball.
|
|
1360
|
+
* - a relative import into another package does not work either: ng-packagr
|
|
1361
|
+
* compiles src/ -> dist/avatar-player/esm2022/ first and only then bundles,
|
|
1362
|
+
* so a path valid from src/ points at nothing from the intermediate dir.
|
|
1363
|
+
*
|
|
1364
|
+
* Living inside the library means ng-packagr compiles it as first-party source
|
|
1365
|
+
* and inlines it into the published bundle. The React package imports these
|
|
1366
|
+
* same files with a relative specifier at build time, so there is still exactly
|
|
1367
|
+
* ONE copy of the geometry in the repository and in CI.
|
|
1368
|
+
*
|
|
1369
|
+
* Consequence: consumers of angular-avatar-player and react-avatar-player get
|
|
1370
|
+
* their own copy of the renderer inside the tarball. That is deliberate — it
|
|
1371
|
+
* means `npm i angular-avatar-player` is a single, self-contained install.
|
|
1372
|
+
*/
|
|
1373
|
+
// ── Color palettes ──────────────────────────────────────────
|
|
1374
|
+
|
|
1375
|
+
// libs/avatar-player/src/lib/services/lip-sync.service.ts
|
|
1376
|
+
/**
|
|
1377
|
+
* Angular-facing wrapper around the framework-free {@link LipSyncPlayer}.
|
|
1378
|
+
*
|
|
1379
|
+
* The logic lives in ../geometry so the React player and any non-Angular
|
|
1380
|
+
* renderer use the exact same mapping. This class exists only so Angular apps
|
|
1381
|
+
* can inject it and so DI gives each player component its own instance.
|
|
1382
|
+
*/
|
|
1383
|
+
class LipSyncService extends LipSyncPlayer {
|
|
1384
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LipSyncService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
1385
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LipSyncService });
|
|
1386
|
+
}
|
|
1387
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: LipSyncService, decorators: [{
|
|
1388
|
+
type: Injectable
|
|
1389
|
+
}] });
|
|
1390
|
+
|
|
1391
|
+
// libs/avatar-player/src/lib/services/avatar-animation.service.ts
|
|
1392
|
+
/**
|
|
1393
|
+
* Angular-facing wrapper around the framework-free {@link AvatarAnimator}.
|
|
1394
|
+
*
|
|
1395
|
+
* The timers live in ../geometry so the React player can drive the same blink
|
|
1396
|
+
* and pupil logic. This class exists only so Angular apps can inject it and so
|
|
1397
|
+
* DI scopes one animator per component — a shared instance would mean one avatar
|
|
1398
|
+
* leaving the DOM stops blinking for every other avatar on the page.
|
|
1399
|
+
*/
|
|
1400
|
+
class AvatarAnimationService extends AvatarAnimator {
|
|
1401
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarAnimationService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
|
|
1402
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarAnimationService });
|
|
1403
|
+
}
|
|
1404
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarAnimationService, decorators: [{
|
|
1405
|
+
type: Injectable
|
|
1406
|
+
}] });
|
|
1407
|
+
|
|
1408
|
+
// libs/avatar-player/src/lib/components/svg-avatar/svg-avatar.component.ts
|
|
1409
|
+
class SvgAvatarComponent {
|
|
1410
|
+
// ── Signal inputs ────────────────────────────────────────────
|
|
1411
|
+
//
|
|
1412
|
+
// These were `@Input` fields mirrored into signals via ngOnChanges.
|
|
1413
|
+
// That indirection was the root cause of four separate bugs: a `computed`
|
|
1414
|
+
// or `effect` that reads a plain property registers no dependency, so it
|
|
1415
|
+
// evaluates once and never invalidates. The symptoms were a frozen avatar,
|
|
1416
|
+
// permanently disabled undo/redo, and a dead Test Speech button.
|
|
1417
|
+
//
|
|
1418
|
+
// Signal inputs make that mistake impossible: reading `config()` inside a
|
|
1419
|
+
// computed registers a real dependency, so the derived values below cannot
|
|
1420
|
+
// silently go stale.
|
|
1421
|
+
config = input.required();
|
|
1422
|
+
animationsEnabled = input(true);
|
|
1423
|
+
/** 0–5, controlled externally for lip sync. */
|
|
1424
|
+
viseme = input(0);
|
|
1425
|
+
/**
|
|
1426
|
+
* Namespace for generated element ids. Set this when inlining more than
|
|
1427
|
+
* one avatar into a document, otherwise every avatar's `url(#hat-clip)`
|
|
1428
|
+
* resolves to the first matching element on the page.
|
|
1429
|
+
*/
|
|
1430
|
+
idsPrefix = input('');
|
|
1431
|
+
sanitizer = inject(DomSanitizer);
|
|
1432
|
+
host = inject(ElementRef);
|
|
1433
|
+
zone = inject(NgZone);
|
|
1434
|
+
anim = inject(AvatarAnimationService);
|
|
1435
|
+
viewBox = CANVAS.viewBox;
|
|
1436
|
+
// Animation state (driven by AvatarAnimationService)
|
|
1437
|
+
eyeBlinkClass = signal('');
|
|
1438
|
+
pupilOffset = signal({ x: 0, y: 0 });
|
|
1439
|
+
/**
|
|
1440
|
+
* Palette plus the two pupil-offset custom properties the SCSS reads.
|
|
1441
|
+
*
|
|
1442
|
+
* A computed, not a getter: a getter returns a new SafeStyle on every
|
|
1443
|
+
* change-detection pass, so Angular re-wrote the style attribute each
|
|
1444
|
+
* cycle. A getter also cannot track `config`, so it went stale too.
|
|
1445
|
+
*/
|
|
1446
|
+
cssVarsStyle = computed(() => {
|
|
1447
|
+
const p = this.pupilOffset();
|
|
1448
|
+
const decls = `--pupil-x: ${p.x}px; --pupil-y: ${p.y}px; ` +
|
|
1449
|
+
Object.entries(avatarCssVars(this.config()))
|
|
1450
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
1451
|
+
.join('; ');
|
|
1452
|
+
return this.sanitizer.bypassSecurityTrustStyle(decls);
|
|
1453
|
+
});
|
|
1454
|
+
/**
|
|
1455
|
+
* The markup is regenerated only when the *config* changes — deliberately
|
|
1456
|
+
* NOT when the viseme does. Re-assigning innerHTML mid-utterance would
|
|
1457
|
+
* recreate `.layer-head` and restart the head-idle animation every 80ms.
|
|
1458
|
+
* Viseme changes are applied to the existing mouth path below instead.
|
|
1459
|
+
*/
|
|
1460
|
+
innerHtml = computed(() => this.sanitizer.bypassSecurityTrustHtml(buildAvatarSvgInner(this.config(), {
|
|
1461
|
+
animated: this.animationsEnabled(),
|
|
1462
|
+
idsPrefix: this.idsPrefix(),
|
|
1463
|
+
})));
|
|
1464
|
+
// Public shape helpers — the geometry itself lives in lib/shared.
|
|
1465
|
+
isAstronaut = computed(() => isAstronaut(this.config()));
|
|
1466
|
+
hasHat = computed(() => hasHat(this.config()));
|
|
1467
|
+
hairClipUrl = computed(() => hairClipUrl(this.config(), this.idsPrefix()));
|
|
1468
|
+
helmetClipUrl = computed(() => helmetClipUrl(this.config(), this.idsPrefix()));
|
|
1469
|
+
mouthPath = computed(() => MOUTH_SHAPES[this.viseme()] ?? MOUTH_SHAPES[0]);
|
|
1470
|
+
constructor() {
|
|
1471
|
+
// Keep the mouth in sync without re-rendering the whole avatar.
|
|
1472
|
+
effect(() => {
|
|
1473
|
+
// Both reads are signal-tracked: mouthPath so the mouth follows lip
|
|
1474
|
+
// sync, innerHtml so the path is re-applied after each markup swap,
|
|
1475
|
+
// when the fresh .layer-mouth element is guaranteed to be in the DOM.
|
|
1476
|
+
const path = this.mouthPath();
|
|
1477
|
+
this.innerHtml();
|
|
1478
|
+
this.applyMouthPath(path);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
applyMouthPath(path) {
|
|
1482
|
+
const mouth = this.host.nativeElement.querySelector('.layer-mouth');
|
|
1483
|
+
if (mouth) {
|
|
1484
|
+
mouth.setAttribute('d', path);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
// Markup was swapped this tick and the element is not queryable yet.
|
|
1488
|
+
requestAnimationFrame(() => {
|
|
1489
|
+
this.host.nativeElement
|
|
1490
|
+
.querySelector('.layer-mouth')
|
|
1491
|
+
?.setAttribute('d', path);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
ngOnInit() {
|
|
1495
|
+
if (this.animationsEnabled()) {
|
|
1496
|
+
this.zone.runOutsideAngular(() => {
|
|
1497
|
+
this.anim.startBlink((v) => this.zone.run(() => this.eyeBlinkClass.set(v)));
|
|
1498
|
+
this.anim.startEyeMovement((v) => this.zone.run(() => this.pupilOffset.set(v)));
|
|
1499
|
+
// Head idle is a pure CSS animation — no JS needed
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
ngOnDestroy() {
|
|
1504
|
+
this.anim.stopAll();
|
|
1505
|
+
}
|
|
1506
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SvgAvatarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1507
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.18", type: SvgAvatarComponent, isStandalone: true, selector: "app-svg-avatar", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null }, animationsEnabled: { classPropertyName: "animationsEnabled", publicName: "animationsEnabled", isSignal: true, isRequired: false, transformFunction: null }, viseme: { classPropertyName: "viseme", publicName: "viseme", isSignal: true, isRequired: false, transformFunction: null }, idsPrefix: { classPropertyName: "idsPrefix", publicName: "idsPrefix", isSignal: true, isRequired: false, transformFunction: null } }, providers: [AvatarAnimationService], ngImport: i0, template: `<svg xmlns="http://www.w3.org/2000/svg"
|
|
1508
|
+
[attr.viewBox]="viewBox"
|
|
1509
|
+
class="avatar-svg"
|
|
1510
|
+
[class.blink-blinking]="eyeBlinkClass() === 'blinking'"
|
|
1511
|
+
[class.blink-half]="eyeBlinkClass() === 'blink-half'"
|
|
1512
|
+
[style]="cssVarsStyle()"
|
|
1513
|
+
[innerHTML]="innerHtml()"></svg>`, isInline: true, styles: [".avatar-svg{display:block;width:100%;height:100%;overflow:visible;shape-rendering:geometricPrecision}.avatar-svg *{transform-box:fill-box}.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transform-origin:center center;transform:scaleY(0);transition:transform .05s linear}.avatar-svg.blink-blinking .eyelid-left,.avatar-svg.blink-blinking .eyelid-right{transform:scaleY(1)}.avatar-svg.blink-half .eyelid-left,.avatar-svg.blink-half .eyelid-right{transform:scaleY(.5)}.avatar-svg .layer-head{transform-origin:100px 150px}.avatar-svg .layer-head.animate-idle{animation:avatar-head-idle 8s ease-in-out infinite;will-change:transform}.avatar-svg .pupils{transform:translate(var(--pupil-x, 0px),var(--pupil-y, 0px));transition:transform .4s cubic-bezier(.25,.46,.45,.94);will-change:transform}.avatar-svg .layer-mouth{transition:d .06s linear}@keyframes avatar-head-idle{0%,to{transform:rotate(0) translate(0)}20%{transform:rotate(1.2deg) translateY(-1.2px)}50%{transform:rotate(-.8deg) translateY(.8px)}70%{transform:rotate(1.5deg) translateY(-1.5px)}}@media (prefers-reduced-motion: reduce){.avatar-svg .layer-head{animation:none!important;transform:none!important}.avatar-svg .pupils,.avatar-svg .layer-mouth,.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transition:none}}\n"], encapsulation: i0.ViewEncapsulation.None });
|
|
1514
|
+
}
|
|
1515
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SvgAvatarComponent, decorators: [{
|
|
1516
|
+
type: Component,
|
|
1517
|
+
args: [{ selector: 'app-svg-avatar', standalone: true, encapsulation: ViewEncapsulation.None, providers: [AvatarAnimationService], template: `<svg xmlns="http://www.w3.org/2000/svg"
|
|
1518
|
+
[attr.viewBox]="viewBox"
|
|
1519
|
+
class="avatar-svg"
|
|
1520
|
+
[class.blink-blinking]="eyeBlinkClass() === 'blinking'"
|
|
1521
|
+
[class.blink-half]="eyeBlinkClass() === 'blink-half'"
|
|
1522
|
+
[style]="cssVarsStyle()"
|
|
1523
|
+
[innerHTML]="innerHtml()"></svg>`, styles: [".avatar-svg{display:block;width:100%;height:100%;overflow:visible;shape-rendering:geometricPrecision}.avatar-svg *{transform-box:fill-box}.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transform-origin:center center;transform:scaleY(0);transition:transform .05s linear}.avatar-svg.blink-blinking .eyelid-left,.avatar-svg.blink-blinking .eyelid-right{transform:scaleY(1)}.avatar-svg.blink-half .eyelid-left,.avatar-svg.blink-half .eyelid-right{transform:scaleY(.5)}.avatar-svg .layer-head{transform-origin:100px 150px}.avatar-svg .layer-head.animate-idle{animation:avatar-head-idle 8s ease-in-out infinite;will-change:transform}.avatar-svg .pupils{transform:translate(var(--pupil-x, 0px),var(--pupil-y, 0px));transition:transform .4s cubic-bezier(.25,.46,.45,.94);will-change:transform}.avatar-svg .layer-mouth{transition:d .06s linear}@keyframes avatar-head-idle{0%,to{transform:rotate(0) translate(0)}20%{transform:rotate(1.2deg) translateY(-1.2px)}50%{transform:rotate(-.8deg) translateY(.8px)}70%{transform:rotate(1.5deg) translateY(-1.5px)}}@media (prefers-reduced-motion: reduce){.avatar-svg .layer-head{animation:none!important;transform:none!important}.avatar-svg .pupils,.avatar-svg .layer-mouth,.avatar-svg .eyelid-left,.avatar-svg .eyelid-right{transition:none}}\n"] }]
|
|
1524
|
+
}], ctorParameters: () => [] });
|
|
1525
|
+
|
|
1526
|
+
// libs/avatar-player/src/lib/components/avatar-player/avatar-player.component.ts
|
|
1527
|
+
class AvatarPlayerComponent {
|
|
1528
|
+
config;
|
|
1529
|
+
speaking = false;
|
|
1530
|
+
message = '';
|
|
1531
|
+
/**
|
|
1532
|
+
* Namespace for the generated `clipPath` ids.
|
|
1533
|
+
*
|
|
1534
|
+
* REQUIRED when more than one player is on a page. Every avatar emits
|
|
1535
|
+
* `url(#hat-clip)`, which resolves against the *first* matching element in
|
|
1536
|
+
* the document — so without a unique prefix per avatar they all clip against
|
|
1537
|
+
* whichever came first, and hats and helmets stop hiding hair.
|
|
1538
|
+
*/
|
|
1539
|
+
idsPrefix = '';
|
|
1540
|
+
/** Base edge length in px. It grows while speaking. */
|
|
1541
|
+
size = 48;
|
|
1542
|
+
currentViseme = signal(0);
|
|
1543
|
+
lipSync = inject(LipSyncService);
|
|
1544
|
+
zone = inject(NgZone);
|
|
1545
|
+
lipSyncCancelFn = null;
|
|
1546
|
+
ngOnChanges(changes) {
|
|
1547
|
+
if (changes['speaking'] || changes['message']) {
|
|
1548
|
+
// Always cancel previous playback before starting new
|
|
1549
|
+
this.lipSyncCancelFn?.();
|
|
1550
|
+
if (this.speaking && this.message) {
|
|
1551
|
+
const visemes = this.lipSync.textToVisemes(this.message);
|
|
1552
|
+
this.lipSyncCancelFn = this.lipSync.play(visemes, (v) => {
|
|
1553
|
+
this.zone.run(() => this.currentViseme.set(v));
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
this.currentViseme.set(0);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/** Speaking reads as a small "leaning in", so the avatar grows. */
|
|
1562
|
+
displaySize() {
|
|
1563
|
+
return this.speaking ? this.size * 1.5 : this.size;
|
|
1564
|
+
}
|
|
1565
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1566
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.18", type: AvatarPlayerComponent, isStandalone: true, selector: "app-avatar-player", inputs: { config: "config", speaking: "speaking", message: "message", idsPrefix: "idsPrefix", size: "size" }, providers: [LipSyncService], usesOnChanges: true, ngImport: i0, template: `
|
|
1567
|
+
<div class="avatar-player" [class.avatar-player--speaking]="speaking">
|
|
1568
|
+
<app-svg-avatar
|
|
1569
|
+
[config]="config"
|
|
1570
|
+
[animationsEnabled]="true"
|
|
1571
|
+
[viseme]="currentViseme()"
|
|
1572
|
+
[idsPrefix]="idsPrefix"
|
|
1573
|
+
[style.width.px]="displaySize()"
|
|
1574
|
+
[style.height.px]="displaySize()"
|
|
1575
|
+
class="player-avatar" />
|
|
1576
|
+
</div>
|
|
1577
|
+
`, isInline: true, styles: [".avatar-player{display:inline-flex;position:relative}.avatar-player .player-avatar{border-radius:50%;transition:width .2s ease,height .2s ease}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SvgAvatarComponent, selector: "app-svg-avatar", inputs: ["config", "animationsEnabled", "viseme", "idsPrefix"] }] });
|
|
1578
|
+
}
|
|
1579
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerComponent, decorators: [{
|
|
1580
|
+
type: Component,
|
|
1581
|
+
args: [{ selector: 'app-avatar-player', standalone: true, imports: [CommonModule, SvgAvatarComponent], providers: [LipSyncService], template: `
|
|
1582
|
+
<div class="avatar-player" [class.avatar-player--speaking]="speaking">
|
|
1583
|
+
<app-svg-avatar
|
|
1584
|
+
[config]="config"
|
|
1585
|
+
[animationsEnabled]="true"
|
|
1586
|
+
[viseme]="currentViseme()"
|
|
1587
|
+
[idsPrefix]="idsPrefix"
|
|
1588
|
+
[style.width.px]="displaySize()"
|
|
1589
|
+
[style.height.px]="displaySize()"
|
|
1590
|
+
class="player-avatar" />
|
|
1591
|
+
</div>
|
|
1592
|
+
`, styles: [".avatar-player{display:inline-flex;position:relative}.avatar-player .player-avatar{border-radius:50%;transition:width .2s ease,height .2s ease}\n"] }]
|
|
1593
|
+
}], propDecorators: { config: [{
|
|
1594
|
+
type: Input,
|
|
1595
|
+
args: [{ required: true }]
|
|
1596
|
+
}], speaking: [{
|
|
1597
|
+
type: Input
|
|
1598
|
+
}], message: [{
|
|
1599
|
+
type: Input
|
|
1600
|
+
}], idsPrefix: [{
|
|
1601
|
+
type: Input
|
|
1602
|
+
}], size: [{
|
|
1603
|
+
type: Input
|
|
1604
|
+
}] } });
|
|
1605
|
+
|
|
1606
|
+
// libs/avatar-player/src/lib/avatar-player.module.ts
|
|
1607
|
+
class AvatarPlayerModule {
|
|
1608
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
1609
|
+
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, imports: [CommonModule,
|
|
1610
|
+
// Standalone components imported into the module
|
|
1611
|
+
SvgAvatarComponent,
|
|
1612
|
+
AvatarPlayerComponent], exports: [
|
|
1613
|
+
// Only these two need to be exported for consumers
|
|
1614
|
+
SvgAvatarComponent,
|
|
1615
|
+
AvatarPlayerComponent] });
|
|
1616
|
+
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, providers: [
|
|
1617
|
+
LipSyncService,
|
|
1618
|
+
AvatarAnimationService,
|
|
1619
|
+
], imports: [CommonModule,
|
|
1620
|
+
AvatarPlayerComponent] });
|
|
1621
|
+
}
|
|
1622
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: AvatarPlayerModule, decorators: [{
|
|
1623
|
+
type: NgModule,
|
|
1624
|
+
args: [{
|
|
1625
|
+
imports: [
|
|
1626
|
+
CommonModule,
|
|
1627
|
+
// Standalone components imported into the module
|
|
1628
|
+
SvgAvatarComponent,
|
|
1629
|
+
AvatarPlayerComponent,
|
|
1630
|
+
],
|
|
1631
|
+
exports: [
|
|
1632
|
+
// Only these two need to be exported for consumers
|
|
1633
|
+
SvgAvatarComponent,
|
|
1634
|
+
AvatarPlayerComponent,
|
|
1635
|
+
],
|
|
1636
|
+
providers: [
|
|
1637
|
+
LipSyncService,
|
|
1638
|
+
AvatarAnimationService,
|
|
1639
|
+
],
|
|
1640
|
+
}]
|
|
1641
|
+
}] });
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Single public entry point of the Angular avatar package.
|
|
1645
|
+
*
|
|
1646
|
+
* A consuming app only ever imports from here:
|
|
1647
|
+
*
|
|
1648
|
+
* import { AvatarPlayerComponent } from 'angular-avatar-player';
|
|
1649
|
+
*
|
|
1650
|
+
* The SVG geometry, palettes and viseme mapping live in ./lib/geometry — plain
|
|
1651
|
+
* TypeScript with no Angular imports, but shipped inside this package. They are
|
|
1652
|
+
* re-exported below, so there is one import path and a consumer never needs a
|
|
1653
|
+
* second package to reach the renderer. `react-avatar-player` compiles the same
|
|
1654
|
+
* geometry files into its own bundle, which is what keeps the two players from
|
|
1655
|
+
* drifting apart.
|
|
1656
|
+
*/
|
|
1657
|
+
// ── Components ──────────────────────────────────────────────
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Generated bundle index. Do not edit.
|
|
1661
|
+
*/
|
|
1662
|
+
|
|
1663
|
+
export { AvatarAnimationService, AvatarAnimator, AvatarPlayerComponent, AvatarPlayerModule, BEARD_SHAPES, CANVAS, EYE_COLORS, EYE_SHAPES, GLASSES_SHAPES, HAIR_COLORS, HAIR_SHAPES, HAT_PROFESSIONS, HELMET_PROFESSIONS, LipSyncPlayer, LipSyncService, MAN_FACE, MOUTH_SHAPES, MS_PER_VISEME, MUSTACHE_SHAPES, PROFESSION_LAYERS, SKIN_TONES, SvgAvatarComponent, VISEME_PRESSED, VISEME_ROUND, VISEME_SILENCE, VISEME_SPREAD, VISEME_TEETH, VISEME_WIDE, WOMAN_FACE, avatarCssVars, buildAvatarSvg, buildAvatarSvgInner, buildBeardSvg, buildTraitPreview, faceFor, faceHalfWidthAt, fitToFace, hairClipUrl, hasHat, helmetClipUrl, isAstronaut, textToVisemes };
|
|
1664
|
+
//# sourceMappingURL=angular-avatar-player.mjs.map
|