@voqalize/avatar 0.2.2 → 0.4.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 (265) hide show
  1. package/LICENSE +21 -661
  2. package/LICENSE-CC-BY-4.0 +396 -0
  3. package/README.md +191 -662
  4. package/assets/README.md +48 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tushar.glb +0 -0
  8. package/client/Avatar.tsx +57 -0
  9. package/client/AvatarClient.ts +818 -0
  10. package/client/arjun.ts +26 -0
  11. package/client/createAvatar.ts +177 -0
  12. package/client/createCanvasAvatar.ts +72 -0
  13. package/client/index.ts +44 -0
  14. package/client/internal.ts +108 -0
  15. package/client/interviewer-female.ts +4 -0
  16. package/client/interviewer-male.ts +4 -0
  17. package/client/ishita.ts +26 -0
  18. package/client/kabir.ts +26 -0
  19. package/client/meera.ts +26 -0
  20. package/client/naina.ts +26 -0
  21. package/client/playout.ts +95 -0
  22. package/client/professional-female-a.ts +4 -0
  23. package/client/professional-female-b.ts +4 -0
  24. package/client/professional-male-a.ts +4 -0
  25. package/client/professional-male-b.ts +4 -0
  26. package/client/react.ts +13 -0
  27. package/client/supports.ts +28 -0
  28. package/client/three/assets.ts +24 -0
  29. package/client/three/budgets.ts +27 -0
  30. package/client/three/holds.ts +64 -0
  31. package/client/three/internal.ts +54 -0
  32. package/client/three/motion-limits.json +116 -0
  33. package/client/three/sequences.ts +281 -0
  34. package/client/three/tanya.ts +69 -0
  35. package/client/three/tara-rig.ts +1562 -0
  36. package/client/three/tara.ts +76 -0
  37. package/client/three/tushar.ts +65 -0
  38. package/client/types.ts +235 -0
  39. package/client/useAvatar.ts +74 -0
  40. package/client/vikram.ts +26 -0
  41. package/dist/Avatar.d.ts +44 -0
  42. package/dist/Avatar.d.ts.map +1 -0
  43. package/{client/dist → dist}/Avatar.js +2 -2
  44. package/dist/Avatar.js.map +1 -0
  45. package/dist/AvatarClient.d.ts +274 -0
  46. package/dist/AvatarClient.d.ts.map +1 -0
  47. package/dist/AvatarClient.js +712 -0
  48. package/dist/AvatarClient.js.map +1 -0
  49. package/dist/arjun.d.ts +7 -0
  50. package/dist/arjun.d.ts.map +1 -0
  51. package/dist/arjun.js +20 -0
  52. package/dist/arjun.js.map +1 -0
  53. package/dist/createAvatar.d.ts +135 -0
  54. package/dist/createAvatar.d.ts.map +1 -0
  55. package/dist/createAvatar.js +75 -0
  56. package/dist/createAvatar.js.map +1 -0
  57. package/dist/createCanvasAvatar.d.ts +22 -0
  58. package/dist/createCanvasAvatar.d.ts.map +1 -0
  59. package/dist/createCanvasAvatar.js +47 -0
  60. package/dist/createCanvasAvatar.js.map +1 -0
  61. package/dist/index.d.ts +34 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +33 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/internal.d.ts +34 -0
  66. package/dist/internal.d.ts.map +1 -0
  67. package/dist/internal.js +41 -0
  68. package/dist/internal.js.map +1 -0
  69. package/dist/interviewer-female.d.ts +4 -0
  70. package/dist/interviewer-female.d.ts.map +1 -0
  71. package/dist/interviewer-female.js +3 -0
  72. package/dist/interviewer-female.js.map +1 -0
  73. package/dist/interviewer-male.d.ts +4 -0
  74. package/dist/interviewer-male.d.ts.map +1 -0
  75. package/dist/interviewer-male.js +3 -0
  76. package/dist/interviewer-male.js.map +1 -0
  77. package/dist/ishita.d.ts +7 -0
  78. package/dist/ishita.d.ts.map +1 -0
  79. package/dist/ishita.js +20 -0
  80. package/dist/ishita.js.map +1 -0
  81. package/dist/kabir.d.ts +7 -0
  82. package/dist/kabir.d.ts.map +1 -0
  83. package/dist/kabir.js +20 -0
  84. package/dist/kabir.js.map +1 -0
  85. package/dist/meera.d.ts +7 -0
  86. package/dist/meera.d.ts.map +1 -0
  87. package/dist/meera.js +20 -0
  88. package/dist/meera.js.map +1 -0
  89. package/dist/naina.d.ts +7 -0
  90. package/dist/naina.d.ts.map +1 -0
  91. package/dist/naina.js +20 -0
  92. package/dist/naina.js.map +1 -0
  93. package/dist/playout.d.ts +42 -0
  94. package/dist/playout.d.ts.map +1 -0
  95. package/dist/playout.js +85 -0
  96. package/dist/playout.js.map +1 -0
  97. package/dist/professional-female-a.d.ts +4 -0
  98. package/dist/professional-female-a.d.ts.map +1 -0
  99. package/dist/professional-female-a.js +3 -0
  100. package/dist/professional-female-a.js.map +1 -0
  101. package/dist/professional-female-b.d.ts +4 -0
  102. package/dist/professional-female-b.d.ts.map +1 -0
  103. package/dist/professional-female-b.js +3 -0
  104. package/dist/professional-female-b.js.map +1 -0
  105. package/dist/professional-male-a.d.ts +4 -0
  106. package/dist/professional-male-a.d.ts.map +1 -0
  107. package/dist/professional-male-a.js +3 -0
  108. package/dist/professional-male-a.js.map +1 -0
  109. package/dist/professional-male-b.d.ts +4 -0
  110. package/dist/professional-male-b.d.ts.map +1 -0
  111. package/dist/professional-male-b.js +3 -0
  112. package/dist/professional-male-b.js.map +1 -0
  113. package/dist/react.d.ts +13 -0
  114. package/dist/react.d.ts.map +1 -0
  115. package/dist/react.js +13 -0
  116. package/dist/react.js.map +1 -0
  117. package/dist/supports.d.ts +20 -0
  118. package/dist/supports.d.ts.map +1 -0
  119. package/dist/supports.js +26 -0
  120. package/dist/supports.js.map +1 -0
  121. package/dist/three/assets.d.ts +24 -0
  122. package/dist/three/assets.d.ts.map +1 -0
  123. package/dist/three/assets.js +22 -0
  124. package/dist/three/assets.js.map +1 -0
  125. package/dist/three/budgets.d.ts +17 -0
  126. package/dist/three/budgets.d.ts.map +1 -0
  127. package/dist/three/budgets.js +21 -0
  128. package/dist/three/budgets.js.map +1 -0
  129. package/dist/three/holds.d.ts +33 -0
  130. package/dist/three/holds.d.ts.map +1 -0
  131. package/dist/three/holds.js +56 -0
  132. package/dist/three/holds.js.map +1 -0
  133. package/dist/three/internal.d.ts +52 -0
  134. package/dist/three/internal.d.ts.map +1 -0
  135. package/dist/three/internal.js +49 -0
  136. package/dist/three/internal.js.map +1 -0
  137. package/dist/three/motion-limits.json +116 -0
  138. package/dist/three/sequences.d.ts +71 -0
  139. package/dist/three/sequences.d.ts.map +1 -0
  140. package/dist/three/sequences.js +262 -0
  141. package/dist/three/sequences.js.map +1 -0
  142. package/dist/three/tanya.d.ts +35 -0
  143. package/dist/three/tanya.d.ts.map +1 -0
  144. package/dist/three/tanya.js +51 -0
  145. package/dist/three/tanya.js.map +1 -0
  146. package/dist/three/tara-rig.d.ts +403 -0
  147. package/dist/three/tara-rig.d.ts.map +1 -0
  148. package/dist/three/tara-rig.js +1500 -0
  149. package/dist/three/tara-rig.js.map +1 -0
  150. package/dist/three/tara.d.ts +35 -0
  151. package/dist/three/tara.d.ts.map +1 -0
  152. package/dist/three/tara.js +58 -0
  153. package/dist/three/tara.js.map +1 -0
  154. package/dist/three/tushar.d.ts +31 -0
  155. package/dist/three/tushar.d.ts.map +1 -0
  156. package/dist/three/tushar.js +47 -0
  157. package/dist/three/tushar.js.map +1 -0
  158. package/dist/types.d.ts +149 -0
  159. package/dist/types.d.ts.map +1 -0
  160. package/dist/types.js +140 -0
  161. package/dist/types.js.map +1 -0
  162. package/dist/useAvatar.d.ts +39 -0
  163. package/dist/useAvatar.d.ts.map +1 -0
  164. package/dist/useAvatar.js +35 -0
  165. package/dist/useAvatar.js.map +1 -0
  166. package/dist/vikram.d.ts +7 -0
  167. package/dist/vikram.d.ts.map +1 -0
  168. package/dist/vikram.js +20 -0
  169. package/dist/vikram.js.map +1 -0
  170. package/package.json +110 -25
  171. package/src/avatar.d.ts +218 -120
  172. package/src/avatar.js +950 -213
  173. package/src/behavior.d.ts +42 -0
  174. package/src/behavior.js +114 -0
  175. package/src/camera.js +29 -0
  176. package/src/canvas/author/parts/eye.mjs +722 -0
  177. package/src/canvas/author/parts/hand.mjs +1156 -0
  178. package/src/canvas/author/parts/mouth.mjs +741 -0
  179. package/src/canvas/author/parts/nose.mjs +100 -0
  180. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  181. package/src/canvas/author/path.mjs +283 -0
  182. package/src/canvas/author/rig.mjs +405 -0
  183. package/src/canvas/avatars/round/face.d.mts +3 -0
  184. package/src/canvas/avatars/round/face.mjs +1307 -0
  185. package/src/canvas/create-rig.d.ts +15 -0
  186. package/src/canvas/create-rig.js +100 -0
  187. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  188. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  189. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  190. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  191. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  192. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  193. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  194. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  195. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  196. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  197. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  198. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  199. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  200. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  201. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  202. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  203. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  204. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  205. package/src/canvas/data/interviewer-female.rig.json +1 -0
  206. package/src/canvas/data/interviewer-male.rig.json +1 -0
  207. package/src/canvas/data/professional-female-a.rig.json +1 -0
  208. package/src/canvas/data/professional-female-b.rig.json +1 -0
  209. package/src/canvas/data/professional-male-a.rig.json +1 -0
  210. package/src/canvas/data/professional-male-b.rig.json +1 -0
  211. package/src/canvas/src/live.js +508 -0
  212. package/src/canvas/src/render2d.js +218 -0
  213. package/src/canvas/src/rig.js +297 -0
  214. package/src/canvas/src/vocab.js +96 -0
  215. package/src/clips.js +18 -7
  216. package/src/conformance.js +119 -0
  217. package/src/emotions.js +8 -5
  218. package/src/face-core.js +27 -1
  219. package/src/face-myna.d.ts +7 -0
  220. package/src/face-myna.js +192 -131
  221. package/src/face-peep-control-plane.js +167 -0
  222. package/src/face-peep.d.ts +7 -0
  223. package/src/face-peep.js +274 -116
  224. package/src/face-wren.d.ts +7 -0
  225. package/src/face-wren.js +17 -19
  226. package/src/faces.d.ts +12 -0
  227. package/src/faces.js +53 -0
  228. package/src/gaze.js +434 -58
  229. package/src/hand.js +120 -91
  230. package/src/head.js +134 -0
  231. package/src/idle.js +227 -119
  232. package/src/interjections.js +200 -31
  233. package/src/params.js +8 -4
  234. package/src/perform.js +7 -9
  235. package/src/prosody.js +647 -0
  236. package/src/rig.d.ts +24 -0
  237. package/src/rig.js +32 -0
  238. package/src/speech-timing.js +23 -0
  239. package/src/visemes.js +110 -24
  240. package/client/dist/Avatar.d.ts +0 -27
  241. package/client/dist/Avatar.d.ts.map +0 -1
  242. package/client/dist/Avatar.js.map +0 -1
  243. package/client/dist/AvatarClient.d.ts +0 -139
  244. package/client/dist/AvatarClient.d.ts.map +0 -1
  245. package/client/dist/AvatarClient.js +0 -223
  246. package/client/dist/AvatarClient.js.map +0 -1
  247. package/client/dist/index.d.ts +0 -22
  248. package/client/dist/index.d.ts.map +0 -1
  249. package/client/dist/index.js +0 -22
  250. package/client/dist/index.js.map +0 -1
  251. package/client/dist/types.d.ts +0 -86
  252. package/client/dist/types.d.ts.map +0 -1
  253. package/client/dist/types.js +0 -31
  254. package/client/dist/types.js.map +0 -1
  255. package/client/dist/useAvatar.d.ts +0 -42
  256. package/client/dist/useAvatar.d.ts.map +0 -1
  257. package/client/dist/useAvatar.js +0 -51
  258. package/client/dist/useAvatar.js.map +0 -1
  259. package/client/src/Avatar.tsx +0 -33
  260. package/client/src/AvatarClient.ts +0 -270
  261. package/client/src/index.ts +0 -22
  262. package/client/src/types.ts +0 -104
  263. package/client/src/useAvatar.ts +0 -85
  264. package/docs/contract-avatar.md +0 -371
  265. package/docs/contract-protocol.md +0 -461
