mochi-avatar 1.0.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.
Files changed (96) hide show
  1. package/BRAND.md +85 -0
  2. package/LICENSE.md +104 -0
  3. package/README.md +135 -0
  4. package/dist/canvas2d/avatar.d.ts +264 -0
  5. package/dist/canvas2d/avatar.d.ts.map +1 -0
  6. package/dist/canvas2d/avatar.js +811 -0
  7. package/dist/canvas2d/avatar.js.map +1 -0
  8. package/dist/canvas2d/face.d.ts +74 -0
  9. package/dist/canvas2d/face.d.ts.map +1 -0
  10. package/dist/canvas2d/face.js +298 -0
  11. package/dist/canvas2d/face.js.map +1 -0
  12. package/dist/canvas2d/paths.d.ts +13 -0
  13. package/dist/canvas2d/paths.d.ts.map +1 -0
  14. package/dist/canvas2d/paths.js +24 -0
  15. package/dist/canvas2d/paths.js.map +1 -0
  16. package/dist/characters/colourways.d.ts +78 -0
  17. package/dist/characters/colourways.d.ts.map +1 -0
  18. package/dist/characters/colourways.js +76 -0
  19. package/dist/characters/colourways.js.map +1 -0
  20. package/dist/characters/index.d.ts +8 -0
  21. package/dist/characters/index.d.ts.map +1 -0
  22. package/dist/characters/index.js +8 -0
  23. package/dist/characters/index.js.map +1 -0
  24. package/dist/characters/mochi.d.ts +19 -0
  25. package/dist/characters/mochi.d.ts.map +1 -0
  26. package/dist/characters/mochi.js +66 -0
  27. package/dist/characters/mochi.js.map +1 -0
  28. package/dist/core/colour.d.ts +25 -0
  29. package/dist/core/colour.d.ts.map +1 -0
  30. package/dist/core/colour.js +37 -0
  31. package/dist/core/colour.js.map +1 -0
  32. package/dist/core/envelope.d.ts +153 -0
  33. package/dist/core/envelope.d.ts.map +1 -0
  34. package/dist/core/envelope.js +141 -0
  35. package/dist/core/envelope.js.map +1 -0
  36. package/dist/core/geometry.d.ts +106 -0
  37. package/dist/core/geometry.d.ts.map +1 -0
  38. package/dist/core/geometry.js +157 -0
  39. package/dist/core/geometry.js.map +1 -0
  40. package/dist/core/idle.d.ts +163 -0
  41. package/dist/core/idle.d.ts.map +1 -0
  42. package/dist/core/idle.js +262 -0
  43. package/dist/core/idle.js.map +1 -0
  44. package/dist/core/layout.d.ts +142 -0
  45. package/dist/core/layout.d.ts.map +1 -0
  46. package/dist/core/layout.js +172 -0
  47. package/dist/core/layout.js.map +1 -0
  48. package/dist/core/lens.d.ts +47 -0
  49. package/dist/core/lens.d.ts.map +1 -0
  50. package/dist/core/lens.js +67 -0
  51. package/dist/core/lens.js.map +1 -0
  52. package/dist/core/looks.d.ts +77 -0
  53. package/dist/core/looks.d.ts.map +1 -0
  54. package/dist/core/looks.js +168 -0
  55. package/dist/core/looks.js.map +1 -0
  56. package/dist/core/motion.d.ts +168 -0
  57. package/dist/core/motion.d.ts.map +1 -0
  58. package/dist/core/motion.js +527 -0
  59. package/dist/core/motion.js.map +1 -0
  60. package/dist/core/mouth.d.ts +93 -0
  61. package/dist/core/mouth.d.ts.map +1 -0
  62. package/dist/core/mouth.js +92 -0
  63. package/dist/core/mouth.js.map +1 -0
  64. package/dist/core/plain.d.ts +23 -0
  65. package/dist/core/plain.d.ts.map +1 -0
  66. package/dist/core/plain.js +63 -0
  67. package/dist/core/plain.js.map +1 -0
  68. package/dist/core/spec.d.ts +156 -0
  69. package/dist/core/spec.d.ts.map +1 -0
  70. package/dist/core/spec.js +236 -0
  71. package/dist/core/spec.js.map +1 -0
  72. package/dist/core/spring.d.ts +53 -0
  73. package/dist/core/spring.d.ts.map +1 -0
  74. package/dist/core/spring.js +68 -0
  75. package/dist/core/spring.js.map +1 -0
  76. package/dist/core/vocabulary.d.ts +182 -0
  77. package/dist/core/vocabulary.d.ts.map +1 -0
  78. package/dist/core/vocabulary.js +66 -0
  79. package/dist/core/vocabulary.js.map +1 -0
  80. package/dist/element/dough-avatar.d.ts +34 -0
  81. package/dist/element/dough-avatar.d.ts.map +1 -0
  82. package/dist/element/dough-avatar.js +189 -0
  83. package/dist/element/dough-avatar.js.map +1 -0
  84. package/dist/element/index.d.ts +3 -0
  85. package/dist/element/index.d.ts.map +1 -0
  86. package/dist/element/index.js +35 -0
  87. package/dist/element/index.js.map +1 -0
  88. package/dist/index.d.ts +38 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +42 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/svg/silhouette.d.ts +97 -0
  93. package/dist/svg/silhouette.d.ts.map +1 -0
  94. package/dist/svg/silhouette.js +143 -0
  95. package/dist/svg/silhouette.js.map +1 -0
  96. package/package.json +90 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The single writer to her mouth.
