@voqalize/avatar 0.2.1 → 0.3.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 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
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 =
|
|
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
|
/**
|