@@ -0,0 +1,218 @@
1
+ // Canvas2D renderer for the blended display list.
2
+ //
3
+ // Rive's own canvas runtime paints vector art with Path2D and warps textured
4
+ // meshes separately; this does the same. Path2D objects are cached per draw and
5
+ // only rebuilt when that draw's geometry actually moved this frame, which keeps
6
+ // a viseme change to ~30 rebuilds out of 813 paths.
7
+
8
+ const mul = (A, B) => [
9
+ A[0] * B[0] + A[2] * B[1],
10
+ A[1] * B[0] + A[3] * B[1],
11
+ A[0] * B[2] + A[2] * B[3],
12
+ A[1] * B[2] + A[3] * B[3],
13
+ A[0] * B[4] + A[2] * B[5] + A[4],
14
+ A[1] * B[4] + A[3] * B[5] + A[5],
15
+ ];
16
+
17
+ const invSimilarity = ([s, , , , tx, ty]) => [1 / s, 0, 0, 1 / s, -tx / s, -ty / s];
18
+
19
+ export function buildPath(cmds) {
20
+ const p = new Path2D();
21
+ for (let i = 0; i < cmds.length;) {
22
+ const c = cmds[i];
23
+ if (c === 0) { p.moveTo(cmds[i + 1], cmds[i + 2]); i += 3; }
24
+ else if (c === 1) { p.lineTo(cmds[i + 1], cmds[i + 2]); i += 3; }
25
+ else if (c === 2) { p.bezierCurveTo(cmds[i + 1], cmds[i + 2], cmds[i + 3], cmds[i + 4], cmds[i + 5], cmds[i + 6]); i += 7; }
26
+ else if (c === 3) { p.closePath(); i += 1; }
27
+ else if (c === -1) {
28
+ // A sub-path placed under its own matrix; Rive emits these for shapes
29
+ // whose contours live in a different space than the shape itself.
30
+ const n = cmds[i + 7];
31
+ const sub = buildPath(cmds.subarray(i + 8, i + 8 + n));
32
+ p.addPath(sub, new DOMMatrix([cmds[i + 1], cmds[i + 2], cmds[i + 3], cmds[i + 4], cmds[i + 5], cmds[i + 6]]));
33
+ i += 8 + n;
34
+ } else break;
35
+ }
36
+ return p;
37
+ }
38
+
39
+ const cssOf = (c) => `rgba(${c[0]},${c[1]},${c[2]},${c[3]})`;
40
+
41
+ export class Renderer2D {
42
+ constructor(canvas, rig) {
43
+ this.canvas = canvas;
44
+ this.rig = rig;
45
+ this.ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
46
+ this.unalign = invSimilarity(rig.meta.align || [1, 0, 0, 1, 0, 0]);
47
+ this.cache = rig.out.map(() => ({ gen: -1, path: null }));
48
+ this.clipCache = new Map();
49
+ this.gradCache = new Map();
50
+ this.global = [1, 0, 0, 1, 0, 0];
51
+ // Only 11 of the 824 draws are image-backed -- the hair, brows, suit,
52
+ // glasses and a freckle texture. 'off' drops them so what is left is the
53
+ // pure vector rig; 'ghost' leaves a flat silhouette in their place so you
54
+ // can see the holes they would fill.
55
+ this.bitmaps = 'on'; // on | off | ghost
56
+ this.stats = { paths: 0, rebuilt: 0, tris: 0, images: 0 };
57
+ this.resize();
58
+ }
59
+
60
+ resize(dpr = window.devicePixelRatio || 1) {
61
+ const { clientWidth: w, clientHeight: h } = this.canvas;
62
+ this.canvas.width = Math.max(1, Math.round(w * dpr));
63
+ this.canvas.height = Math.max(1, Math.round(h * dpr));
64
+ const { w: aw, h: ah } = this.rig.meta.artboard;
65
+ const s = Math.min(this.canvas.width / aw, this.canvas.height / ah);
66
+ // contain + centre, then divide out the fit transform the extractor baked in
67
+ this.global = mul([s, 0, 0, s, (this.canvas.width - aw * s) / 2, (this.canvas.height - ah * s) / 2], this.unalign);
68
+ this.scale = s;
69
+ }
70
+
71
+ _path(d) {
72
+ const c = this.cache[d.i];
73
+ if (c.gen !== d.gen || !c.path) { c.path = buildPath(d.cmds); c.gen = d.gen; this.stats.rebuilt++; }
74
+ return c.path;
75
+ }
76
+
77
+ // Masks deform along with the body, so their Path2D is cached on the same
78
+ // gen counter the draws use rather than built once and kept forever.
79
+ _clipPath(c) {
80
+ let e = this.clipCache.get(c.i);
81
+ if (!e || e.gen !== c.gen) { e = { gen: c.gen, path: buildPath(c.c) }; this.clipCache.set(c.i, e); }
82
+ return e.path;
83
+ }
84
+
85
+ _paint(d) {
86
+ if (d.colour) return d.colour;
87
+ const p = this.rig.paints[d.paint];
88
+ if (p.t === 'solid') return cssOf(p.c);
89
+ let g = this.gradCache.get(d.paint);
90
+ if (!g) {
91
+ const ctx = this.ctx;
92
+ const [x0, y0, x1, y1] = p.p;
93
+ g = p.t === 'linear'
94
+ ? ctx.createLinearGradient(x0, y0, x1, y1)
95
+ : ctx.createRadialGradient(x0, y0, 0, x0, y0, Math.hypot(x1 - x0, y1 - y0));
96
+ for (const [o, c] of p.stops) g.addColorStop(Math.min(1, Math.max(0, o)), cssOf(c));
97
+ this.gradCache.set(d.paint, g);
98
+ }
99
+ return g;
100
+ }
101
+
102
+ draw() {
103
+ const ctx = this.ctx, rig = this.rig, G = this.global;
104
+ this.stats.paths = 0; this.stats.rebuilt = 0; this.stats.tris = 0; this.stats.images = 0;
105
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
106
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
107
+
108
+ for (const d of rig.out) {
109
+ if (!(d.a > 0.003)) continue;
110
+ const set = rig.clipsets[d.clip];
111
+ const clipped = set && set.length;
112
+ if (clipped) {
113
+ ctx.save();
114
+ for (const id of set) {
115
+ const c = rig.clipOut[id];
116
+ const m = mul(G, c.m);
117
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
118
+ ctx.clip(this._clipPath(c), c.r || 'nonzero');
119
+ }
120
+ }
121
+ const m = mul(G, d.m);
122
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
123
+ ctx.globalAlpha = d.a > 1 ? 1 : d.a;
124
+ ctx.globalCompositeOperation = d.blend || 'source-over';
125
+
126
+ if (d.k === 'path') {
127
+ this.stats.paths++;
128
+ const path = this._path(d);
129
+ if (d.stroke) {
130
+ ctx.strokeStyle = this._paint(d);
131
+ ctx.lineWidth = d.stroke.w;
132
+ ctx.lineCap = d.stroke.cap;
133
+ ctx.lineJoin = d.stroke.join;
134
+ ctx.stroke(path);
135
+ } else {
136
+ ctx.fillStyle = this._paint(d);
137
+ ctx.fill(path, d.rule || 'nonzero');
138
+ }
139
+ } else if (this.bitmaps === 'off') {
140
+ // nothing to draw: the image is what this slot is made of
141
+ } else if (this.bitmaps === 'ghost') {
142
+ this.stats.images++;
143
+ this._ghost(ctx, d);
144
+ } else if (d.k === 'bitmap') {
145
+ this.stats.images++;
146
+ ctx.drawImage(rig.images[d.src], 0, 0);
147
+ } else if (d.k === 'mesh') {
148
+ this.stats.images++;
149
+ this._mesh(ctx, d, m);
150
+ }
151
+
152
+ if (clipped) ctx.restore();
153
+ }
154
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
155
+ ctx.globalAlpha = 1;
156
+ ctx.globalCompositeOperation = 'source-over';
157
+ }
158
+
159
+ // The footprint an image-backed draw occupies, as a flat fill: the mesh's own
160
+ // triangles, or the image's rect for a plain blit. Useful for seeing exactly
161
+ // which parts of the character a new persona would have to supply art for.
162
+ _ghost(ctx, d) {
163
+ ctx.globalAlpha = 0.4;
164
+ ctx.fillStyle = '#7c5cff';
165
+ ctx.beginPath();
166
+ if (d.k === 'mesh') {
167
+ const V = d.verts, I = d.idx;
168
+ for (let t = 0; t < I.length; t += 3) {
169
+ const a = I[t] * 2, b = I[t + 1] * 2, c = I[t + 2] * 2;
170
+ ctx.moveTo(V[a], V[a + 1]); ctx.lineTo(V[b], V[b + 1]); ctx.lineTo(V[c], V[c + 1]);
171
+ ctx.closePath();
172
+ }
173
+ } else ctx.rect(0, 0, d.w, d.h);
174
+ ctx.fill();
175
+ }
176
+
177
+ // Deformable image mesh: for each triangle, solve the affine that carries its
178
+ // UV triangle onto its deformed vertex triangle, clip to the triangle, and
179
+ // blit the whole texture through it. Triangles are nudged outward from their
180
+ // centroid by half a device pixel so shared edges do not show seams.
181
+ _mesh(ctx, d, m) {
182
+ const img = this.rig.images[d.src];
183
+ if (!img) return;
184
+ const V = d.verts, U = d.uvs, I = d.idx, W = img.width, H = img.height;
185
+ const grow = 0.5 / (Math.hypot(m[0], m[1]) || 1);
186
+ for (let t = 0; t < I.length; t += 3) {
187
+ const i0 = I[t] * 2, i1 = I[t + 1] * 2, i2 = I[t + 2] * 2;
188
+ let x0 = V[i0], y0 = V[i0 + 1], x1 = V[i1], y1 = V[i1 + 1], x2 = V[i2], y2 = V[i2 + 1];
189
+ const s0x = U[i0] * W, s0y = U[i0 + 1] * H;
190
+ const s1x = U[i1] * W, s1y = U[i1 + 1] * H;
191
+ const s2x = U[i2] * W, s2y = U[i2 + 1] * H;
192
+ const det = (s1x - s0x) * (s2y - s0y) - (s2x - s0x) * (s1y - s0y);
193
+ if (!det) continue;
194
+ const a = ((x1 - x0) * (s2y - s0y) - (x2 - x0) * (s1y - s0y)) / det;
195
+ const c = ((x2 - x0) * (s1x - s0x) - (x1 - x0) * (s2x - s0x)) / det;
196
+ const b = ((y1 - y0) * (s2y - s0y) - (y2 - y0) * (s1y - s0y)) / det;
197
+ const dd = ((y2 - y0) * (s1x - s0x) - (y1 - y0) * (s2x - s0x)) / det;
198
+ const e = x0 - a * s0x - c * s0y;
199
+ const f = y0 - b * s0x - dd * s0y;
200
+
201
+ const cx = (x0 + x1 + x2) / 3, cy = (y0 + y1 + y2) / 3;
202
+ const push = (x, y) => {
203
+ const dx = x - cx, dy = y - cy, l = Math.hypot(dx, dy) || 1;
204
+ return [x + (dx / l) * grow, y + (dy / l) * grow];
205
+ };
206
+ [x0, y0] = push(x0, y0); [x1, y1] = push(x1, y1); [x2, y2] = push(x2, y2);
207
+
208
+ ctx.save();
209
+ ctx.beginPath();
210
+ ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.closePath();
211
+ ctx.clip();
212
+ ctx.transform(a, b, c, dd, e, f);
213
+ ctx.drawImage(img, 0, 0);
214
+ ctx.restore();
215
+ this.stats.tris++;
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,297 @@
1
+ // The rig: a base display list plus a library of poses stored as differences
2
+ // from it. Blending is additive — v = base + Σ wᵢ·(poseᵢ − base) — so two
3
+ // weights of 0.5 give a true half-way blend regardless of the order they are
4
+ // listed in. That is the one place we deliberately differ from Rive, which
5
+ // lerps sequentially and is therefore order-dependent.
6
+
7
+ const EPS = 1e-4;
8
+
9
+ // Deltas arrive keyed by draw index. Flatten them into tuple lists once, and
10
+ // record the union of touched indices so a frame only rebuilds what moved.
11
+ function prepare(delta) {
12
+ const touch = new Set();
13
+ const pairs = (src, cast) => {
14
+ if (!src) return null;
15
+ const out = [];
16
+ for (const k of Object.keys(src)) { const i = +k; touch.add(i); out.push([i, cast(src[k])]); }
17
+ return out.length ? out : null;
18
+ };
19
+ const F = (a) => Float32Array.from(a);
20
+ const id = (v) => v;
21
+ const p = {
22
+ mat: pairs(delta.mat, F),
23
+ geo: pairs(delta.geo, F),
24
+ verts: pairs(delta.verts, F),
25
+ alpha: pairs(delta.alpha, id),
26
+ paint: pairs(delta.paint, id),
27
+ clip: pairs(delta.clip, id),
28
+ src: pairs(delta.src, id),
29
+ hide: null,
30
+ };
31
+ if (delta.hide && delta.hide.length) {
32
+ p.hide = delta.hide.slice();
33
+ for (const i of p.hide) touch.add(i);
34
+ }
35
+ p.touch = Int32Array.from(touch);
36
+
37
+ // Masks are indexed by clip slot rather than draw index, so they need their
38
+ // own dirty set. A mask moving is the common case -- it rides the body -- and
39
+ // it must interpolate, not snap, or its edge pops while the art glides.
40
+ const ctouch = new Set();
41
+ const cpairs = (src, cast) => {
42
+ if (!src) return null;
43
+ const out = [];
44
+ for (const k of Object.keys(src)) { const i = +k; ctouch.add(i); out.push([i, cast(src[k])]); }
45
+ return out.length ? out : null;
46
+ };
47
+ p.cmat = cpairs(delta.cmat, F);
48
+ p.cgeo = cpairs(delta.cgeo, F);
49
+ p.ctouch = Int32Array.from(ctouch);
50
+ return p;
51
+ }
52
+
53
+ export class Rig {
54
+ constructor(data, images) {
55
+ this.data = data;
56
+ this.images = images;
57
+ this.meta = data.meta;
58
+ this.paints = data.paints;
59
+ this.clipsets = data.clipsets; // arrays of clip-slot ids
60
+
61
+ // Live mask state, mirroring the draw list: base kept for restore, an out
62
+ // buffer that poses blend into, and a gen counter the renderer caches on.
63
+ this.clipBase = data.clipSlots.map((c) => ({
64
+ m: Float32Array.from(c.m), c: Float32Array.from(c.c), r: c.r,
65
+ }));
66
+ this.clipOut = this.clipBase.map((b, i) => ({
67
+ i, r: b.r, m: Float32Array.from(b.m), c: Float32Array.from(b.c), gen: 0,
68
+ }));
69
+ this.clipOut.forEach((o) => { o._c = o.c; });
70
+
71
+ this.base = data.draws.map((d) => ({
72
+ k: d.k, m: Float32Array.from(d.m), a: d.a, blend: d.blend, clip: d.clip,
73
+ rule: d.rule, stroke: d.stroke, paint: d.paint, src: d.src, w: d.w, h: d.h,
74
+ cmds: d.cmds ? Float32Array.from(d.cmds) : null,
75
+ verts: d.verts ? Float32Array.from(d.verts) : null,
76
+ uvs: d.uvs ? Float32Array.from(d.uvs) : null,
77
+ idx: d.idx ? Uint16Array.from(d.idx) : null,
78
+ }));
79
+
80
+ // Live display list. cmds/verts get their own scratch buffers so a pose
81
+ // that has to snap (topology change) can swap its array in and back out.
82
+ this.out = this.base.map((b, i) => {
83
+ const o = {
84
+ i, k: b.k, blend: b.blend, rule: b.rule, stroke: b.stroke,
85
+ src: b.src, w: b.w, h: b.h, uvs: b.uvs, idx: b.idx,
86
+ m: Float32Array.from(b.m), a: b.a, clip: b.clip, paint: b.paint,
87
+ colour: null, // set when paint blending produced a plain colour
88
+ gen: 0,
89
+ };
90
+ o._cmds = b.cmds ? Float32Array.from(b.cmds) : null;
91
+ o._verts = b.verts ? Float32Array.from(b.verts) : null;
92
+ o.cmds = o._cmds;
93
+ o.verts = o._verts;
94
+ o._geoDirty = false;
95
+ return o;
96
+ });
97
+
98
+ this.poses = {};
99
+ for (const [name, d] of Object.entries(data.poses)) this.poses[name] = prepare(d);
100
+
101
+ // A sampled timeline is just an ordered set of poses; playing it is a
102
+ // two-weight blend between the frames either side of the cursor. Reusing
103
+ // the pose machinery means ambient motion costs nothing extra.
104
+ this.tracks = {};
105
+ for (const [name, t] of Object.entries(data.tracks || {})) {
106
+ const keys = t.frames.map((f, j) => {
107
+ const key = `@${name}/${j}`;
108
+ this.poses[key] = prepare(f);
109
+ return key;
110
+ });
111
+ // `at` holds each key's normalised position in the clip. Keys are placed
112
+ // where the motion is, so they are not evenly spaced and playback has to
113
+ // search rather than divide.
114
+ this.tracks[name] = { period: t.period, loop: t.loop, at: t.at, keys };
115
+ }
116
+
117
+ this.dirty = new Set();
118
+ this.cdirty = new Set();
119
+ this.frame = 0;
120
+ this._acc = new Map(); // draw index -> paint accumulator, reused each frame
121
+ }
122
+
123
+ restore(i) {
124
+ const o = this.out[i], b = this.base[i];
125
+ o.m.set(b.m);
126
+ o.a = b.a;
127
+ o.clip = b.clip;
128
+ o.paint = b.paint;
129
+ o.src = b.src;
130
+ o.colour = null;
131
+ // Only touch geometry if a pose actually moved it last frame. Ambient
132
+ // motion is almost pure matrix work, so this keeps the renderer's Path2D
133
+ // cache warm for ~800 of the 813 paths.
134
+ if (o._geoDirty) {
135
+ if (b.cmds) { o.cmds = o._cmds; o.cmds.set(b.cmds); }
136
+ if (b.verts) { o.verts = o._verts; o.verts.set(b.verts); }
137
+ o.gen = this.frame;
138
+ o._geoDirty = false;
139
+ }
140
+ }
141
+
142
+ restoreClip(i) {
143
+ const o = this.clipOut[i], b = this.clipBase[i];
144
+ o.m.set(b.m);
145
+ o.c = o._c;
146
+ o.c.set(b.c);
147
+ o.gen = this.frame;
148
+ }
149
+
150
+ // weights: { poseName: 0..1 }. Anything absent is zero.
151
+ evaluate(weights) {
152
+ this.frame++;
153
+ const out = this.out, base = this.base;
154
+
155
+ for (const i of this.dirty) this.restore(i);
156
+ this.dirty.clear();
157
+ for (const i of this.cdirty) this.restoreClip(i);
158
+ this.cdirty.clear();
159
+
160
+ const act = [];
161
+ for (const name of Object.keys(weights)) {
162
+ const w = weights[name];
163
+ if (!(w > EPS || w < -EPS)) continue; // spline weights may go negative
164
+ const p = this.poses[name];
165
+ if (!p) continue;
166
+ act.push(p, w);
167
+ const t = p.touch;
168
+ for (let j = 0; j < t.length; j++) this.dirty.add(t[j]);
169
+ const ct = p.ctouch;
170
+ for (let j = 0; j < ct.length; j++) this.cdirty.add(ct[j]);
171
+ }
172
+
173
+ const acc = this._acc;
174
+ acc.clear();
175
+ const slot = (i) => {
176
+ let s = acc.get(i);
177
+ if (!s) { s = { paint: -1, pw: 0, clip: -1, cw: 0, geoSnap: null, gw: 0, src: -1, sw: 0 }; acc.set(i, s); }
178
+ return s;
179
+ };
180
+ const cacc = new Map();
181
+ const cslot = (i) => {
182
+ let s = cacc.get(i);
183
+ if (!s) { s = { snap: null, gw: 0 }; cacc.set(i, s); }
184
+ return s;
185
+ };
186
+
187
+ for (let a = 0; a < act.length; a += 2) {
188
+ const p = act[a], w = act[a + 1];
189
+
190
+ if (p.mat) for (const [i, v] of p.mat) {
191
+ const o = out[i].m, b = base[i].m;
192
+ for (let j = 0; j < 6; j++) o[j] += w * (v[j] - b[j]);
193
+ }
194
+
195
+ if (p.geo) for (const [i, v] of p.geo) {
196
+ const d = out[i], b = base[i].cmds;
197
+ d.gen = this.frame; d._geoDirty = true;
198
+ if (v.length !== b.length) {
199
+ // Topology changed, so there is nothing to interpolate between.
200
+ // Snap to whichever competing pose is pulling hardest.
201
+ const s = slot(i);
202
+ if (w > s.gw) { s.gw = w; s.geoSnap = v; }
203
+ } else {
204
+ const o = d.cmds === d._cmds ? d.cmds : d._cmds;
205
+ for (let j = 0; j < v.length; j++) o[j] += w * (v[j] - b[j]);
206
+ }
207
+ }
208
+
209
+ if (p.verts) for (const [i, v] of p.verts) {
210
+ const d = out[i], b = base[i].verts, o = d.verts;
211
+ d.gen = this.frame; d._geoDirty = true;
212
+ for (let j = 0; j < v.length; j++) o[j] += w * (v[j] - b[j]);
213
+ }
214
+
215
+ if (p.alpha) for (const [i, v] of p.alpha) out[i].a += w * (v - base[i].a);
216
+ if (p.hide) for (const i of p.hide) out[i].a += w * (0 - base[i].a);
217
+
218
+ if (p.paint) for (const [i, v] of p.paint) {
219
+ const s = slot(i);
220
+ s.list = s.list || [];
221
+ s.list.push(v, w);
222
+ }
223
+ if (p.clip) for (const [i, v] of p.clip) {
224
+ const s = slot(i);
225
+ if (w > s.cw) { s.cw = w; s.clip = v; }
226
+ }
227
+ if (p.cmat) for (const [i, v] of p.cmat) {
228
+ const o = this.clipOut[i].m, b = this.clipBase[i].m;
229
+ for (let j = 0; j < 6; j++) o[j] += w * (v[j] - b[j]);
230
+ this.clipOut[i].gen = this.frame;
231
+ }
232
+
233
+ if (p.cgeo) for (const [i, v] of p.cgeo) {
234
+ const d = this.clipOut[i], b = this.clipBase[i].c;
235
+ d.gen = this.frame;
236
+ if (v.length !== b.length) {
237
+ const s = cslot(i);
238
+ if (w > s.gw) { s.gw = w; s.snap = v; }
239
+ } else {
240
+ const o = d.c === d._c ? d.c : d._c;
241
+ for (let j = 0; j < v.length; j++) o[j] += w * (v[j] - b[j]);
242
+ }
243
+ }
244
+
245
+ // A texture swap has no midpoint, so it flips with the dominant pose.
246
+ if (p.src) for (const [i, v] of p.src) {
247
+ const s = slot(i);
248
+ if (w > s.sw) { s.sw = w; s.src = v; }
249
+ }
250
+ }
251
+
252
+ for (const [i, s] of cacc) if (s.snap && s.gw > 0.5) this.clipOut[i].c = s.snap;
253
+
254
+ for (const [i, s] of acc) {
255
+ const d = out[i];
256
+ if (s.geoSnap && s.gw > 0.5) d.cmds = s.geoSnap;
257
+ if (s.clip >= 0 && s.cw > 0.5) d.clip = s.clip;
258
+ if (s.src >= 0 && s.sw > 0.5) d.src = s.src;
259
+ if (s.list) this._resolvePaint(d, base[i].paint, s.list);
260
+ }
261
+
262
+ return out;
263
+ }
264
+
265
+ // Solid-to-solid paint changes blend in RGBA, which is what the eye-hue
266
+ // ladder and the viseme lip tints need. Anything involving a gradient has no
267
+ // meaningful midpoint, so it snaps at the halfway mark instead.
268
+ _resolvePaint(d, basePaint, list) {
269
+ const P = this.paints;
270
+ const b = P[basePaint];
271
+ let allSolid = b.t === 'solid';
272
+ let best = -1, bw = 0;
273
+ for (let j = 0; j < list.length; j += 2) {
274
+ if (P[list[j]].t !== 'solid') allSolid = false;
275
+ if (list[j + 1] > bw) { bw = list[j + 1]; best = list[j]; }
276
+ }
277
+ if (!allSolid) { if (bw > 0.5) { d.paint = best; d.colour = null; } return; }
278
+ const c = [b.c[0], b.c[1], b.c[2], b.c[3]];
279
+ for (let j = 0; j < list.length; j += 2) {
280
+ const v = P[list[j]].c, w = list[j + 1];
281
+ for (let k = 0; k < 4; k++) c[k] += w * (v[k] - b.c[k]);
282
+ }
283
+ const q = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
284
+ d.paint = basePaint;
285
+ d.colour = `rgba(${q(c[0])},${q(c[1])},${q(c[2])},${Math.min(1, Math.max(0, c[3])).toFixed(3)})`;
286
+ }
287
+ }
288
+
289
+ export async function loadRig(url) {
290
+ const base = new URL(url, location.href);
291
+ const data = await (await fetch(base)).json();
292
+ const images = await Promise.all(data.images.map(async (im) => {
293
+ const r = await fetch(new URL('img/' + im.file, base));
294
+ return createImageBitmap(await r.blob());
295
+ }));
296
+ return new Rig(data, images);
297
+ }
@@ -0,0 +1,96 @@
1
+ // The driver's pose vocabulary, as data.
2
+ //
3
+ // `drivers.js` emits { poseName: weight } maps and nothing else; a name it asks
4
+ // for that the rig does not have is silently dropped by `rig.js`, so a typo
5
+ // shows up as a feature that simply never animates. That failure mode is why
6
+ // this file exists: the driver builds its names from these lists, and the
7
+ // authoring-side checker (`author/validate.mjs`) validates against the same
8
+ // lists. One source, so the two cannot drift.
9
+ //
10
+ // 73 required pose names + 3 optional `wardrobe/*` + 9 optional `rhubarb/*`
11
+ // + 4 optional `gaze/*`, and 6 track names.
12
+
13
+ export const VISEMES = [
14
+ 'viseme/100 - silence', 'viseme/101 - A,AI', 'viseme/102 - a', 'viseme/103 - ae',
15
+ 'viseme/104 - O', 'viseme/105 - i', 'viseme/107 - p,b,m', 'viseme/108 - L',
16
+ 'viseme/109 - v,f', 'viseme/110 - U', 'viseme/112 - o', 'viseme/113 - s,z',
17
+ 'viseme/114 - ol', 'viseme/115 - sh,tsh,zh, dzh', 'viseme/116 - th', 'viseme/118 - kgn',
18
+ ];
19
+
20
+ // The six eye states the expression x blink cross-fade can reach.
21
+ export const eyePose = (n) => `eye/eyes-${n}`;
22
+ export const EYE_STATES = ['idle_closed', 'idle_wide', 'happy', 'happy_closed', 'sad', 'sad_closed'];
23
+ export const EYE_POSES = EYE_STATES.map(eyePose);
24
+
25
+ // Six feature axes, each with a +100 and a -100 extreme.
26
+ export const MORPH_AXES = ['head', 'lips', 'nose', 'brows', 'eyes', 'distance'];
27
+ export const MORPH_POSES = MORPH_AXES.flatMap((a) => [`morph/${a}_100`, `morph/${a}_-100`]);
28
+
29
+ // The iris hue ladder: 37 steps of 10 degrees, zero-padded to three digits.
30
+ // The driver holds one or two neighbouring rungs and the runtime crossfades
31
+ // them solid-to-solid in RGBA.
32
+ export const hueKey = (n) => `hue/${String(n).padStart(3, '0')}`;
33
+ export const HUE_STEP = 10;
34
+ export const HUE_LADDER = Array.from({ length: 360 / HUE_STEP + 1 }, (_, i) => hueKey(i * HUE_STEP));
35
+
36
+ // Two overlays on top of whatever hue is showing. They pull in opposite
37
+ // directions: saturation-0 drains colour, brightness-0 lifts a black overlay.
38
+ export const IRIS_SATURATION = 'iris/eyes-saturation-0';
39
+ export const IRIS_BRIGHTNESS = 'iris/eyes-brightness-0';
40
+ export const IRIS_POSES = [IRIS_SATURATION, IRIS_BRIGHTNESS];
41
+
42
+ // Optional by design: these three only fire when state.hair / suit / glassesOff
43
+ // are raised, and they exist for the bitmap-backed mascot. A hand-authored
44
+ // vector avatar that covers 73 of 76 names is complete, not incomplete.
45
+ export const WARDROBE_HAIR = 'wardrobe/hair-blonde';
46
+ export const WARDROBE_SUIT = 'wardrobe/suit-black';
47
+ export const WARDROBE_GLASSES = 'wardrobe/glasses-off';
48
+ export const WARDROBE_POSES = [WARDROBE_HAIR, WARDROBE_SUIT, WARDROBE_GLASSES];
49
+
50
+ // Optional the same way: GAZE. The four names are the extremes of the driver's
51
+ // `pupilX`/`pupilY` channels (-1..1, + x = the viewer's right, + y = down), so
52
+ // a mixer reaches an arbitrary gaze by holding ONE of each pair at |channel|
53
+ // and the two axes compose. They are named for what a VIEWER sees, not for
54
+ // whose left it is.
55
+ //
56
+ // Optional because an eye that cannot look anywhere but forward is still a
57
+ // complete face — ink and facet do not implement these and are not incomplete
58
+ // — and because a rig that does implement them costs four poses to say so.
59
+ export const GAZE_DIRS = ['left', 'right', 'up', 'down'];
60
+ export const GAZE_POSES = GAZE_DIRS.map((d) => `gaze/${d}`);
61
+
62
+ // The driver's boot state, as data — and the only three numbers in it that any
63
+ // AUTHORING-time code has to know. `drivers.js` sets these three on
64
+ // `this.state` (drivers.js:34-36); it deliberately does NOT import them, so
65
+ // this list is a mirror and has to be kept in step by hand.
66
+ //
67
+ // They are here because they are load-bearing on the far side of the pipeline:
68
+ // the base iris paint in a rig file is solved backwards from where these
69
+ // defaults should land (see `author/rig.mjs` solveIrisBase), so changing one of
70
+ // them silently changes the colour of every avatar's eyes.
71
+ export const DRIVER_DEFAULTS = { hue: 200, saturation: 0.15, brightness: 0.5 };
72
+
73
+ // Tracks, not poses. `breathing` and `subtle` run forever under the ambient
74
+ // amplitude; the four idle one-shots are shuffled, so every one of them must
75
+ // start and end on the exact rest pose.
76
+ export const AMBIENT_TRACKS = ['breathing', 'subtle'];
77
+ export const IDLE_CLIPS = ['idle 1', 'idle 2', 'idle 3', 'idle 4'];
78
+ export const TRACKS = [...AMBIENT_TRACKS, ...IDLE_CLIPS];
79
+
80
+ // --- mouth part: the Rhubarb letters ---------------------------------------
81
+ // The nine-letter Rhubarb alphabet (A–H plus X for silence), which is the
82
+ // alphabet voqalize's lip-sync speaks and the condensation of the Preston Blair
83
+ // set that every open-source aligner already targets. A rig gets these when its
84
+ // mouth is `author/parts/mouth.mjs` — they are baked straight from that
85
+ // contract's own `VISEME_SHAPES` (author/parts/mouth-tables.mjs) with no
86
+ // hand-tuning, so they are a PROOF that the channel mapping lands on the right
87
+ // nine shapes as much as they are a vocabulary.
88
+ //
89
+ // OPTIONAL, and they stay optional: `ink` and `facet` still hold their mouths
90
+ // inline against the pre-channel keys and have no way to bake them, and the
91
+ // 16-code mascot set above remains the required one for every rig.
92
+ export const RHUBARB_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'X'];
93
+ export const RHUBARB_POSES = RHUBARB_LETTERS.map((l) => 'rhubarb/' + l);
94
+
95
+ export const REQUIRED_POSES = [...VISEMES, ...EYE_POSES, ...MORPH_POSES, ...HUE_LADDER, ...IRIS_POSES];
96
+ export const OPTIONAL_POSES = [...WARDROBE_POSES, ...RHUBARB_POSES, ...GAZE_POSES];
package/src/clips.js CHANGED
@@ -6,12 +6,14 @@
6
6
  * is already doing, which is what makes "nod while speaking" work without any
7
7
  * special-casing.
8
8
  *
9
- * Two behaviours matter more here than the curves themselves:
10
- * · clips are interruptible (barge-in must kill an in-flight nod), and
11
- * · the queue collapses duplicates — three stacked nods reads as a fault.
9
+ * A server action is a physical movement, not an abstract state transition.
10
+ * Queued actions finish their landing before the next action begins; their
11
+ * underlying state may change freely while that happens. `stop()` remains for
12
+ * real speech taking mouth ownership.
12
13
  */
13
14
 
14
15
  import { clamp } from './params.js';
16
+ import { INTERJECTION_TRACK_TAIL_MS } from './speech-timing.js';
15
17
  import { VisemeTrack } from './visemes.js';
16
18
 
17
19
  const smoothstep = (t) => t * t * (3 - 2 * t);
@@ -50,6 +52,7 @@ export class ClipPlayer {
50
52
  this.mouth = new VisemeTrack();
51
53
  this._blinksDone = 0;
52
54
  this.onEnd = null;
55
+ this.queue = [];
53
56
  }
54
57
 
55
58
  get playing() { return !!this.clip; }
@@ -60,11 +63,17 @@ export class ClipPlayer {
60
63
  * @param {HTMLAudioElement} [audio] if given, the clip's mouth track is
61
64
  * scheduled against the audio clock instead of the local timer
62
65
  */
63
- play(clip, audio) {
66
+ play(clip, audio, { queue = false } = {}) {
64
67
  if (!clip) return;
65
- // Collapse a repeat of the clip already running.
66
- if (this.clip && this.clip.id === clip.id && !this.fading) return;
68
+ if (this.clip && queue) {
69
+ if (this.clip.id === clip.id || this.queue.some((item) => item.clip.id === clip.id)) return;
70
+ this.queue.push({ clip, audio });
71
+ return;
72
+ }
73
+ this._start(clip, audio);
74
+ }
67
75
 
76
+ _start(clip, audio) {
68
77
  this.clip = clip;
69
78
  this.t = 0;
70
79
  this.fading = false;
@@ -76,7 +85,7 @@ export class ClipPlayer {
76
85
  const clock = audio
77
86
  ? () => audio.currentTime * 1000
78
87
  : () => this.t;
79
- this.mouth.tailMs = 60;
88
+ this.mouth.tailMs = INTERJECTION_TRACK_TAIL_MS;
80
89
  this.mouth.start(clip.mouthCues, clock);
81
90
  } else {
82
91
  this.mouth.stop();
@@ -101,6 +110,8 @@ export class ClipPlayer {
101
110
  if (this.audio) { this.audio.pause(); this.audio = null; }
102
111
  if (had && had.gaze && this.hooks.onGaze) this.hooks.onGaze(null);
103
112
  if (this.onEnd) this.onEnd(had);
113
+ const next = this.queue.shift();
114
+ if (next) this._start(next.clip, next.audio);
104
115
  }
105
116
 
106
117
  /**