3
+ *
4
+ * One object owns `setMouthOpen`, and that is the whole point rather than a
5
+ * tidiness preference. The mouth is layer 4 and is written last, so nothing
6
+ * above it can arbitrate between two sources -- two writers produce a mouth
7
+ * flickering between them at whatever rate they happen to disagree, and no
8
+ * layer ordering can fix it.
9
+ *
10
+ * ## Where the viseme path goes
11
+ *
12
+ * The mouth has two paths and they are not a ladder: cloud speech-to-speech
13
+ * carries no phoneme timings, so the envelope is the only thing available
14
+ * there, not a fallback. The five-vowel path belongs to a local TTS front end,
15
+ * whose G2P stage produces timings for free.
16
+ *
17
+ * There is deliberately no second implementation and no factory to choose
18
+ * between them YET. Nothing emits phoneme timings on the current road and
19
+ * `MochiAvatar.caps.visemes` is false, so a `VisemeMouth` would be an
20
+ * abstraction over one caller and a capability nobody can satisfy. When a
21
+ * provider reports `phonemeTimings`, it arrives as a second class behind this
22
+ * same interface and the choice is made ONCE -- by picking an object, never by
23
+ * a flag read inside a callback, which would be a branch in every frame and a
24
+ * place for the wrong path to be taken.
25
+ *
26
+ * ## The gate is THREE conditions, not one
27
+ *
28
+ * When that day comes, the precise path requires all of:
29
+ *
30
+ * 1. the provider emits phoneme timings,
31
+ * 2. the backend can render visemes (`caps.visemes`), and
32
+ * 3. THE LOADED MODEL carries every one of the five vowel presets.
33
+ *
34
+ * The third is the one that gets dropped, and it is the expensive one. VRM's
35
+ * vowel presets are optional per model, so a capable backend can load a model
36
+ * carrying none: routing on caps alone sends weights nowhere and leaves the
37
+ * mouth shut mid-word, intermittently, with every flag reporting success. RMS
38
+ * over the whole utterance is visibly better than four fifths of a mouth.
39
+ *
40
+ * mochi locked all three into `canUseVisemes()` in shared/avatar.ts. It is not
41
+ * ported here because there is nothing yet to call it -- port it together with
42
+ * the second driver, not before.
43
+ */
44
+ import { DEFAULT_ENVELOPE, SILENT, advanceEnvelope } from './envelope.js';
45
+ /**
46
+ * The RMS path.
47
+ *
48
+ * Driven once per rendered frame rather than per audio callback. Under a peer
49
+ * connection her voice is a remote MediaStream the browser plays itself -- no
50
+ * PCM reaches this process at all -- so the level arrives by sampling an
51
+ * analyser, which is naturally a per-frame operation. At 60Hz that is a 16ms
52
+ * sampling interval against a ~4Hz syllable rate, which is ample.
53
+ */
54
+ export class EnvelopeMouth {
55
+ avatar;
56
+ settings;
57
+ state = SILENT;
58
+ constructor(avatar, settings = DEFAULT_ENVELOPE) {
59
+ this.avatar = avatar;
60
+ this.settings = settings;
61
+ }
62
+ observe(level, dtSeconds) {
63
+ this.state = advanceEnvelope(level, this.state, dtSeconds, this.settings);
64
+ this.avatar.setMouthOpen(this.state.mouthOpen);
65
+ }
66
+ /**
67
+ * Whether the envelope currently judges the signal to be speech.
68
+ *
69
+ * The one place anything outside the rig should ask that question. Reading it
70
+ * here rather than re-deriving it from a level means the mouth and the
71
+ * "is she still talking" decision can never disagree -- and they did, when
72
+ * the caller compared a raw RMS against a constant while this compared a peak
73
+ * against a learned floor.
74
+ */
75
+ get speaking() {
76
+ return this.state.speaking;
77
+ }
78
+ /**
79
+ * Shut, and forget the level -- but KEEP the learned floor and peak.
80
+ *
81
+ * The references are what make the envelope level-independent, and they are
82
+ * properties of this voice on this connection, not of this turn. Discarding
83
+ * them at every turn boundary would make her first syllable back a
84
+ * recalibration, which is visible: the mouth either slams or sits closed
85
+ * while the estimate catches up.
86
+ */
87
+ end() {
88
+ this.state = { ...this.state, mouthOpen: 0 };
89
+ this.avatar.setMouthOpen(0);
90
+ }
91
+ }
92
+ //# sourceMappingURL=mouth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mouth.js","sourceRoot":"","sources":["../../src/core/mouth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAGH,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,eAAe,EAAyB,MAAM,eAAe,CAAA;AAehG;;;;;;;;GAQG;AACH,MAAM,OAAO,aAAa;IAIL,MAAM;IACN,QAAQ;IAJnB,KAAK,GAAG,MAAM,CAAA;IAEtB,YACmB,MAAiB,EACjB,QAAQ,GAAqB,gBAAgB;sBAD7C,MAAM;wBACN,QAAQ;IACxB,CAAC;IAEJ,OAAO,CAAC,KAAa,EAAE,SAAiB;QACtC,IAAI,CAAC,KAAK,GAAG,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QACzE,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;IAChD,CAAC;IAED;;;;;;;;OAQG;IACH,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAA;IAC5B,CAAC;IAED;;;;;;;;OAQG;IACH,GAAG;QACD,IAAI,CAAC,KAAK,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,CAAC,EAAE,CAAA;QAC5C,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;IAC7B,CAAC;CACF"}
@@ -0,0 +1,23 @@
1
+ import type { FaceSpec } from './spec.js';
2
+ /**
3
+ * The default face, and deliberately NOT a good one.
4
+ *
5
+ * An engine that renders nothing until you hand it thirty-nine numbers is an
6
+ * engine nobody evaluates, so there has to be a default. But the character this
7
+ * engine was extracted from is reserved, and shipping her as the default would
8
+ * hand her out with the library — so `PLAIN` is built to be visibly a different
9
+ * creature, along exactly the axes that identify her:
10
+ *
11
+ * | | Mochi | PLAIN |
12
+ * |---|---|---|
13
+ * | waist | 0.295, low and distinctive | 0.44, a near-symmetric egg |
14
+ * | shoulders | 1.86 / 2.58, asymmetric | 2.2 / 2.2, a plain ellipse |
15
+ * | shading | two tones, lit copy displaced | one flat tone, no displacement |
16
+ * | cheeks | a coral blush at 0.34 | none |
17
+ * | face | low on the body, small eyes | centred, larger eyes |
18
+ *
19
+ * Every one of those is a choice you are expected to replace. Start here, move
20
+ * the numbers, and the result is yours — that is what the format is for.
21
+ */
22
+ export declare const PLAIN: FaceSpec;
23
+ //# sourceMappingURL=plain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plain.d.ts","sourceRoot":"","sources":["../../src/core/plain.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAEzC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,KAAK,EAAE,QA+CnB,CAAA"}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The default face, and deliberately NOT a good one.
3
+ *
4
+ * An engine that renders nothing until you hand it thirty-nine numbers is an
5
+ * engine nobody evaluates, so there has to be a default. But the character this
6
+ * engine was extracted from is reserved, and shipping her as the default would
7
+ * hand her out with the library — so `PLAIN` is built to be visibly a different
8
+ * creature, along exactly the axes that identify her:
9
+ *
10
+ * | | Mochi | PLAIN |
11
+ * |---|---|---|
12
+ * | waist | 0.295, low and distinctive | 0.44, a near-symmetric egg |
13
+ * | shoulders | 1.86 / 2.58, asymmetric | 2.2 / 2.2, a plain ellipse |
14
+ * | shading | two tones, lit copy displaced | one flat tone, no displacement |
15
+ * | cheeks | a coral blush at 0.34 | none |
16
+ * | face | low on the body, small eyes | centred, larger eyes |
17
+ *
18
+ * Every one of those is a choice you are expected to replace. Start here, move
19
+ * the numbers, and the result is yours — that is what the format is for.
20
+ */
21
+ export const PLAIN = {
22
+ size: 100,
23
+ bodyW: 100,
24
+ bodyH: 92,
25
+ waist: 0.44,
26
+ upperShoulder: 2.2,
27
+ lowerShoulder: 2.2,
28
+ gripX: 0.7,
29
+ gripY: 0.7,
30
+ eyeX: 0.34,
31
+ eyeY: 0.55,
32
+ eyeHw: 5.5,
33
+ eyeUpper: 5.5,
34
+ eyeLower: 5.5,
35
+ eyeTilt: 0,
36
+ eyeRound: 2,
37
+ eyeGlint: 1.8,
38
+ gazeTravel: 0.4,
39
+ mouthY: 0.36,
40
+ mouthHw: 7,
41
+ mouthUpper: 0,
42
+ mouthLower: 2.6,
43
+ mouthRound: 2,
44
+ mouthOpenGain: 8,
45
+ cheekAlpha: 0,
46
+ cheekX: 0.6,
47
+ cheekY: 0.4,
48
+ cheekR: 10,
49
+ breathAmp: 0.03,
50
+ breathMs: 3000,
51
+ stiffness: 180,
52
+ damping: 22,
53
+ // Zero: the lit copy sits exactly on the shadow, so the body is one flat
54
+ // tone. The displaced two-tone shading is part of the reserved character.
55
+ shadowX: 0,
56
+ shadowY: 0,
57
+ colBody: '#b9c2cc',
58
+ colShadow: '#a9b3bf',
59
+ colInk: '#333b44',
60
+ colCheek: '#dd9999',
61
+ colGlint: '#ffffff',
62
+ };
63
+ //# sourceMappingURL=plain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plain.js","sourceRoot":"","sources":["../../src/core/plain.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,KAAK,GAAa;IAC7B,IAAI,EAAE,GAAG;IACT,KAAK,EAAE,GAAG;IACV,KAAK,EAAE,EAAE;IACT,KAAK,EAAE,IAAI;IACX,aAAa,EAAE,GAAG;IAClB,aAAa,EAAE,GAAG;IAClB,KAAK,EAAE,GAAG;IACV,KAAK,EAAE,GAAG;IAEV,IAAI,EAAE,IAAI;IACV,IAAI,EAAE,IAAI;IACV,KAAK,EAAE,GAAG;IACV,QAAQ,EAAE,GAAG;IACb,QAAQ,EAAE,GAAG;IACb,OAAO,EAAE,CAAC;IACV,QAAQ,EAAE,CAAC;IACX,QAAQ,EAAE,GAAG;IACb,UAAU,EAAE,GAAG;IAEf,MAAM,EAAE,IAAI;IACZ,OAAO,EAAE,CAAC;IACV,UAAU,EAAE,CAAC;IACb,UAAU,EAAE,GAAG;IACf,UAAU,EAAE,CAAC;IACb,aAAa,EAAE,CAAC;IAEhB,UAAU,EAAE,CAAC;IACb,MAAM,EAAE,GAAG;IACX,MAAM,EAAE,GAAG;IACX,MAAM,EAAE,EAAE;IAEV,SAAS,EAAE,IAAI;IACf,QAAQ,EAAE,IAAI;IACd,SAAS,EAAE,GAAG;IACd,OAAO,EAAE,EAAE;IAEX,yEAAyE;IACzE,0EAA0E;IAC1E,OAAO,EAAE,CAAC;IACV,OAAO,EAAE,CAAC;IAEV,OAAO,EAAE,SAAS;IAClB,SAAS,EAAE,SAAS;IACpB,MAAM,EAAE,SAAS;IACjB,QAAQ,EAAE,SAAS;IACnB,QAAQ,EAAE,SAAS;CACpB,CAAA"}
@@ -0,0 +1,156 @@
1
+ /**
2
+ * A face, as data.
3
+ *
4
+ * This is the plugin format. A designed avatar is a JSON file of these numbers
5
+ * -- no code, no assets, nothing executable. Design a face you like, save the
6
+ * file, and any host that embeds this engine can load it.
7
+ *
8
+ * ## Why data and not a code plugin
9
+ *
10
+ * The obvious "plugin" is a JS module implementing `AvatarBackend`, and that
11
+ * seam does exist (see `AvatarBackend` in `core/vocabulary`). It is not what a downloaded
12
+ * avatar gets to be. The renderer holds the IPC bridge that moves her window
13
+ * and disables click-through, and will later hold her audio stream; handing
14
+ * arbitrary downloaded code into that process trades a very large amount of
15
+ * authority for a different-shaped blob. Data cannot escalate.
16
+ *
17
+ * ## Why every field is bounded, not merely typed
18
+ *
19
+ * These numbers arrive from a file somebody else wrote. Type-checking alone
20
+ * accepts `waist: 50` or `bodyW: -1`, which do not throw -- they render a shape
21
+ * that is wrong in a way no error mentions, or divide by something near zero
22
+ * and produce a mochi the size of the screen. Every field therefore declares a
23
+ * range, and the range is checked at the boundary rather than defended against
24
+ * in the geometry.
25
+ */
26
+ export interface FaceSpec {
27
+ /**
28
+ * How big she is on screen, as a percentage of the base scale.
29
+ *
30
+ * Part of the FACE, because "how she looks" includes how much room she takes
31
+ * — and because leaving it out made it a code change: `showFace` hardcoded
32
+ * `size: 100`, so somebody who wanted a smaller mochi had to edit
33
+ * TypeScript, rebuild, and hope. That is the exact thing this format exists
34
+ * to stop.
35
+ *
36
+ * 100 puts her `bodyW` of 100 units at `BASE_UNIT_SCALE` — about 94 CSS
37
+ * pixels, which is also the size v1's design handoff specified for her
38
+ * window.
39
+ */
40
+ readonly size: number;
41
+ /** FULL width, in design units. */
42
+ readonly bodyW: number;
43
+ /** FULL height. She rests on the surface, so this is not a half-extent. */
44
+ readonly bodyH: number;
45
+ /** Height of the widest point, 0..0.95. Measured 0.295 on the icon. */
46
+ readonly waist: number;
47
+ /** Superellipse exponent above the waist. Below 2 is pointier than an ellipse. */
48
+ readonly upperShoulder: number;
49
+ /** Superellipse exponent below the waist. Above 2 is fuller than an ellipse. */
50
+ readonly lowerShoulder: number;
51
+ /** How much the face follows the body's deformation. Under 1 resists it. */
52
+ readonly gripX: number;
53
+ readonly gripY: number;
54
+ readonly eyeX: number;
55
+ readonly eyeY: number;
56
+ readonly eyeHw: number;
57
+ readonly eyeUpper: number;
58
+ readonly eyeLower: number;
59
+ readonly eyeTilt: number;
60
+ /** Superellipse exponent for the eyes. 2 is a round dot; below sharpens. */
61
+ readonly eyeRound: number;
62
+ /**
63
+ * Half-extent of the catchlight -- the white block that makes an eye look lit.
64
+ *
65
+ * 0 draws none, which is the honest way to opt out: a face with no highlight
66
+ * is a design choice, and expressing it as a size means it needs no flag.
67
+ */
68
+ readonly eyeGlint: number;
69
+ readonly gazeTravel: number;
70
+ readonly mouthY: number;
71
+ readonly mouthHw: number;
72
+ readonly mouthUpper: number;
73
+ readonly mouthLower: number;
74
+ readonly mouthRound: number;
75
+ /** How far a mouthOpen of 1 pushes the lower arc down. */
76
+ readonly mouthOpenGain: number;
77
+ readonly cheekAlpha: number;
78
+ readonly cheekX: number;
79
+ readonly cheekY: number;
80
+ readonly cheekR: number;
81
+ /**
82
+ * How far a full inhale spreads her, on the squash channel.
83
+ *
84
+ * ONE-SIDED: the breath runs 0..1, so this is the whole excursion rather than
85
+ * a half-amplitude, and it only ever spreads her. It cannot stretch her
86
+ * taller than her resting silhouette -- see `core/idle.ts`, `breathAt`.
87
+ */
88
+ readonly breathAmp: number;
89
+ readonly breathMs: number;
90
+ readonly stiffness: number;
91
+ readonly damping: number;
92
+ /** Displacement of the LIT copy of the silhouette, as a fraction of her size. */
93
+ readonly shadowX: number;
94
+ readonly shadowY: number;
95
+ readonly colBody: string;
96
+ readonly colShadow: string;
97
+ readonly colInk: string;
98
+ readonly colCheek: string;
99
+ /** The catchlight. Not hardcoded white: an ink that is not near-black wants its own. */
100
+ readonly colGlint: string;
101
+ }
102
+ type NumericKey = {
103
+ [K in keyof FaceSpec]: FaceSpec[K] extends number ? K : never;
104
+ }[keyof FaceSpec];
105
+ export type ColourKey = {
106
+ [K in keyof FaceSpec]: FaceSpec[K] extends string ? K : never;
107
+ }[keyof FaceSpec];
108
+ export interface Bound {
109
+ readonly min: number;
110
+ readonly max: number;
111
+ /** Granularity a slider should offer. Not enforced -- a spec may be finer. */
112
+ readonly step: number;
113
+ }
114
+ /**
115
+ * The permitted range of every numeric field.
116
+ *
117
+ * One table, read by BOTH the validator and the tuner's sliders. Two copies
118
+ * would let the editor offer a value the loader rejects, which presents to a
119
+ * user as "I designed this and the app ignored it".
120
+ *
121
+ * Wider than the tuner's comfortable range in places: these are the bounds of
122
+ * what is safe to render, not of what looks good. Taste is the designer's.
123
+ */
124
+ export declare const FACE_BOUNDS: Readonly<Record<NumericKey, Bound>>;
125
+ export declare const COLOUR_KEYS: readonly ColourKey[];
126
+ /**
127
+ * `#rgb` and `#rrggbb`, with or without alpha. Nothing else — see parseFaceSpec.
128
+ *
129
+ * Exported so `core/colour` can validate against the SAME pattern rather than
130
+ * its own copy. Two copies had already drifted apart once: one trimmed its
131
+ * input and the other did not, so a colour could pass validation here and be
132
+ * unreadable there, or the reverse.
133
+ */
134
+ export declare const HEX_COLOUR: RegExp;
135
+ export type ParseResult = {
136
+ readonly ok: true;
137
+ readonly face: FaceSpec;
138
+ } | {
139
+ readonly ok: false;
140
+ readonly problems: readonly string[];
141
+ };
142
+ /**
143
+ * Turn something read off disk into a face, or say exactly what is wrong.
144
+ *
145
+ * Reports EVERY problem rather than the first. Someone hand-editing an avatar
146
+ * file wants the whole list; failing on the first field turns one round of
147
+ * fixing into five, and they have no way to see the rest until each is cleared.
148
+ *
149
+ * Colours are restricted to hex. CSS accepts a great deal more -- `url(...)`,
150
+ * `image-set(...)`, custom properties -- and a colour string reaches
151
+ * `fillStyle`, which is the one place in this format where a value is
152
+ * interpreted rather than measured.
153
+ */
154
+ export declare function parseFaceSpec(value: unknown): ParseResult;
155
+ export {};
156
+ //# sourceMappingURL=spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spec.d.ts","sourceRoot":"","sources":["../../src/core/spec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,236 @@
1
+ /**
2
+ * A face, as data.
3
+ *
4
+ * This is the plugin format. A designed avatar is a JSON file of these numbers
5
+ * -- no code, no assets, nothing executable. Design a face you like, save the
6
+ * file, and any host that embeds this engine can load it.
7
+ *
8
+ * ## Why data and not a code plugin
9
+ *
10
+ * The obvious "plugin" is a JS module implementing `AvatarBackend`, and that
11
+ * seam does exist (see `AvatarBackend` in `core/vocabulary`). It is not what a downloaded
12
+ * avatar gets to be. The renderer holds the IPC bridge that moves her window
13
+ * and disables click-through, and will later hold her audio stream; handing
14
+ * arbitrary downloaded code into that process trades a very large amount of
15
+ * authority for a different-shaped blob. Data cannot escalate.
16
+ *
17
+ * ## Why every field is bounded, not merely typed
18
+ *
19
+ * These numbers arrive from a file somebody else wrote. Type-checking alone
20
+ * accepts `waist: 50` or `bodyW: -1`, which do not throw -- they render a shape
21
+ * that is wrong in a way no error mentions, or divide by something near zero
22
+ * and produce a mochi the size of the screen. Every field therefore declares a
23
+ * range, and the range is checked at the boundary rather than defended against
24
+ * in the geometry.
25
+ */
26
+ /**
27
+ * The permitted range of every numeric field.
28
+ *
29
+ * One table, read by BOTH the validator and the tuner's sliders. Two copies
30
+ * would let the editor offer a value the loader rejects, which presents to a
31
+ * user as "I designed this and the app ignored it".
32
+ *
33
+ * Wider than the tuner's comfortable range in places: these are the bounds of
34
+ * what is safe to render, not of what looks good. Taste is the designer's.
35
+ */
36
+ export const FACE_BOUNDS = {
37
+ // The same band `clampSizePercent` enforces in `core/layout`, stated here
38
+ // because this is where a user-supplied value is refused.
39
+ size: { min: 50, max: 200, step: 5 },
40
+ bodyW: { min: 20, max: 400, step: 1 },
41
+ bodyH: { min: 20, max: 400, step: 1 },
42
+ // Not 1: at the very top the shape degenerates to a spike with no underside.
43
+ waist: { min: 0, max: 0.95, step: 0.005 },
44
+ // Not below 1: the superellipse becomes concave and self-intersects.
45
+ upperShoulder: { min: 1, max: 8, step: 0.02 },
46
+ lowerShoulder: { min: 1, max: 8, step: 0.02 },
47
+ gripX: { min: 0, max: 1, step: 0.01 },
48
+ gripY: { min: 0, max: 1, step: 0.01 },
49
+ eyeX: { min: 0, max: 1, step: 0.005 },
50
+ eyeY: { min: 0, max: 1, step: 0.005 },
51
+ eyeHw: { min: 0, max: 60, step: 0.5 },
52
+ eyeUpper: { min: -60, max: 60, step: 0.5 },
53
+ eyeLower: { min: -60, max: 60, step: 0.5 },
54
+ eyeTilt: { min: -1.5, max: 1.5, step: 0.01 },
55
+ eyeRound: { min: 0.5, max: 8, step: 0.05 },
56
+ // Unbounded above by the eye on purpose: the highlight is CLIPPED to the eye
57
+ // it sits in, so an oversized one turns the whole eye white rather than
58
+ // escaping it. That is a legitimate look, and no range check has to know the
59
+ // eye's size to permit it.
60
+ eyeGlint: { min: 0, max: 30, step: 0.1 },
61
+ gazeTravel: { min: 0, max: 2, step: 0.01 },
62
+ mouthY: { min: 0, max: 1, step: 0.005 },
63
+ mouthHw: { min: 0, max: 80, step: 0.5 },
64
+ mouthUpper: { min: -60, max: 60, step: 0.25 },
65
+ mouthLower: { min: -60, max: 60, step: 0.25 },
66
+ mouthRound: { min: 0.5, max: 8, step: 0.05 },
67
+ mouthOpenGain: { min: 0, max: 100, step: 0.5 },
68
+ cheekAlpha: { min: 0, max: 1, step: 0.01 },
69
+ cheekX: { min: 0, max: 1.5, step: 0.01 },
70
+ cheekY: { min: 0, max: 1, step: 0.01 },
71
+ cheekR: { min: 0, max: 80, step: 0.5 },
72
+ breathAmp: { min: 0, max: 0.5, step: 0.005 },
73
+ // Not 0: the breath divides by the period.
74
+ breathMs: { min: 200, max: 20_000, step: 50 },
75
+ // Not 0: a spring with no stiffness never returns.
76
+ stiffness: { min: 1, max: 2000, step: 5 },
77
+ damping: { min: 0.1, max: 200, step: 1 },
78
+ shadowX: { min: -1, max: 1, step: 0.002 },
79
+ shadowY: { min: -1, max: 1, step: 0.002 },
80
+ };
81
+ /**
82
+ * Every colour field, as an exhaustive table rather than a list.
83
+ *
84
+ * `Record<ColourKey, true>` is the whole point: a list typed `ColourKey[]` is
85
+ * satisfied by ANY subset, so adding a sixth colour to `FaceSpec` compiled
86
+ * cleanly while the parser stayed unaware of it -- and the parser then reported
87
+ * the new field as "not a field of an avatar" and cast a face missing it to
88
+ * `FaceSpec` anyway. The built-in itself would have failed to load, and the
89
+ * tests would not have caught it because they derive their expectations from
90
+ * this same array.
91
+ *
92
+ * `FACE_BOUNDS` above is exhaustive over the numeric keys for exactly this
93
+ * reason. The colours were the half of the format that had no such guard.
94
+ */
95
+ const COLOUR_FIELDS = {
96
+ colBody: true,
97
+ colShadow: true,
98
+ colInk: true,
99
+ colCheek: true,
100
+ colGlint: true,
101
+ };
102
+ export const COLOUR_KEYS = Object.keys(COLOUR_FIELDS);
103
+ /**
104
+ * `#rgb` and `#rrggbb`, with or without alpha. Nothing else — see parseFaceSpec.
105
+ *
106
+ * Exported so `core/colour` can validate against the SAME pattern rather than
107
+ * its own copy. Two copies had already drifted apart once: one trimmed its
108
+ * input and the other did not, so a colour could pass validation here and be
109
+ * unreadable there, or the reverse.
110
+ */
111
+ export const HEX_COLOUR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
112
+ /** Long enough for any label a person writes, short enough not to be a payload. */
113
+ const NAME_MAX = 80;
114
+ /**
115
+ * A value, rendered for an error message, without throwing.
116
+ *
117
+ * `JSON.stringify` throws on a BigInt -- so the error path itself could throw,
118
+ * and `parseFaceSpec` would break the one promise its signature makes: that it
119
+ * returns a verdict. Disk JSON cannot carry a BigInt, but the parameter is
120
+ * `unknown` and the export is public, so the contract has to hold for whatever
121
+ * arrives, not for what is expected to.
122
+ */
123
+ function describe(value) {
124
+ if (typeof value === 'string')
125
+ return JSON.stringify(value);
126
+ if (typeof value === 'bigint')
127
+ return `${value}n`;
128
+ if (typeof value === 'object' && value !== null)
129
+ return Array.isArray(value) ? 'an array' : 'an object';
130
+ return String(value);
131
+ }
132
+ /**
133
+ * An error, as a string, without throwing a second time.
134
+ *
135
+ * `String(error)` is not safe on arbitrary input: a null-prototype object has
136
+ * no `toString`, and a Proxy can throw from one. That made the whole point of
137
+ * the catch below reachable-but-broken — the one function whose contract is
138
+ * "always returns a verdict" could still take its caller down.
139
+ */
140
+ function describeError(error) {
141
+ try {
142
+ return String(error);
143
+ }
144
+ catch {
145
+ return 'an error that could not be described';
146
+ }
147
+ }
148
+ /**
149
+ * Turn something read off disk into a face, or say exactly what is wrong.
150
+ *
151
+ * Reports EVERY problem rather than the first. Someone hand-editing an avatar
152
+ * file wants the whole list; failing on the first field turns one round of
153
+ * fixing into five, and they have no way to see the rest until each is cleared.
154
+ *
155
+ * Colours are restricted to hex. CSS accepts a great deal more -- `url(...)`,
156
+ * `image-set(...)`, custom properties -- and a colour string reaches
157
+ * `fillStyle`, which is the one place in this format where a value is
158
+ * interpreted rather than measured.
159
+ */
160
+ export function parseFaceSpec(value) {
161
+ try {
162
+ return readFaceSpec(value);
163
+ }
164
+ catch (error) {
165
+ // The signature promises a VERDICT for any `unknown`, and reading a
166
+ // property is not guaranteed to be safe: a getter or a Proxy can throw, and
167
+ // then the one function whose job is to decide whether something is a face
168
+ // takes the caller down with it. Today's inputs are `JSON.parse` output and
169
+ // structured-cloned IPC payloads, neither of which can carry a getter --
170
+ // which is exactly why nothing would have found this until the day some
171
+ // caller passed a live object.
172
+ return { ok: false, problems: [`the avatar could not be read: ${describeError(error)}`] };
173
+ }
174
+ }
175
+ function readFaceSpec(value) {
176
+ const problems = [];
177
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
178
+ return { ok: false, problems: ['the avatar file must contain a JSON object'] };
179
+ }
180
+ const source = value;
181
+ const face = {};
182
+ for (const [key, bound] of Object.entries(FACE_BOUNDS)) {
183
+ const raw = source[key];
184
+ if (raw === undefined) {
185
+ problems.push(`${key} is missing`);
186
+ continue;
187
+ }
188
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
189
+ problems.push(`${key} must be a finite number, got ${describe(raw)}`);
190
+ continue;
191
+ }
192
+ if (raw < bound.min || raw > bound.max) {
193
+ problems.push(`${key} must be between ${bound.min} and ${bound.max}, got ${raw}`);
194
+ continue;
195
+ }
196
+ face[key] = raw;
197
+ }
198
+ for (const key of COLOUR_KEYS) {
199
+ const raw = source[key];
200
+ if (raw === undefined) {
201
+ problems.push(`${key} is missing`);
202
+ continue;
203
+ }
204
+ if (typeof raw !== 'string' || !HEX_COLOUR.test(raw)) {
205
+ problems.push(`${key} must be a hex colour like #8ec8a8, got ${describe(raw)}`);
206
+ continue;
207
+ }
208
+ face[key] = raw;
209
+ }
210
+ // `name` is a label for whoever is editing the file. It is deliberately NOT
211
+ // part of `FaceSpec` -- nothing renders it, and `resolveAvatar` identifies an
212
+ // avatar by filename -- but it is still validated, because "accepted and
213
+ // discarded" and "accepted, wrong, and discarded" look identical from the
214
+ // outside. A `name` of `12345` used to pass silently; now it is a problem
215
+ // reported alongside every other, which is the promise this parser makes.
216
+ if ('name' in source) {
217
+ const name = source['name'];
218
+ if (typeof name !== 'string' || name.trim() === '') {
219
+ problems.push(`name must be a non-empty label, got ${describe(name)}`);
220
+ }
221
+ else if (name.length > NAME_MAX) {
222
+ problems.push(`name must be at most ${NAME_MAX} characters`);
223
+ }
224
+ }
225
+ // Unknown keys are reported, not rejected. A typo like `eyeWith` is silently
226
+ // ignored otherwise, and the designer sees their change do nothing.
227
+ const known = new Set([...Object.keys(FACE_BOUNDS), ...COLOUR_KEYS]);
228
+ for (const key of Object.keys(source)) {
229
+ if (!known.has(key) && key !== 'name')
230
+ problems.push(`${key} is not a field of an avatar`);
231
+ }
232
+ if (problems.length > 0)
233
+ return { ok: false, problems };
234
+ return { ok: true, face: face };
235
+ }
236
+ //# sourceMappingURL=spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spec.js","sourceRoot":"","sources":["../../src/core/spec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A second-order spring.
3
+ *
4
+ * The reason this exists rather than the one-pole filter used elsewhere: a
5
+ * first-order system approaches its target and stops. It CANNOT overshoot, and
6
+ * overshoot is what reads as mass. A mochi that gets squashed should wobble
7
+ * past centre and settle; one that eases back looks like a scaling picture.
8
+ *
9
+ * Pure arithmetic, no clock. The caller owns time, which is what lets the same
10
+ * code run in a render loop and in a test that advances a number.
11
+ */
12
+ export interface SpringConfig {
13
+ /** Pull toward the target. Higher is snappier. */
14
+ readonly stiffness: number;
15
+ /** Resistance. Below ~2*sqrt(stiffness) the spring is underdamped and overshoots. */
16
+ readonly damping: number;
17
+ }
18
+ export declare const DEFAULT_SPRING: SpringConfig;
19
+ /**
20
+ * Longest step the integrator will take, in seconds.
21
+ *
22
+ * Semi-implicit Euler is only conditionally stable: past roughly 2/sqrt(k) the
23
+ * correction overshoots further than the error it was correcting and the value
24
+ * diverges to infinity within a few frames. A backgrounded window, a breakpoint
25
+ * or a laptop waking from sleep all deliver exactly that -- a dt of seconds.
26
+ *
27
+ * Clamping means a long gap is under-integrated (she resumes from where she
28
+ * was, a little behind) rather than exploding. Under-integration is invisible;
29
+ * divergence paints a mochi the size of the screen and then NaN, after which
30
+ * every subsequent frame is blank with nothing in the log to say why.
31
+ */
32
+ export declare const MAX_STEP_SECONDS: number;
33
+ export declare class Spring {
34
+ private position;
35
+ private velocity;
36
+ constructor(initial?: number);
37
+ /** The current value, without advancing time. */
38
+ get value(): number;
39
+ /**
40
+ * Advance by `dtSeconds` toward `target` and return the new value.
41
+ *
42
+ * Non-finite inputs are ignored rather than propagated: one NaN reaching the
43
+ * velocity would poison the spring permanently, and the symptom -- she simply
44
+ * stops moving, forever, from an arbitrary later frame -- points nowhere near
45
+ * the frame that caused it.
46
+ */
47
+ step(dtSeconds: number, target: number, config?: SpringConfig): number;
48
+ /** Jump to a value and kill the velocity. For teleports, not for animation. */
49
+ snap(value: number): void;
50
+ /** True once the spring has effectively stopped, so a caller can skip work. */
51
+ isAtRest(target: number, epsilon?: number): boolean;
52
+ }
53
+ //# sourceMappingURL=spring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spring.d.ts","sourceRoot":"","sources":["../../src/core/spring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,YAAY;IAC3B,kDAAkD;IAClD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,qFAAqF;IACrF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,cAAc,EAAE,YAA8C,CAAA;AAE3E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC,qBAAa,MAAM;IACjB,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,QAAQ,CAAI;IAEpB,YAAY,OAAO,SAAI,EAEtB;IAED,iDAAiD;IACjD,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;;;;OAOG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAE,YAA6B,GAAG,MAAM,CAWrF;IAED,+EAA+E;IAC/E,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAIxB;IAED,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,SAAO,GAAG,OAAO,CAEhD;CACF"}