@ai-presence/face 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -0
- package/dist/index.mjs +21 -0
- package/package.json +27 -0
- package/src/presence-face.d.ts +346 -0
- package/src/presence-face.js +1711 -0
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
(function initPresenceFace(globalScope) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const core = resolveCore(globalScope);
|
|
5
|
+
const PresenceState = core?.PresenceState || {};
|
|
6
|
+
const PresenceEvent = core?.PresenceEvent || {};
|
|
7
|
+
|
|
8
|
+
const FaceExpression = Object.freeze({
|
|
9
|
+
IDLE: "idle",
|
|
10
|
+
LISTENING: "listening",
|
|
11
|
+
READING: "reading",
|
|
12
|
+
THINKING: "thinking",
|
|
13
|
+
CURIOUS: "curious",
|
|
14
|
+
AMUSED: "amused",
|
|
15
|
+
DELIGHTED: "delighted",
|
|
16
|
+
UNCERTAIN: "uncertain",
|
|
17
|
+
CONCERNED: "concerned",
|
|
18
|
+
READY: "ready",
|
|
19
|
+
SPEAKING: "speaking",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const FACE_EXPRESSIONS = Object.freeze(Object.values(FaceExpression));
|
|
23
|
+
const faceExpressionSet = new Set(FACE_EXPRESSIONS);
|
|
24
|
+
|
|
25
|
+
const DEFAULT_FACE_MAP = Object.freeze({
|
|
26
|
+
[PresenceState.IDLE || "idle"]: FaceExpression.IDLE,
|
|
27
|
+
[PresenceState.USER_TYPING || "user-typing"]: FaceExpression.LISTENING,
|
|
28
|
+
[PresenceState.READING || "reading"]: FaceExpression.READING,
|
|
29
|
+
[PresenceState.WAITING || "waiting"]: FaceExpression.LISTENING,
|
|
30
|
+
[PresenceState.THINKING || "thinking"]: FaceExpression.THINKING,
|
|
31
|
+
[PresenceState.STREAMING || "streaming"]: FaceExpression.SPEAKING,
|
|
32
|
+
[PresenceState.SPEAKING || "speaking"]: FaceExpression.SPEAKING,
|
|
33
|
+
[PresenceState.INTERRUPTED || "interrupted"]: FaceExpression.UNCERTAIN,
|
|
34
|
+
[PresenceState.READY || "ready"]: FaceExpression.READY,
|
|
35
|
+
[PresenceState.ERROR || "error"]: FaceExpression.CONCERNED,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const DEFAULT_FACE_CONTROL_PROFILE = Object.freeze({
|
|
39
|
+
blinkCadenceMs: 4600,
|
|
40
|
+
drift: 0.18,
|
|
41
|
+
settleMs: 160,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const FACE_CONTROL_CHANNELS = Object.freeze(["gaze", "blink", "brows", "mouth", "posture", "motion"]);
|
|
45
|
+
|
|
46
|
+
const FACE_MOUTH_SHAPES = Object.freeze([
|
|
47
|
+
"curious",
|
|
48
|
+
"downturned",
|
|
49
|
+
"held",
|
|
50
|
+
"listening",
|
|
51
|
+
"preparing",
|
|
52
|
+
"pressed",
|
|
53
|
+
"release",
|
|
54
|
+
"rest",
|
|
55
|
+
"soft-smile",
|
|
56
|
+
"speaking",
|
|
57
|
+
]);
|
|
58
|
+
const PRESENCE_STATE_VALUES = Object.freeze(Object.values(PresenceState));
|
|
59
|
+
const PRESENCE_TRANSITION_EVENT_VALUES = Object.freeze([
|
|
60
|
+
...Object.values(PresenceEvent),
|
|
61
|
+
"set-state",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const FACE_CONTROLLER_READS = Object.freeze({
|
|
65
|
+
gaze: Object.freeze(["state", "detail.question", "attentionTarget", "attentionX", "attentionY", "focus", "transitionEvent", "transitionAgeMs", "ageMs"]),
|
|
66
|
+
blink: Object.freeze(["state", "profile.blinkCadenceMs", "transitionEvent", "transitionAgeMs"]),
|
|
67
|
+
brows: Object.freeze(["state", "detail.question", "detail.revision", "transitionEvent", "transitionAgeMs"]),
|
|
68
|
+
mouth: Object.freeze(["state", "detail.question", "detail.revision", "speechActivity", "tension", "latencyPhase", "recovery", "transitionEvent", "transitionAgeMs"]),
|
|
69
|
+
posture: Object.freeze(["state", "energy", "recovery", "interruption", "latencyPhase", "transitionEvent", "transitionAgeMs"]),
|
|
70
|
+
motion: Object.freeze(["state", "profile.drift", "profile.settleMs", "energy", "anticipation", "recovery", "speechActivity", "latencyPhase", "transitionEvent", "transitionAgeMs", "ageMs"]),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const BLINK_TRANSITION_PULSE_EVENTS = Object.freeze([
|
|
74
|
+
PresenceEvent.SUBMIT || "submit",
|
|
75
|
+
PresenceEvent.STREAM_OPEN || "stream-open",
|
|
76
|
+
PresenceEvent.TOKEN || "token",
|
|
77
|
+
PresenceEvent.INTERRUPT || "interrupt",
|
|
78
|
+
]);
|
|
79
|
+
const GAZE_TRANSITION_RESPONSES = Object.freeze({
|
|
80
|
+
[PresenceEvent.SUBMIT || "submit"]: Object.freeze({ x: -0.024, y: -0.018, focus: 0.035 }),
|
|
81
|
+
[PresenceEvent.STREAM_OPEN || "stream-open"]: Object.freeze({ x: -0.014, y: -0.014, focus: 0.028 }),
|
|
82
|
+
[PresenceEvent.TOKEN || "token"]: Object.freeze({ x: 0.018, y: 0.012, focus: 0.026 }),
|
|
83
|
+
[PresenceEvent.INTERRUPT || "interrupt"]: Object.freeze({ x: -0.04, y: -0.018, focus: 0.05 }),
|
|
84
|
+
});
|
|
85
|
+
const GAZE_TRANSITION_RESPONSE_WINDOW_MS = 240;
|
|
86
|
+
const MOTION_TRANSITION_RESPONSES = Object.freeze({
|
|
87
|
+
[PresenceEvent.SUBMIT || "submit"]: Object.freeze({ x: 0, y: -0.032 }),
|
|
88
|
+
[PresenceEvent.STREAM_OPEN || "stream-open"]: Object.freeze({ x: 0.012, y: -0.024 }),
|
|
89
|
+
[PresenceEvent.TOKEN || "token"]: Object.freeze({ x: 0.018, y: 0.016 }),
|
|
90
|
+
[PresenceEvent.INTERRUPT || "interrupt"]: Object.freeze({ x: -0.032, y: 0.012 }),
|
|
91
|
+
});
|
|
92
|
+
const MOTION_TRANSITION_RESPONSE_WINDOW_MS = 240;
|
|
93
|
+
const MOUTH_TRANSITION_RESPONSES = Object.freeze({
|
|
94
|
+
[PresenceEvent.SUBMIT || "submit"]: Object.freeze({ openness: 0.018, activity: 0.035, tension: 0.035 }),
|
|
95
|
+
[PresenceEvent.STREAM_OPEN || "stream-open"]: Object.freeze({ openness: 0.028, activity: 0.05, tension: -0.035 }),
|
|
96
|
+
[PresenceEvent.TOKEN || "token"]: Object.freeze({ openness: 0.035, activity: 0.06, tension: -0.025 }),
|
|
97
|
+
[PresenceEvent.INTERRUPT || "interrupt"]: Object.freeze({ openness: -0.018, activity: -0.035, tension: 0.08 }),
|
|
98
|
+
});
|
|
99
|
+
const MOUTH_TRANSITION_RESPONSE_WINDOW_MS = 220;
|
|
100
|
+
const BROWS_TRANSITION_RESPONSES = Object.freeze({
|
|
101
|
+
[PresenceEvent.SUBMIT || "submit"]: Object.freeze({ lift: 0.035, pinch: 0.04, asymmetry: 0.006 }),
|
|
102
|
+
[PresenceEvent.STREAM_OPEN || "stream-open"]: Object.freeze({ lift: 0.018, pinch: -0.055, asymmetry: -0.012 }),
|
|
103
|
+
[PresenceEvent.TOKEN || "token"]: Object.freeze({ lift: 0.012, pinch: -0.03, asymmetry: 0 }),
|
|
104
|
+
[PresenceEvent.INTERRUPT || "interrupt"]: Object.freeze({ lift: -0.022, pinch: 0.07, asymmetry: 0.055 }),
|
|
105
|
+
});
|
|
106
|
+
const BROWS_TRANSITION_RESPONSE_WINDOW_MS = 220;
|
|
107
|
+
const POSTURE_TRANSITION_RESPONSES = Object.freeze({
|
|
108
|
+
[PresenceEvent.SUBMIT || "submit"]: Object.freeze({ lean: 0.026, turn: 0.006, energy: 0.035, recovery: 0 }),
|
|
109
|
+
[PresenceEvent.STREAM_OPEN || "stream-open"]: Object.freeze({ lean: 0.018, turn: 0.012, energy: 0.03, recovery: 0 }),
|
|
110
|
+
[PresenceEvent.TOKEN || "token"]: Object.freeze({ lean: -0.012, turn: 0.006, energy: 0.025, recovery: 0 }),
|
|
111
|
+
[PresenceEvent.INTERRUPT || "interrupt"]: Object.freeze({ lean: -0.045, turn: -0.024, energy: 0.02, recovery: 0.14 }),
|
|
112
|
+
});
|
|
113
|
+
const POSTURE_TRANSITION_RESPONSE_WINDOW_MS = 260;
|
|
114
|
+
|
|
115
|
+
function resolveCore(scope) {
|
|
116
|
+
if (scope?.AIPresenceCore) return scope.AIPresenceCore;
|
|
117
|
+
if (typeof require === "function") {
|
|
118
|
+
try {
|
|
119
|
+
return require("@ai-presence/core");
|
|
120
|
+
} catch {
|
|
121
|
+
// Fall back to the no-build monorepo path used by the prototype.
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return require("../../core/src/presence-core.js");
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isFaceExpression(value) {
|
|
133
|
+
return faceExpressionSet.has(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeFaceExpression(value, fallback = FaceExpression.IDLE) {
|
|
137
|
+
return isFaceExpression(value) ? value : fallback;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function faceExpressionForPresence(snapshotOrState, options = {}) {
|
|
141
|
+
const state = typeof snapshotOrState === "string"
|
|
142
|
+
? snapshotOrState
|
|
143
|
+
: snapshotOrState?.state;
|
|
144
|
+
const detail = typeof snapshotOrState === "string"
|
|
145
|
+
? options.detail || {}
|
|
146
|
+
: snapshotOrState?.detail || {};
|
|
147
|
+
const map = options.map || DEFAULT_FACE_MAP;
|
|
148
|
+
|
|
149
|
+
if (isFaceExpression(detail.expression)) return detail.expression;
|
|
150
|
+
if (detail.error) return FaceExpression.CONCERNED;
|
|
151
|
+
if (detail.question && state === PresenceState.READING) return FaceExpression.CURIOUS;
|
|
152
|
+
if (detail.revision && state === PresenceState.READING) return FaceExpression.UNCERTAIN;
|
|
153
|
+
if (detail.ready && state === PresenceState.READY) return FaceExpression.READY;
|
|
154
|
+
|
|
155
|
+
return normalizeFaceExpression(map[state], FaceExpression.IDLE);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeSnapshotInput(snapshotOrState, options = {}) {
|
|
159
|
+
if (typeof snapshotOrState === "string") {
|
|
160
|
+
return {
|
|
161
|
+
state: snapshotOrState,
|
|
162
|
+
previousState: null,
|
|
163
|
+
event: null,
|
|
164
|
+
detail: options.detail || {},
|
|
165
|
+
updatedAt: null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
state: snapshotOrState?.state,
|
|
171
|
+
previousState: snapshotOrState?.previousState || null,
|
|
172
|
+
event: snapshotOrState?.event || null,
|
|
173
|
+
detail: snapshotOrState?.detail || {},
|
|
174
|
+
updatedAt: Number.isFinite(Number(snapshotOrState?.updatedAt))
|
|
175
|
+
? Number(snapshotOrState.updatedAt)
|
|
176
|
+
: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function clamp(value, min, max) {
|
|
181
|
+
return Math.max(min, Math.min(max, value));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function finiteNumber(value, fallback = 0) {
|
|
185
|
+
const numeric = Number(value);
|
|
186
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function finiteClamp(value, min, max, fallback = 0) {
|
|
190
|
+
return clamp(finiteNumber(value, fallback), min, max);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readHistory(options = {}) {
|
|
194
|
+
if (Array.isArray(options.history)) return options.history;
|
|
195
|
+
if (options.trace && typeof options.trace.getEntries === "function") {
|
|
196
|
+
return options.trace.getEntries();
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(options.trace)) return options.trace;
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resolveNow(options, snapshot, history) {
|
|
203
|
+
const optionNow = typeof options.now === "function" ? options.now() : options.now;
|
|
204
|
+
const numericNow = Number(optionNow);
|
|
205
|
+
if (Number.isFinite(numericNow)) return numericNow;
|
|
206
|
+
if (snapshot.updatedAt !== null) return snapshot.updatedAt;
|
|
207
|
+
const latest = history[history.length - 1];
|
|
208
|
+
const latestTime = Number(latest?.updatedAt);
|
|
209
|
+
return Number.isFinite(latestTime) ? latestTime : 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveFrameTime(options, fallbackTime) {
|
|
213
|
+
const optionTime = typeof options.timeMs === "function" ? options.timeMs() : options.timeMs;
|
|
214
|
+
const numericTime = Number(optionTime);
|
|
215
|
+
return Number.isFinite(numericTime) ? numericTime : fallbackTime;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function latestHistoryEntry(history) {
|
|
219
|
+
return history.length ? history[history.length - 1] : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function includesRecentState(snapshot, history, state, windowMs, now) {
|
|
223
|
+
if (snapshot.previousState === state) return true;
|
|
224
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
225
|
+
const entry = history[index];
|
|
226
|
+
if (entry?.state !== state) continue;
|
|
227
|
+
const updatedAt = Number(entry.updatedAt);
|
|
228
|
+
if (!Number.isFinite(updatedAt) || now - updatedAt <= windowMs) return true;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function stateAgeMs(snapshot, history, now) {
|
|
235
|
+
if (snapshot.updatedAt !== null) return Math.max(0, now - snapshot.updatedAt);
|
|
236
|
+
const latest = latestHistoryEntry(history);
|
|
237
|
+
const latestTime = Number(latest?.updatedAt);
|
|
238
|
+
return Number.isFinite(latestTime) ? Math.max(0, now - latestTime) : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function controlProfile(options = {}) {
|
|
242
|
+
const profile = options.profile || {};
|
|
243
|
+
return {
|
|
244
|
+
blinkCadenceMs: Number.isFinite(Number(profile.blinkCadenceMs))
|
|
245
|
+
? Number(profile.blinkCadenceMs)
|
|
246
|
+
: DEFAULT_FACE_CONTROL_PROFILE.blinkCadenceMs,
|
|
247
|
+
drift: Number.isFinite(Number(profile.drift))
|
|
248
|
+
? Number(profile.drift)
|
|
249
|
+
: DEFAULT_FACE_CONTROL_PROFILE.drift,
|
|
250
|
+
settleMs: Number.isFinite(Number(profile.settleMs))
|
|
251
|
+
? Number(profile.settleMs)
|
|
252
|
+
: DEFAULT_FACE_CONTROL_PROFILE.settleMs,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function controlInputsForPresence(snapshot, options) {
|
|
257
|
+
if (typeof core?.presenceControlInputsForSnapshot === "function") {
|
|
258
|
+
return core.presenceControlInputsForSnapshot(snapshot, options);
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function inputNumber(inputs, key, fallback) {
|
|
264
|
+
const value = Number(inputs?.[key]);
|
|
265
|
+
return Number.isFinite(value) ? value : fallback;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isReadyState(stateName) {
|
|
269
|
+
return stateName === PresenceState.READY || stateName === "ready";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function hasFreshTransitionEvent(inputs, events, windowMs) {
|
|
273
|
+
const ageMs = Number(inputs?.transitionAgeMs);
|
|
274
|
+
return events.includes(inputs?.transitionEvent)
|
|
275
|
+
&& Number.isFinite(ageMs)
|
|
276
|
+
&& ageMs >= 0
|
|
277
|
+
&& ageMs <= windowMs;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function transitionGazeResponse(inputs) {
|
|
281
|
+
const response = GAZE_TRANSITION_RESPONSES[inputs?.transitionEvent];
|
|
282
|
+
const ageMs = Number(inputs?.transitionAgeMs);
|
|
283
|
+
if (!response || !Number.isFinite(ageMs) || ageMs < 0 || ageMs > GAZE_TRANSITION_RESPONSE_WINDOW_MS) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const progress = clamp(ageMs / GAZE_TRANSITION_RESPONSE_WINDOW_MS, 0, 1);
|
|
288
|
+
const envelope = (1 - progress) * (1 - progress);
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
x: response.x * envelope,
|
|
291
|
+
y: response.y * envelope,
|
|
292
|
+
focus: response.focus * envelope,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function transitionMouthResponse(inputs) {
|
|
297
|
+
const response = MOUTH_TRANSITION_RESPONSES[inputs?.transitionEvent];
|
|
298
|
+
const ageMs = Number(inputs?.transitionAgeMs);
|
|
299
|
+
if (!response || !Number.isFinite(ageMs) || ageMs < 0 || ageMs > MOUTH_TRANSITION_RESPONSE_WINDOW_MS) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const progress = clamp(ageMs / MOUTH_TRANSITION_RESPONSE_WINDOW_MS, 0, 1);
|
|
304
|
+
const envelope = (1 - progress) * (1 - progress);
|
|
305
|
+
return Object.freeze({
|
|
306
|
+
openness: response.openness * envelope,
|
|
307
|
+
activity: response.activity * envelope,
|
|
308
|
+
tension: response.tension * envelope,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function transitionBrowsResponse(inputs) {
|
|
313
|
+
const response = BROWS_TRANSITION_RESPONSES[inputs?.transitionEvent];
|
|
314
|
+
const ageMs = Number(inputs?.transitionAgeMs);
|
|
315
|
+
if (!response || !Number.isFinite(ageMs) || ageMs < 0 || ageMs > BROWS_TRANSITION_RESPONSE_WINDOW_MS) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const progress = clamp(ageMs / BROWS_TRANSITION_RESPONSE_WINDOW_MS, 0, 1);
|
|
320
|
+
const envelope = (1 - progress) * (1 - progress);
|
|
321
|
+
return Object.freeze({
|
|
322
|
+
lift: response.lift * envelope,
|
|
323
|
+
pinch: response.pinch * envelope,
|
|
324
|
+
asymmetry: response.asymmetry * envelope,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function transitionPostureResponse(inputs) {
|
|
329
|
+
const response = POSTURE_TRANSITION_RESPONSES[inputs?.transitionEvent];
|
|
330
|
+
const ageMs = Number(inputs?.transitionAgeMs);
|
|
331
|
+
if (!response || !Number.isFinite(ageMs) || ageMs < 0 || ageMs > POSTURE_TRANSITION_RESPONSE_WINDOW_MS) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const progress = clamp(ageMs / POSTURE_TRANSITION_RESPONSE_WINDOW_MS, 0, 1);
|
|
336
|
+
const envelope = (1 - progress) * (1 - progress);
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
lean: response.lean * envelope,
|
|
339
|
+
turn: response.turn * envelope,
|
|
340
|
+
energy: response.energy * envelope,
|
|
341
|
+
recovery: response.recovery * envelope,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function faceAttentionTarget(stateName, detail, inputs, fallback) {
|
|
346
|
+
if (detail.question && (stateName === PresenceState.READING || stateName === "reading")) {
|
|
347
|
+
return "question";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
switch (inputs?.attentionTarget) {
|
|
351
|
+
case "audience":
|
|
352
|
+
case "content":
|
|
353
|
+
case "input":
|
|
354
|
+
case "status":
|
|
355
|
+
case "user":
|
|
356
|
+
return inputs.attentionTarget;
|
|
357
|
+
case "response":
|
|
358
|
+
return fallback === "middle-distance" ? "middle-distance" : "response-origin";
|
|
359
|
+
default:
|
|
360
|
+
return fallback;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function recoverySignalsForContext(context) {
|
|
365
|
+
const { inputs, snapshot, history, now } = context;
|
|
366
|
+
const recentlyInterrupted = inputs
|
|
367
|
+
? inputs.latencyPhase === "recovery" && inputs.recovery >= 0.4
|
|
368
|
+
: includesRecentState(snapshot, history, PresenceState.INTERRUPTED || "interrupted", 2400, now);
|
|
369
|
+
const recentlySpoke = inputs
|
|
370
|
+
? inputs.latencyPhase === "recovery" && inputs.speechActivity > 0
|
|
371
|
+
: includesRecentState(snapshot, history, PresenceState.STREAMING || "streaming", 1800, now)
|
|
372
|
+
|| includesRecentState(snapshot, history, PresenceState.SPEAKING || "speaking", 1800, now);
|
|
373
|
+
|
|
374
|
+
return { recentlyInterrupted, recentlySpoke };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function createFaceControllerContext(snapshotOrState, options = {}) {
|
|
378
|
+
const snapshot = normalizeSnapshotInput(snapshotOrState, options);
|
|
379
|
+
const history = readHistory(options);
|
|
380
|
+
const now = resolveNow(options, snapshot, history);
|
|
381
|
+
const profile = controlProfile(options);
|
|
382
|
+
const inputs = controlInputsForPresence(snapshot, { ...options, history, now });
|
|
383
|
+
const stateName = snapshot.state || PresenceState.IDLE || "idle";
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
snapshot,
|
|
387
|
+
snapshotOrState,
|
|
388
|
+
stateName,
|
|
389
|
+
detail: snapshot.detail,
|
|
390
|
+
history,
|
|
391
|
+
now,
|
|
392
|
+
profile,
|
|
393
|
+
inputs,
|
|
394
|
+
ageMs: inputs ? inputs.ageMs : stateAgeMs(snapshot, history, now),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function decideGaze(context) {
|
|
399
|
+
const { stateName, detail, inputs } = context;
|
|
400
|
+
let control;
|
|
401
|
+
|
|
402
|
+
switch (stateName) {
|
|
403
|
+
case PresenceState.USER_TYPING:
|
|
404
|
+
case "user-typing":
|
|
405
|
+
control = {
|
|
406
|
+
target: faceAttentionTarget(stateName, detail, inputs, "input"),
|
|
407
|
+
x: inputNumber(inputs, "attentionX", -0.18),
|
|
408
|
+
y: inputNumber(inputs, "attentionY", 0.18),
|
|
409
|
+
focus: inputNumber(inputs, "focus", 0.7),
|
|
410
|
+
};
|
|
411
|
+
break;
|
|
412
|
+
|
|
413
|
+
case PresenceState.READING:
|
|
414
|
+
case "reading":
|
|
415
|
+
control = {
|
|
416
|
+
target: faceAttentionTarget(stateName, detail, inputs, "content"),
|
|
417
|
+
x: inputNumber(inputs, "attentionX", -0.2),
|
|
418
|
+
y: inputNumber(inputs, "attentionY", 0.28),
|
|
419
|
+
focus: inputNumber(inputs, "focus", 0.76),
|
|
420
|
+
};
|
|
421
|
+
break;
|
|
422
|
+
|
|
423
|
+
case PresenceState.WAITING:
|
|
424
|
+
case "waiting":
|
|
425
|
+
control = {
|
|
426
|
+
target: faceAttentionTarget(stateName, detail, inputs, "response-origin"),
|
|
427
|
+
x: inputNumber(inputs, "attentionX", -0.08),
|
|
428
|
+
y: inputNumber(inputs, "attentionY", -0.04),
|
|
429
|
+
focus: inputNumber(inputs, "focus", 0.66),
|
|
430
|
+
};
|
|
431
|
+
break;
|
|
432
|
+
|
|
433
|
+
case PresenceState.THINKING:
|
|
434
|
+
case "thinking":
|
|
435
|
+
control = {
|
|
436
|
+
target: faceAttentionTarget(stateName, detail, inputs, "middle-distance"),
|
|
437
|
+
x: inputNumber(inputs, "attentionX", 0.16),
|
|
438
|
+
y: inputNumber(inputs, "attentionY", -0.02),
|
|
439
|
+
focus: inputNumber(inputs, "focus", 0.58),
|
|
440
|
+
};
|
|
441
|
+
break;
|
|
442
|
+
|
|
443
|
+
case PresenceState.STREAMING:
|
|
444
|
+
case "streaming":
|
|
445
|
+
control = {
|
|
446
|
+
target: faceAttentionTarget(stateName, detail, inputs, "audience"),
|
|
447
|
+
x: inputNumber(inputs, "attentionX", 0),
|
|
448
|
+
y: inputNumber(inputs, "attentionY", 0),
|
|
449
|
+
focus: inputNumber(inputs, "focus", 0.74),
|
|
450
|
+
};
|
|
451
|
+
break;
|
|
452
|
+
|
|
453
|
+
case PresenceState.SPEAKING:
|
|
454
|
+
case "speaking":
|
|
455
|
+
control = {
|
|
456
|
+
target: faceAttentionTarget(stateName, detail, inputs, "audience"),
|
|
457
|
+
x: inputNumber(inputs, "attentionX", 0),
|
|
458
|
+
y: inputNumber(inputs, "attentionY", -0.02),
|
|
459
|
+
focus: inputNumber(inputs, "focus", 0.78),
|
|
460
|
+
};
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case PresenceState.INTERRUPTED:
|
|
464
|
+
case "interrupted":
|
|
465
|
+
control = {
|
|
466
|
+
target: faceAttentionTarget(stateName, detail, inputs, "user"),
|
|
467
|
+
x: inputNumber(inputs, "attentionX", -0.26),
|
|
468
|
+
y: inputNumber(inputs, "attentionY", -0.08),
|
|
469
|
+
focus: inputNumber(inputs, "focus", 0.88),
|
|
470
|
+
};
|
|
471
|
+
break;
|
|
472
|
+
|
|
473
|
+
case PresenceState.READY:
|
|
474
|
+
case "ready":
|
|
475
|
+
control = {
|
|
476
|
+
target: faceAttentionTarget(stateName, detail, inputs, "user"),
|
|
477
|
+
x: inputNumber(inputs, "attentionX", 0),
|
|
478
|
+
y: inputNumber(inputs, "attentionY", 0),
|
|
479
|
+
focus: inputNumber(inputs, "focus", 0.68),
|
|
480
|
+
};
|
|
481
|
+
break;
|
|
482
|
+
|
|
483
|
+
case PresenceState.ERROR:
|
|
484
|
+
case "error":
|
|
485
|
+
control = {
|
|
486
|
+
target: faceAttentionTarget(stateName, detail, inputs, "status"),
|
|
487
|
+
x: inputNumber(inputs, "attentionX", 0),
|
|
488
|
+
y: inputNumber(inputs, "attentionY", 0.18),
|
|
489
|
+
focus: inputNumber(inputs, "focus", 0.8),
|
|
490
|
+
};
|
|
491
|
+
break;
|
|
492
|
+
|
|
493
|
+
default:
|
|
494
|
+
control = {
|
|
495
|
+
target: faceAttentionTarget(stateName, detail, inputs, "user"),
|
|
496
|
+
x: inputNumber(inputs, "attentionX", 0),
|
|
497
|
+
y: inputNumber(inputs, "attentionY", 0),
|
|
498
|
+
focus: inputNumber(inputs, "focus", 0.56),
|
|
499
|
+
};
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!inputs && isReadyState(stateName)) {
|
|
504
|
+
const softness = clamp(context.ageMs / 1800, 0, 1);
|
|
505
|
+
control.focus = clamp(control.focus - softness * 0.1, 0.5, 0.72);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const transitionGaze = transitionGazeResponse(inputs);
|
|
509
|
+
if (transitionGaze) {
|
|
510
|
+
control.x = clamp(control.x + transitionGaze.x, -1, 1);
|
|
511
|
+
control.y = clamp(control.y + transitionGaze.y, -1, 1);
|
|
512
|
+
control.focus = clamp(control.focus + transitionGaze.focus, 0, 1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return control;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function decideBlink(context) {
|
|
519
|
+
const { stateName, profile, inputs } = context;
|
|
520
|
+
const transitionPulse = hasFreshTransitionEvent(inputs, BLINK_TRANSITION_PULSE_EVENTS, 180);
|
|
521
|
+
let control;
|
|
522
|
+
|
|
523
|
+
switch (stateName) {
|
|
524
|
+
case PresenceState.USER_TYPING:
|
|
525
|
+
case "user-typing":
|
|
526
|
+
control = { openness: 1, cadenceMs: 4400, pulse: false };
|
|
527
|
+
break;
|
|
528
|
+
|
|
529
|
+
case PresenceState.READING:
|
|
530
|
+
case "reading":
|
|
531
|
+
control = { openness: 0.94, cadenceMs: 5200, pulse: false };
|
|
532
|
+
break;
|
|
533
|
+
|
|
534
|
+
case PresenceState.WAITING:
|
|
535
|
+
case "waiting":
|
|
536
|
+
control = { openness: 0.9, cadenceMs: 3400, pulse: false };
|
|
537
|
+
break;
|
|
538
|
+
|
|
539
|
+
case PresenceState.THINKING:
|
|
540
|
+
case "thinking":
|
|
541
|
+
control = { openness: 0.82, cadenceMs: 3800, pulse: false };
|
|
542
|
+
break;
|
|
543
|
+
|
|
544
|
+
case PresenceState.STREAMING:
|
|
545
|
+
case "streaming":
|
|
546
|
+
control = { openness: 0.98, cadenceMs: 6800, pulse: false };
|
|
547
|
+
break;
|
|
548
|
+
|
|
549
|
+
case PresenceState.SPEAKING:
|
|
550
|
+
case "speaking":
|
|
551
|
+
control = { openness: 0.98, cadenceMs: 7200, pulse: false };
|
|
552
|
+
break;
|
|
553
|
+
|
|
554
|
+
case PresenceState.INTERRUPTED:
|
|
555
|
+
case "interrupted":
|
|
556
|
+
control = { openness: 0.72, cadenceMs: 900, pulse: true };
|
|
557
|
+
break;
|
|
558
|
+
|
|
559
|
+
case PresenceState.ERROR:
|
|
560
|
+
case "error":
|
|
561
|
+
control = { openness: 0.86, cadenceMs: 3000, pulse: false };
|
|
562
|
+
break;
|
|
563
|
+
|
|
564
|
+
case PresenceState.READY:
|
|
565
|
+
case "ready":
|
|
566
|
+
default:
|
|
567
|
+
control = { openness: 1, cadenceMs: profile.blinkCadenceMs, pulse: false };
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (transitionPulse) control.pulse = true;
|
|
572
|
+
return control;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function decideBrows(context) {
|
|
576
|
+
const { stateName, detail, inputs } = context;
|
|
577
|
+
let control;
|
|
578
|
+
|
|
579
|
+
switch (stateName) {
|
|
580
|
+
case PresenceState.USER_TYPING:
|
|
581
|
+
case "user-typing":
|
|
582
|
+
control = { lift: 0.08, pinch: 0.06, asymmetry: 0 };
|
|
583
|
+
break;
|
|
584
|
+
|
|
585
|
+
case PresenceState.READING:
|
|
586
|
+
case "reading":
|
|
587
|
+
control = {
|
|
588
|
+
lift: detail.question ? 0.22 : 0.08,
|
|
589
|
+
pinch: detail.revision ? 0.2 : 0.12,
|
|
590
|
+
asymmetry: detail.question ? 0.16 : 0,
|
|
591
|
+
};
|
|
592
|
+
break;
|
|
593
|
+
|
|
594
|
+
case PresenceState.WAITING:
|
|
595
|
+
case "waiting":
|
|
596
|
+
control = { lift: 0.02, pinch: 0.28, asymmetry: 0.04 };
|
|
597
|
+
break;
|
|
598
|
+
|
|
599
|
+
case PresenceState.THINKING:
|
|
600
|
+
case "thinking":
|
|
601
|
+
control = { lift: -0.04, pinch: 0.36, asymmetry: 0.08 };
|
|
602
|
+
break;
|
|
603
|
+
|
|
604
|
+
case PresenceState.STREAMING:
|
|
605
|
+
case "streaming":
|
|
606
|
+
control = { lift: 0.08, pinch: 0.08, asymmetry: 0 };
|
|
607
|
+
break;
|
|
608
|
+
|
|
609
|
+
case PresenceState.SPEAKING:
|
|
610
|
+
case "speaking":
|
|
611
|
+
control = { lift: 0.12, pinch: 0.04, asymmetry: 0 };
|
|
612
|
+
break;
|
|
613
|
+
|
|
614
|
+
case PresenceState.INTERRUPTED:
|
|
615
|
+
case "interrupted":
|
|
616
|
+
control = { lift: -0.12, pinch: 0.54, asymmetry: 0.34 };
|
|
617
|
+
break;
|
|
618
|
+
|
|
619
|
+
case PresenceState.READY:
|
|
620
|
+
case "ready":
|
|
621
|
+
control = { lift: 0.1, pinch: 0, asymmetry: 0 };
|
|
622
|
+
break;
|
|
623
|
+
|
|
624
|
+
case PresenceState.ERROR:
|
|
625
|
+
case "error":
|
|
626
|
+
control = { lift: -0.08, pinch: 0.5, asymmetry: 0.08 };
|
|
627
|
+
break;
|
|
628
|
+
|
|
629
|
+
default:
|
|
630
|
+
control = { lift: 0, pinch: 0, asymmetry: 0 };
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const transitionBrows = transitionBrowsResponse(inputs);
|
|
635
|
+
if (transitionBrows) {
|
|
636
|
+
control.lift = clamp(control.lift + transitionBrows.lift, -1, 1);
|
|
637
|
+
control.pinch = clamp(control.pinch + transitionBrows.pinch, 0, 1);
|
|
638
|
+
control.asymmetry = clamp(control.asymmetry + transitionBrows.asymmetry, -1, 1);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return control;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function decideMouth(context) {
|
|
645
|
+
const { stateName, detail, inputs } = context;
|
|
646
|
+
let control;
|
|
647
|
+
|
|
648
|
+
switch (stateName) {
|
|
649
|
+
case PresenceState.USER_TYPING:
|
|
650
|
+
case "user-typing":
|
|
651
|
+
control = { shape: "listening", openness: 0.03, activity: 0.05, tension: inputNumber(inputs, "tension", 0.08) };
|
|
652
|
+
break;
|
|
653
|
+
|
|
654
|
+
case PresenceState.READING:
|
|
655
|
+
case "reading":
|
|
656
|
+
control = {
|
|
657
|
+
shape: detail.question ? "curious" : "held",
|
|
658
|
+
openness: 0.04,
|
|
659
|
+
activity: 0.08,
|
|
660
|
+
tension: inputNumber(inputs, "tension", detail.revision ? 0.32 : 0.12),
|
|
661
|
+
};
|
|
662
|
+
break;
|
|
663
|
+
|
|
664
|
+
case PresenceState.WAITING:
|
|
665
|
+
case "waiting":
|
|
666
|
+
control = { shape: "preparing", openness: 0.03, activity: 0.16, tension: inputNumber(inputs, "tension", 0.34) };
|
|
667
|
+
break;
|
|
668
|
+
|
|
669
|
+
case PresenceState.THINKING:
|
|
670
|
+
case "thinking":
|
|
671
|
+
control = { shape: "pressed", openness: 0.02, activity: 0.12, tension: inputNumber(inputs, "tension", 0.42) };
|
|
672
|
+
break;
|
|
673
|
+
|
|
674
|
+
case PresenceState.STREAMING:
|
|
675
|
+
case "streaming":
|
|
676
|
+
control = {
|
|
677
|
+
shape: "speaking",
|
|
678
|
+
openness: 0.34,
|
|
679
|
+
activity: inputNumber(inputs, "speechActivity", 0.82),
|
|
680
|
+
tension: inputNumber(inputs, "tension", 0.06),
|
|
681
|
+
};
|
|
682
|
+
break;
|
|
683
|
+
|
|
684
|
+
case PresenceState.SPEAKING:
|
|
685
|
+
case "speaking":
|
|
686
|
+
control = {
|
|
687
|
+
shape: "speaking",
|
|
688
|
+
openness: 0.42,
|
|
689
|
+
activity: inputNumber(inputs, "speechActivity", 1),
|
|
690
|
+
tension: inputNumber(inputs, "tension", 0.04),
|
|
691
|
+
};
|
|
692
|
+
break;
|
|
693
|
+
|
|
694
|
+
case PresenceState.INTERRUPTED:
|
|
695
|
+
case "interrupted":
|
|
696
|
+
control = { shape: "held", openness: 0.07, activity: 0.04, tension: inputNumber(inputs, "tension", 0.72) };
|
|
697
|
+
break;
|
|
698
|
+
|
|
699
|
+
case PresenceState.READY:
|
|
700
|
+
case "ready":
|
|
701
|
+
control = {
|
|
702
|
+
shape: "soft-smile",
|
|
703
|
+
openness: 0.12,
|
|
704
|
+
activity: inputNumber(inputs, "speechActivity", 0.1),
|
|
705
|
+
tension: inputNumber(inputs, "tension", 0),
|
|
706
|
+
};
|
|
707
|
+
break;
|
|
708
|
+
|
|
709
|
+
case PresenceState.ERROR:
|
|
710
|
+
case "error":
|
|
711
|
+
control = { shape: "downturned", openness: 0.03, activity: 0, tension: inputNumber(inputs, "tension", 0.66) };
|
|
712
|
+
break;
|
|
713
|
+
|
|
714
|
+
default:
|
|
715
|
+
control = { shape: "rest", openness: 0, activity: inputNumber(inputs, "speechActivity", 0), tension: inputNumber(inputs, "tension", 0) };
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const { recentlyInterrupted, recentlySpoke } = recoverySignalsForContext(context);
|
|
720
|
+
if (isReadyState(stateName) && recentlyInterrupted) {
|
|
721
|
+
control.tension = Math.max(control.tension, 0.18);
|
|
722
|
+
} else if (isReadyState(stateName) && recentlySpoke) {
|
|
723
|
+
control.shape = "release";
|
|
724
|
+
control.activity = 0.18;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const transitionMouth = transitionMouthResponse(inputs);
|
|
728
|
+
if (transitionMouth) {
|
|
729
|
+
control.openness = clamp(control.openness + transitionMouth.openness, 0, 1);
|
|
730
|
+
control.activity = clamp(control.activity + transitionMouth.activity, 0, 1);
|
|
731
|
+
control.tension = clamp(control.tension + transitionMouth.tension, 0, 1);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return control;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function decidePosture(context) {
|
|
738
|
+
const { stateName, inputs } = context;
|
|
739
|
+
let control;
|
|
740
|
+
|
|
741
|
+
switch (stateName) {
|
|
742
|
+
case PresenceState.USER_TYPING:
|
|
743
|
+
case "user-typing":
|
|
744
|
+
control = { lean: 0.1, turn: -0.04, energy: inputNumber(inputs, "energy", 0.38), recovery: inputNumber(inputs, "recovery", 0) };
|
|
745
|
+
break;
|
|
746
|
+
|
|
747
|
+
case PresenceState.READING:
|
|
748
|
+
case "reading":
|
|
749
|
+
control = { lean: 0.14, turn: -0.06, energy: inputNumber(inputs, "energy", 0.42), recovery: inputNumber(inputs, "recovery", 0) };
|
|
750
|
+
break;
|
|
751
|
+
|
|
752
|
+
case PresenceState.WAITING:
|
|
753
|
+
case "waiting":
|
|
754
|
+
control = { lean: 0.24, turn: 0, energy: inputNumber(inputs, "energy", 0.5), recovery: inputNumber(inputs, "recovery", 0) };
|
|
755
|
+
break;
|
|
756
|
+
|
|
757
|
+
case PresenceState.THINKING:
|
|
758
|
+
case "thinking":
|
|
759
|
+
control = { lean: 0.18, turn: 0.05, energy: inputNumber(inputs, "energy", 0.48), recovery: inputNumber(inputs, "recovery", 0) };
|
|
760
|
+
break;
|
|
761
|
+
|
|
762
|
+
case PresenceState.STREAMING:
|
|
763
|
+
case "streaming":
|
|
764
|
+
control = { lean: 0.14, turn: 0, energy: inputNumber(inputs, "energy", 0.72), recovery: inputNumber(inputs, "recovery", 0) };
|
|
765
|
+
break;
|
|
766
|
+
|
|
767
|
+
case PresenceState.SPEAKING:
|
|
768
|
+
case "speaking":
|
|
769
|
+
control = { lean: 0.12, turn: 0, energy: inputNumber(inputs, "energy", 0.78), recovery: inputNumber(inputs, "recovery", 0) };
|
|
770
|
+
break;
|
|
771
|
+
|
|
772
|
+
case PresenceState.INTERRUPTED:
|
|
773
|
+
case "interrupted":
|
|
774
|
+
control = {
|
|
775
|
+
lean: -0.22,
|
|
776
|
+
turn: -0.08,
|
|
777
|
+
energy: inputNumber(inputs, "energy", 0.62),
|
|
778
|
+
recovery: Math.max(inputNumber(inputs, "recovery", 0), 0.16),
|
|
779
|
+
};
|
|
780
|
+
break;
|
|
781
|
+
|
|
782
|
+
case PresenceState.READY:
|
|
783
|
+
case "ready":
|
|
784
|
+
control = { lean: 0.02, turn: 0, energy: inputNumber(inputs, "energy", 0.3), recovery: inputNumber(inputs, "recovery", 0) };
|
|
785
|
+
break;
|
|
786
|
+
|
|
787
|
+
case PresenceState.ERROR:
|
|
788
|
+
case "error":
|
|
789
|
+
control = { lean: -0.12, turn: 0, energy: inputNumber(inputs, "energy", 0.42), recovery: inputNumber(inputs, "recovery", 0) };
|
|
790
|
+
break;
|
|
791
|
+
|
|
792
|
+
default:
|
|
793
|
+
control = { lean: 0, turn: 0, energy: inputNumber(inputs, "energy", 0.2), recovery: inputNumber(inputs, "recovery", 0) };
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (isReadyState(stateName) && recoverySignalsForContext(context).recentlyInterrupted) {
|
|
798
|
+
control.recovery = Math.max(control.recovery, 0.42);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const transitionPosture = transitionPostureResponse(inputs);
|
|
802
|
+
if (transitionPosture) {
|
|
803
|
+
control.lean = clamp(control.lean + transitionPosture.lean, -1, 1);
|
|
804
|
+
control.turn = clamp(control.turn + transitionPosture.turn, -1, 1);
|
|
805
|
+
control.energy = clamp(control.energy + transitionPosture.energy, 0, 1);
|
|
806
|
+
control.recovery = clamp(control.recovery + transitionPosture.recovery, 0, 1);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return control;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function decideMotion(context) {
|
|
813
|
+
const { stateName, inputs, profile } = context;
|
|
814
|
+
let control;
|
|
815
|
+
|
|
816
|
+
switch (stateName) {
|
|
817
|
+
case PresenceState.USER_TYPING:
|
|
818
|
+
case "user-typing":
|
|
819
|
+
control = {
|
|
820
|
+
energy: 0.34,
|
|
821
|
+
drift: profile.drift + 0.04,
|
|
822
|
+
anticipation: inputNumber(inputs, "anticipation", 0.18),
|
|
823
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
824
|
+
settleMs: profile.settleMs,
|
|
825
|
+
};
|
|
826
|
+
break;
|
|
827
|
+
|
|
828
|
+
case PresenceState.READING:
|
|
829
|
+
case "reading":
|
|
830
|
+
control = {
|
|
831
|
+
energy: 0.34,
|
|
832
|
+
drift: profile.drift + 0.02,
|
|
833
|
+
anticipation: inputNumber(inputs, "anticipation", 0.22),
|
|
834
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
835
|
+
settleMs: profile.settleMs,
|
|
836
|
+
};
|
|
837
|
+
break;
|
|
838
|
+
|
|
839
|
+
case PresenceState.WAITING:
|
|
840
|
+
case "waiting":
|
|
841
|
+
control = {
|
|
842
|
+
energy: 0.46,
|
|
843
|
+
drift: profile.drift + 0.08,
|
|
844
|
+
anticipation: inputNumber(inputs, "anticipation", 0.62),
|
|
845
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
846
|
+
settleMs: 120,
|
|
847
|
+
};
|
|
848
|
+
break;
|
|
849
|
+
|
|
850
|
+
case PresenceState.THINKING:
|
|
851
|
+
case "thinking":
|
|
852
|
+
control = {
|
|
853
|
+
energy: 0.42,
|
|
854
|
+
drift: profile.drift + 0.05,
|
|
855
|
+
anticipation: inputNumber(inputs, "anticipation", 0.52),
|
|
856
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
857
|
+
settleMs: 140,
|
|
858
|
+
};
|
|
859
|
+
break;
|
|
860
|
+
|
|
861
|
+
case PresenceState.STREAMING:
|
|
862
|
+
case "streaming":
|
|
863
|
+
control = {
|
|
864
|
+
energy: 0.78,
|
|
865
|
+
drift: profile.drift + 0.1,
|
|
866
|
+
anticipation: inputNumber(inputs, "anticipation", 0.12),
|
|
867
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
868
|
+
settleMs: 90,
|
|
869
|
+
};
|
|
870
|
+
break;
|
|
871
|
+
|
|
872
|
+
case PresenceState.SPEAKING:
|
|
873
|
+
case "speaking":
|
|
874
|
+
control = {
|
|
875
|
+
energy: 0.86,
|
|
876
|
+
drift: profile.drift + 0.12,
|
|
877
|
+
anticipation: inputNumber(inputs, "anticipation", 0),
|
|
878
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
879
|
+
settleMs: 80,
|
|
880
|
+
};
|
|
881
|
+
break;
|
|
882
|
+
|
|
883
|
+
case PresenceState.INTERRUPTED:
|
|
884
|
+
case "interrupted":
|
|
885
|
+
control = {
|
|
886
|
+
energy: 0.54,
|
|
887
|
+
drift: profile.drift + 0.04,
|
|
888
|
+
anticipation: inputNumber(inputs, "anticipation", 0),
|
|
889
|
+
recovery: inputNumber(inputs, "recovery", 1),
|
|
890
|
+
settleMs: 90,
|
|
891
|
+
};
|
|
892
|
+
break;
|
|
893
|
+
|
|
894
|
+
case PresenceState.READY:
|
|
895
|
+
case "ready":
|
|
896
|
+
control = {
|
|
897
|
+
energy: clamp(inputNumber(inputs, "energy", 0.3) - 0.06, 0.16, 1),
|
|
898
|
+
drift: profile.drift,
|
|
899
|
+
anticipation: inputNumber(inputs, "anticipation", 0),
|
|
900
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
901
|
+
settleMs: profile.settleMs + 40,
|
|
902
|
+
};
|
|
903
|
+
break;
|
|
904
|
+
|
|
905
|
+
case PresenceState.ERROR:
|
|
906
|
+
case "error":
|
|
907
|
+
control = {
|
|
908
|
+
energy: 0.18,
|
|
909
|
+
drift: profile.drift * 0.6,
|
|
910
|
+
anticipation: inputNumber(inputs, "anticipation", 0),
|
|
911
|
+
recovery: Math.max(inputNumber(inputs, "recovery", 0), 0.24),
|
|
912
|
+
settleMs: profile.settleMs + 80,
|
|
913
|
+
};
|
|
914
|
+
break;
|
|
915
|
+
|
|
916
|
+
default:
|
|
917
|
+
control = {
|
|
918
|
+
energy: inputNumber(inputs, "energy", 0.2),
|
|
919
|
+
drift: profile.drift,
|
|
920
|
+
anticipation: inputNumber(inputs, "anticipation", 0),
|
|
921
|
+
recovery: inputNumber(inputs, "recovery", 0),
|
|
922
|
+
settleMs: profile.settleMs,
|
|
923
|
+
};
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const { recentlyInterrupted, recentlySpoke } = recoverySignalsForContext(context);
|
|
928
|
+
if (isReadyState(stateName) && recentlyInterrupted) {
|
|
929
|
+
control.recovery = Math.max(control.recovery, 0.38);
|
|
930
|
+
control.settleMs = Math.max(control.settleMs, 220);
|
|
931
|
+
} else if (isReadyState(stateName) && recentlySpoke) {
|
|
932
|
+
control.settleMs = Math.max(control.settleMs, 210);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!inputs && isReadyState(stateName)) {
|
|
936
|
+
const softness = clamp(context.ageMs / 1800, 0, 1);
|
|
937
|
+
control.energy = clamp(control.energy - softness * 0.08, 0.16, 1);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return control;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const faceControllerDeciders = Object.freeze({
|
|
944
|
+
gaze: decideGaze,
|
|
945
|
+
blink: decideBlink,
|
|
946
|
+
brows: decideBrows,
|
|
947
|
+
mouth: decideMouth,
|
|
948
|
+
posture: decidePosture,
|
|
949
|
+
motion: decideMotion,
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
function composeFaceControllerDecisions(context) {
|
|
953
|
+
const decisions = {};
|
|
954
|
+
for (const channel of FACE_CONTROL_CHANNELS) {
|
|
955
|
+
decisions[channel] = Object.freeze({
|
|
956
|
+
channel,
|
|
957
|
+
controller: `${channel}-controller`,
|
|
958
|
+
reads: FACE_CONTROLLER_READS[channel],
|
|
959
|
+
control: Object.freeze(faceControllerDeciders[channel](context)),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
return Object.freeze(decisions);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function freezeSharedControlInputs(inputs) {
|
|
966
|
+
if (!inputs) return null;
|
|
967
|
+
return Object.freeze({
|
|
968
|
+
...inputs,
|
|
969
|
+
recentStates: Object.freeze([...(inputs.recentStates || [])]),
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function freezeControlsFromDecisions(expression, decisions) {
|
|
974
|
+
return Object.freeze({
|
|
975
|
+
expression,
|
|
976
|
+
gaze: decisions.gaze.control,
|
|
977
|
+
blink: decisions.blink.control,
|
|
978
|
+
brows: decisions.brows.control,
|
|
979
|
+
mouth: decisions.mouth.control,
|
|
980
|
+
posture: decisions.posture.control,
|
|
981
|
+
motion: decisions.motion.control,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function normalizedWave(timeMs, periodMs, offset = 0) {
|
|
986
|
+
const period = Math.max(1, finiteNumber(periodMs, 1));
|
|
987
|
+
const turns = (finiteNumber(timeMs, 0) / period) + offset;
|
|
988
|
+
return Math.sin(turns * Math.PI * 2);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function normalizedPhase(timeMs, periodMs, offset = 0) {
|
|
992
|
+
const period = Math.max(1, finiteNumber(periodMs, 1));
|
|
993
|
+
const raw = (finiteNumber(timeMs, 0) / period) + offset;
|
|
994
|
+
return raw - Math.floor(raw);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function resolveMotionScale(options = {}) {
|
|
998
|
+
return finiteClamp(options.motionScale, 0, 1, 1);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function blinkClosureForPhase(phase, pulse) {
|
|
1002
|
+
const closure = phase < 0.08
|
|
1003
|
+
? 1 - Math.abs(phase - 0.04) / 0.04
|
|
1004
|
+
: phase > 0.92
|
|
1005
|
+
? (phase - 0.92) / 0.08
|
|
1006
|
+
: 0;
|
|
1007
|
+
return clamp(closure * (pulse ? 0.52 : 0.32), 0, 0.72);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function transitionBlinkClosure(inputs, motionScale) {
|
|
1011
|
+
if (!hasFreshTransitionEvent(inputs, BLINK_TRANSITION_PULSE_EVENTS, 180)) return 0;
|
|
1012
|
+
const progress = clamp(finiteNumber(inputs.transitionAgeMs, 0) / 180, 0, 1);
|
|
1013
|
+
const envelope = progress < 0.45
|
|
1014
|
+
? progress / 0.45
|
|
1015
|
+
: 1 - ((progress - 0.45) / 0.55);
|
|
1016
|
+
return clamp(envelope * 0.52 * motionScale, 0, 0.72);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function transitionMotionResponse(inputs, motionScale) {
|
|
1020
|
+
const response = MOTION_TRANSITION_RESPONSES[inputs?.transitionEvent];
|
|
1021
|
+
const ageMs = Number(inputs?.transitionAgeMs);
|
|
1022
|
+
if (!response || !Number.isFinite(ageMs) || ageMs < 0 || ageMs > MOTION_TRANSITION_RESPONSE_WINDOW_MS) {
|
|
1023
|
+
return Object.freeze({ x: 0, y: 0 });
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const progress = clamp(ageMs / MOTION_TRANSITION_RESPONSE_WINDOW_MS, 0, 1);
|
|
1027
|
+
const envelope = (1 - progress) * (1 - progress) * motionScale;
|
|
1028
|
+
return Object.freeze({
|
|
1029
|
+
x: response.x * envelope,
|
|
1030
|
+
y: response.y * envelope,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function composeFaceControllerFrame(report, context, options = {}) {
|
|
1035
|
+
const timeMs = resolveFrameTime(options, context.now);
|
|
1036
|
+
const motionScale = resolveMotionScale(options);
|
|
1037
|
+
const ageMs = stateAgeMs(context.snapshot, context.history, timeMs);
|
|
1038
|
+
const controls = freezeControlsFromDecisions(report.expression, report.decisions);
|
|
1039
|
+
const motion = controls.motion;
|
|
1040
|
+
const blink = controls.blink;
|
|
1041
|
+
const mouth = controls.mouth;
|
|
1042
|
+
const posture = controls.posture;
|
|
1043
|
+
const cadenceMs = finiteClamp(blink.cadenceMs, 300, 20000, context.profile.blinkCadenceMs);
|
|
1044
|
+
const phase = normalizedPhase(timeMs * motionScale, cadenceMs);
|
|
1045
|
+
const closure = Math.max(
|
|
1046
|
+
blinkClosureForPhase(phase, blink.pulse) * motionScale,
|
|
1047
|
+
transitionBlinkClosure(context.inputs, motionScale),
|
|
1048
|
+
);
|
|
1049
|
+
const energy = finiteClamp(motion.energy, 0, 1, 0);
|
|
1050
|
+
const drift = finiteClamp(motion.drift, 0, 1, context.profile.drift);
|
|
1051
|
+
const anticipation = finiteClamp(motion.anticipation, 0, 1, 0);
|
|
1052
|
+
const recovery = finiteClamp(motion.recovery, 0, 1, 0);
|
|
1053
|
+
const speechActivity = finiteClamp(mouth.activity, 0, 1, 0);
|
|
1054
|
+
const driftWaveX = motionScale === 0 ? 0 : normalizedWave(timeMs, 2400, 0.13) * motionScale;
|
|
1055
|
+
const driftWaveY = motionScale === 0 ? 0 : normalizedWave(timeMs, 3100, 0.41) * motionScale;
|
|
1056
|
+
const speechBeat = speechActivity * (0.5 + normalizedWave(timeMs, 260, 0.08) * 0.5) * motionScale;
|
|
1057
|
+
const breath = clamp((0.5 + normalizedWave(timeMs, 3600, 0.32) * 0.5) * motionScale, 0, 1);
|
|
1058
|
+
const transitionMotion = transitionMotionResponse(context.inputs, motionScale);
|
|
1059
|
+
const settle = clamp(ageMs / Math.max(1, finiteNumber(motion.settleMs, context.profile.settleMs)), 0, 1);
|
|
1060
|
+
const driftScale = drift * (0.18 + energy * 0.32) * (1 - recovery * 0.35);
|
|
1061
|
+
const driftX = driftWaveX * driftScale;
|
|
1062
|
+
const driftY = driftWaveY * driftScale * 0.72;
|
|
1063
|
+
const anticipationKick = anticipation * (1 - settle) * 0.08 * motionScale;
|
|
1064
|
+
const recoveryDrop = recovery * (1 - settle * 0.45) * 0.08 * motionScale;
|
|
1065
|
+
|
|
1066
|
+
return Object.freeze({
|
|
1067
|
+
gaze: Object.freeze({
|
|
1068
|
+
target: controls.gaze.target,
|
|
1069
|
+
x: finiteClamp(controls.gaze.x + driftX * 0.18 - recoveryDrop, -1, 1, 0),
|
|
1070
|
+
y: finiteClamp(controls.gaze.y + driftY * 0.12 - anticipationKick, -1, 1, 0),
|
|
1071
|
+
focus: finiteClamp(controls.gaze.focus - closure * 0.2 + anticipation * 0.04, 0, 1, 0.56),
|
|
1072
|
+
driftX: finiteClamp(driftX, -0.2, 0.2, 0),
|
|
1073
|
+
driftY: finiteClamp(driftY, -0.2, 0.2, 0),
|
|
1074
|
+
}),
|
|
1075
|
+
blink: Object.freeze({
|
|
1076
|
+
openness: finiteClamp(blink.openness - closure, 0, 1, 1),
|
|
1077
|
+
cadenceMs,
|
|
1078
|
+
pulse: Boolean(blink.pulse),
|
|
1079
|
+
phase: finiteClamp(phase, 0, 1, 0),
|
|
1080
|
+
}),
|
|
1081
|
+
brows: Object.freeze({
|
|
1082
|
+
lift: finiteClamp(controls.brows.lift + breath * 0.018 + anticipation * 0.04 - recovery * 0.03, -1, 1, 0),
|
|
1083
|
+
pinch: finiteClamp(controls.brows.pinch + anticipation * 0.04 + recovery * 0.06, 0, 1, 0),
|
|
1084
|
+
asymmetry: finiteClamp(controls.brows.asymmetry + driftWaveX * drift * 0.06, -1, 1, 0),
|
|
1085
|
+
}),
|
|
1086
|
+
mouth: Object.freeze({
|
|
1087
|
+
shape: mouth.shape,
|
|
1088
|
+
openness: finiteClamp(mouth.openness + speechBeat * 0.16 - closure * 0.04, 0, 1, 0),
|
|
1089
|
+
activity: speechActivity,
|
|
1090
|
+
tension: finiteClamp(mouth.tension + anticipation * 0.04 + recovery * 0.08, 0, 1, 0),
|
|
1091
|
+
beat: finiteClamp(speechBeat, 0, 1, 0),
|
|
1092
|
+
}),
|
|
1093
|
+
posture: Object.freeze({
|
|
1094
|
+
lean: finiteClamp(posture.lean + breath * energy * 0.025 + anticipationKick - recoveryDrop, -1, 1, 0),
|
|
1095
|
+
turn: finiteClamp(posture.turn + driftWaveX * drift * 0.04, -1, 1, 0),
|
|
1096
|
+
energy: finiteClamp(posture.energy, 0, 1, energy),
|
|
1097
|
+
recovery: finiteClamp(posture.recovery, 0, 1, recovery),
|
|
1098
|
+
breath,
|
|
1099
|
+
}),
|
|
1100
|
+
motion: Object.freeze({
|
|
1101
|
+
energy,
|
|
1102
|
+
drift,
|
|
1103
|
+
anticipation,
|
|
1104
|
+
recovery,
|
|
1105
|
+
settleMs: finiteClamp(motion.settleMs, 0, 20000, context.profile.settleMs),
|
|
1106
|
+
offsetX: finiteClamp(driftX + anticipationKick - recoveryDrop + transitionMotion.x, -1, 1, 0),
|
|
1107
|
+
offsetY: finiteClamp(driftY + breath * energy * 0.02 - recoveryDrop + transitionMotion.y, -1, 1, 0),
|
|
1108
|
+
}),
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function finiteInRange(value, min, max) {
|
|
1113
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function validateCoherenceNumber(channel, frame, key, min, max, warnings) {
|
|
1117
|
+
if (!finiteInRange(frame?.[key], min, max)) {
|
|
1118
|
+
warnings.push(`${channel}.${key} must be finite ${min}..${max}`);
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
return frame[key];
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function validateCoherenceString(channel, frame, key, allowed, warnings) {
|
|
1125
|
+
if (!allowed.includes(frame?.[key])) {
|
|
1126
|
+
warnings.push(`${channel}.${key} must be one of ${allowed.join(",")}`);
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
return frame[key];
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function validateCoherenceBoolean(channel, frame, key, warnings) {
|
|
1133
|
+
if (typeof frame?.[key] !== "boolean") {
|
|
1134
|
+
warnings.push(`${channel}.${key} must be boolean`);
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
return frame[key];
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function compactNumber(value) {
|
|
1141
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
1142
|
+
? Math.round(value * 1000) / 1000
|
|
1143
|
+
: null;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function compactBoolean(value) {
|
|
1147
|
+
return typeof value === "boolean" ? value : null;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function compactString(value) {
|
|
1151
|
+
return typeof value === "string" ? value : null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function compactPresenceState(value) {
|
|
1155
|
+
return PRESENCE_STATE_VALUES.includes(value) ? value : null;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function compactTransitionEvent(value) {
|
|
1159
|
+
return PRESENCE_TRANSITION_EVENT_VALUES.includes(value) ? value : null;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function compactTransitionAgeMs(value) {
|
|
1163
|
+
const numeric = Number(value);
|
|
1164
|
+
return Number.isFinite(numeric)
|
|
1165
|
+
? compactNumber(clamp(numeric, 0, 20000))
|
|
1166
|
+
: null;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function compactTransitionContext(inputs) {
|
|
1170
|
+
return Object.freeze({
|
|
1171
|
+
previousState: compactPresenceState(inputs?.previousState),
|
|
1172
|
+
transitionEvent: compactTransitionEvent(inputs?.transitionEvent),
|
|
1173
|
+
transitionAgeMs: compactTransitionAgeMs(inputs?.transitionAgeMs),
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function compactSummaryRecord(summary) {
|
|
1178
|
+
const compact = {};
|
|
1179
|
+
if (!summary || typeof summary !== "object") return Object.freeze(compact);
|
|
1180
|
+
|
|
1181
|
+
for (const [key, value] of Object.entries(summary)) {
|
|
1182
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
1183
|
+
compact[key] = value;
|
|
1184
|
+
} else {
|
|
1185
|
+
compact[key] = compactNumber(value);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
return Object.freeze(compact);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function compactControlSummary(channel, control = {}) {
|
|
1193
|
+
if (channel === "gaze") {
|
|
1194
|
+
return Object.freeze({
|
|
1195
|
+
target: compactString(control.target),
|
|
1196
|
+
x: compactNumber(control.x),
|
|
1197
|
+
y: compactNumber(control.y),
|
|
1198
|
+
focus: compactNumber(control.focus),
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
if (channel === "blink") {
|
|
1202
|
+
return Object.freeze({
|
|
1203
|
+
openness: compactNumber(control.openness),
|
|
1204
|
+
cadenceMs: compactNumber(control.cadenceMs),
|
|
1205
|
+
pulse: compactBoolean(control.pulse),
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
if (channel === "brows") {
|
|
1209
|
+
return Object.freeze({
|
|
1210
|
+
lift: compactNumber(control.lift),
|
|
1211
|
+
pinch: compactNumber(control.pinch),
|
|
1212
|
+
asymmetry: compactNumber(control.asymmetry),
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
if (channel === "mouth") {
|
|
1216
|
+
return Object.freeze({
|
|
1217
|
+
shape: compactString(control.shape),
|
|
1218
|
+
openness: compactNumber(control.openness),
|
|
1219
|
+
activity: compactNumber(control.activity),
|
|
1220
|
+
tension: compactNumber(control.tension),
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
if (channel === "posture") {
|
|
1224
|
+
return Object.freeze({
|
|
1225
|
+
lean: compactNumber(control.lean),
|
|
1226
|
+
turn: compactNumber(control.turn),
|
|
1227
|
+
energy: compactNumber(control.energy),
|
|
1228
|
+
recovery: compactNumber(control.recovery),
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return Object.freeze({
|
|
1232
|
+
energy: compactNumber(control.energy),
|
|
1233
|
+
drift: compactNumber(control.drift),
|
|
1234
|
+
anticipation: compactNumber(control.anticipation),
|
|
1235
|
+
recovery: compactNumber(control.recovery),
|
|
1236
|
+
settleMs: compactNumber(control.settleMs),
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function freezeChannelCoherence(channel, present, bounded, summary, warnings) {
|
|
1241
|
+
return Object.freeze({
|
|
1242
|
+
channel,
|
|
1243
|
+
present,
|
|
1244
|
+
bounded,
|
|
1245
|
+
summary: Object.freeze(summary),
|
|
1246
|
+
warnings: Object.freeze(warnings),
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function hasCompleteChannelCoherenceReports(coherence) {
|
|
1251
|
+
if (!coherence || typeof coherence !== "object") return false;
|
|
1252
|
+
if (!coherence.channelReports || typeof coherence.channelReports !== "object") return false;
|
|
1253
|
+
return FACE_CONTROL_CHANNELS.every((channel) => {
|
|
1254
|
+
const report = coherence.channelReports[channel];
|
|
1255
|
+
return report && typeof report === "object" && report.channel === channel;
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function coherenceForDecisionTrace(frameReport) {
|
|
1260
|
+
const existingCoherence = frameReport?.coherence;
|
|
1261
|
+
return hasCompleteChannelCoherenceReports(existingCoherence)
|
|
1262
|
+
? existingCoherence
|
|
1263
|
+
: faceControllerCoherenceForFrame(frameReport);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function freezeDecisionTraceChannel(channel, decision, channelCoherence) {
|
|
1267
|
+
const warnings = Array.isArray(channelCoherence?.warnings)
|
|
1268
|
+
? [...channelCoherence.warnings]
|
|
1269
|
+
: [];
|
|
1270
|
+
const reads = Array.isArray(decision?.reads)
|
|
1271
|
+
? decision.reads.filter((read) => typeof read === "string")
|
|
1272
|
+
: [];
|
|
1273
|
+
const present = Boolean(channelCoherence?.present);
|
|
1274
|
+
const bounded = Boolean(channelCoherence?.bounded);
|
|
1275
|
+
|
|
1276
|
+
return Object.freeze({
|
|
1277
|
+
channel,
|
|
1278
|
+
controller: typeof decision?.controller === "string" ? decision.controller : null,
|
|
1279
|
+
reads: Object.freeze(reads),
|
|
1280
|
+
control: compactControlSummary(channel, decision?.control),
|
|
1281
|
+
frame: compactSummaryRecord(channelCoherence?.summary),
|
|
1282
|
+
present,
|
|
1283
|
+
bounded,
|
|
1284
|
+
rendererSafe: present && bounded,
|
|
1285
|
+
warningCount: warnings.length,
|
|
1286
|
+
warnings: Object.freeze(warnings),
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function faceControllerCoherenceForFrame(frameReport = {}) {
|
|
1291
|
+
const warnings = [];
|
|
1292
|
+
const frame = frameReport?.frame || {};
|
|
1293
|
+
const decisions = frameReport?.decisions || {};
|
|
1294
|
+
const channelReports = {};
|
|
1295
|
+
|
|
1296
|
+
for (const channel of FACE_CONTROL_CHANNELS) {
|
|
1297
|
+
const channelWarnings = [];
|
|
1298
|
+
const channelFrame = frame[channel];
|
|
1299
|
+
const decision = decisions[channel];
|
|
1300
|
+
const present = Boolean(channelFrame && typeof channelFrame === "object" && decision && typeof decision === "object");
|
|
1301
|
+
|
|
1302
|
+
if (!channelFrame || typeof channelFrame !== "object") {
|
|
1303
|
+
channelWarnings.push(`${channel} frame is missing`);
|
|
1304
|
+
}
|
|
1305
|
+
if (!decision || typeof decision !== "object") {
|
|
1306
|
+
channelWarnings.push(`${channel} decision is missing`);
|
|
1307
|
+
} else {
|
|
1308
|
+
if (decision.channel !== channel) channelWarnings.push(`${channel} decision channel mismatch`);
|
|
1309
|
+
if (decision.controller !== `${channel}-controller`) channelWarnings.push(`${channel} controller mismatch`);
|
|
1310
|
+
if (!Array.isArray(decision.reads) || !decision.reads.includes("state")) {
|
|
1311
|
+
channelWarnings.push(`${channel} decision reads must include state`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const summary = {};
|
|
1316
|
+
if (channel === "gaze") {
|
|
1317
|
+
summary.target = validateCoherenceString(channel, channelFrame, "target", [
|
|
1318
|
+
"audience",
|
|
1319
|
+
"content",
|
|
1320
|
+
"input",
|
|
1321
|
+
"middle-distance",
|
|
1322
|
+
"question",
|
|
1323
|
+
"response-origin",
|
|
1324
|
+
"status",
|
|
1325
|
+
"user",
|
|
1326
|
+
], channelWarnings);
|
|
1327
|
+
summary.x = compactNumber(validateCoherenceNumber(channel, channelFrame, "x", -1, 1, channelWarnings));
|
|
1328
|
+
summary.y = compactNumber(validateCoherenceNumber(channel, channelFrame, "y", -1, 1, channelWarnings));
|
|
1329
|
+
summary.focus = compactNumber(validateCoherenceNumber(channel, channelFrame, "focus", 0, 1, channelWarnings));
|
|
1330
|
+
summary.driftX = compactNumber(validateCoherenceNumber(channel, channelFrame, "driftX", -0.2, 0.2, channelWarnings));
|
|
1331
|
+
summary.driftY = compactNumber(validateCoherenceNumber(channel, channelFrame, "driftY", -0.2, 0.2, channelWarnings));
|
|
1332
|
+
} else if (channel === "blink") {
|
|
1333
|
+
summary.openness = compactNumber(validateCoherenceNumber(channel, channelFrame, "openness", 0, 1, channelWarnings));
|
|
1334
|
+
summary.cadenceMs = compactNumber(validateCoherenceNumber(channel, channelFrame, "cadenceMs", 300, 20000, channelWarnings));
|
|
1335
|
+
summary.pulse = validateCoherenceBoolean(channel, channelFrame, "pulse", channelWarnings);
|
|
1336
|
+
summary.phase = compactNumber(validateCoherenceNumber(channel, channelFrame, "phase", 0, 1, channelWarnings));
|
|
1337
|
+
} else if (channel === "brows") {
|
|
1338
|
+
summary.lift = compactNumber(validateCoherenceNumber(channel, channelFrame, "lift", -1, 1, channelWarnings));
|
|
1339
|
+
summary.pinch = compactNumber(validateCoherenceNumber(channel, channelFrame, "pinch", 0, 1, channelWarnings));
|
|
1340
|
+
summary.asymmetry = compactNumber(validateCoherenceNumber(channel, channelFrame, "asymmetry", -1, 1, channelWarnings));
|
|
1341
|
+
} else if (channel === "mouth") {
|
|
1342
|
+
summary.shape = validateCoherenceString(channel, channelFrame, "shape", FACE_MOUTH_SHAPES, channelWarnings);
|
|
1343
|
+
summary.openness = compactNumber(validateCoherenceNumber(channel, channelFrame, "openness", 0, 1, channelWarnings));
|
|
1344
|
+
summary.activity = compactNumber(validateCoherenceNumber(channel, channelFrame, "activity", 0, 1, channelWarnings));
|
|
1345
|
+
summary.tension = compactNumber(validateCoherenceNumber(channel, channelFrame, "tension", 0, 1, channelWarnings));
|
|
1346
|
+
summary.beat = compactNumber(validateCoherenceNumber(channel, channelFrame, "beat", 0, 1, channelWarnings));
|
|
1347
|
+
} else if (channel === "posture") {
|
|
1348
|
+
summary.lean = compactNumber(validateCoherenceNumber(channel, channelFrame, "lean", -1, 1, channelWarnings));
|
|
1349
|
+
summary.turn = compactNumber(validateCoherenceNumber(channel, channelFrame, "turn", -1, 1, channelWarnings));
|
|
1350
|
+
summary.energy = compactNumber(validateCoherenceNumber(channel, channelFrame, "energy", 0, 1, channelWarnings));
|
|
1351
|
+
summary.recovery = compactNumber(validateCoherenceNumber(channel, channelFrame, "recovery", 0, 1, channelWarnings));
|
|
1352
|
+
summary.breath = compactNumber(validateCoherenceNumber(channel, channelFrame, "breath", 0, 1, channelWarnings));
|
|
1353
|
+
} else if (channel === "motion") {
|
|
1354
|
+
summary.energy = compactNumber(validateCoherenceNumber(channel, channelFrame, "energy", 0, 1, channelWarnings));
|
|
1355
|
+
summary.drift = compactNumber(validateCoherenceNumber(channel, channelFrame, "drift", 0, 1, channelWarnings));
|
|
1356
|
+
summary.anticipation = compactNumber(validateCoherenceNumber(channel, channelFrame, "anticipation", 0, 1, channelWarnings));
|
|
1357
|
+
summary.recovery = compactNumber(validateCoherenceNumber(channel, channelFrame, "recovery", 0, 1, channelWarnings));
|
|
1358
|
+
summary.settleMs = compactNumber(validateCoherenceNumber(channel, channelFrame, "settleMs", 0, 20000, channelWarnings));
|
|
1359
|
+
summary.offsetX = compactNumber(validateCoherenceNumber(channel, channelFrame, "offsetX", -1, 1, channelWarnings));
|
|
1360
|
+
summary.offsetY = compactNumber(validateCoherenceNumber(channel, channelFrame, "offsetY", -1, 1, channelWarnings));
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const bounded = channelWarnings.length === 0;
|
|
1364
|
+
warnings.push(...channelWarnings);
|
|
1365
|
+
channelReports[channel] = freezeChannelCoherence(channel, present, bounded, summary, channelWarnings);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const presentChannels = FACE_CONTROL_CHANNELS.filter((channel) => channelReports[channel].present);
|
|
1369
|
+
const boundedChannels = FACE_CONTROL_CHANNELS.filter((channel) => channelReports[channel].bounded);
|
|
1370
|
+
const complete = presentChannels.length === FACE_CONTROL_CHANNELS.length;
|
|
1371
|
+
const bounded = boundedChannels.length === FACE_CONTROL_CHANNELS.length;
|
|
1372
|
+
|
|
1373
|
+
return Object.freeze({
|
|
1374
|
+
channels: FACE_CONTROL_CHANNELS,
|
|
1375
|
+
complete,
|
|
1376
|
+
bounded,
|
|
1377
|
+
rendererSafe: complete && bounded,
|
|
1378
|
+
summary: Object.freeze({
|
|
1379
|
+
channelCount: FACE_CONTROL_CHANNELS.length,
|
|
1380
|
+
presentChannelCount: presentChannels.length,
|
|
1381
|
+
boundedChannelCount: boundedChannels.length,
|
|
1382
|
+
gazeTarget: channelReports.gaze.summary.target,
|
|
1383
|
+
gazeFocus: channelReports.gaze.summary.focus,
|
|
1384
|
+
blinkOpenness: channelReports.blink.summary.openness,
|
|
1385
|
+
mouthShape: channelReports.mouth.summary.shape,
|
|
1386
|
+
mouthActivity: channelReports.mouth.summary.activity,
|
|
1387
|
+
postureLean: channelReports.posture.summary.lean,
|
|
1388
|
+
motionEnergy: channelReports.motion.summary.energy,
|
|
1389
|
+
motionRecovery: channelReports.motion.summary.recovery,
|
|
1390
|
+
}),
|
|
1391
|
+
channelReports: Object.freeze(channelReports),
|
|
1392
|
+
warnings: Object.freeze(warnings),
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function faceControllerDecisionTraceForFrame(frameReport = {}) {
|
|
1397
|
+
const report = frameReport && typeof frameReport === "object" ? frameReport : {};
|
|
1398
|
+
const decisions = report.decisions && typeof report.decisions === "object"
|
|
1399
|
+
? report.decisions
|
|
1400
|
+
: {};
|
|
1401
|
+
const coherence = coherenceForDecisionTrace(report);
|
|
1402
|
+
const channelTraces = {};
|
|
1403
|
+
let decisionCount = 0;
|
|
1404
|
+
|
|
1405
|
+
for (const channel of FACE_CONTROL_CHANNELS) {
|
|
1406
|
+
const decision = decisions[channel];
|
|
1407
|
+
if (decision && typeof decision === "object") decisionCount += 1;
|
|
1408
|
+
channelTraces[channel] = freezeDecisionTraceChannel(
|
|
1409
|
+
channel,
|
|
1410
|
+
decision,
|
|
1411
|
+
coherence.channelReports[channel],
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const warnings = Array.isArray(coherence.warnings) ? [...coherence.warnings] : [];
|
|
1416
|
+
const complete = Boolean(coherence.complete && decisionCount === FACE_CONTROL_CHANNELS.length);
|
|
1417
|
+
|
|
1418
|
+
return Object.freeze({
|
|
1419
|
+
channels: FACE_CONTROL_CHANNELS,
|
|
1420
|
+
transitionContext: compactTransitionContext(report.sharedInputs),
|
|
1421
|
+
decisionCount,
|
|
1422
|
+
complete,
|
|
1423
|
+
rendererSafe: Boolean(complete && coherence.rendererSafe),
|
|
1424
|
+
warningCount: warnings.length,
|
|
1425
|
+
warnings: Object.freeze(warnings),
|
|
1426
|
+
decisions: Object.freeze(channelTraces),
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function formatSvgNumber(value, fallback = 0) {
|
|
1431
|
+
const numeric = finiteNumber(value, fallback);
|
|
1432
|
+
if (Math.abs(numeric) < 0.0005) return "0";
|
|
1433
|
+
return String(Math.round(numeric * 1000) / 1000);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function escapeSvgAttribute(value) {
|
|
1437
|
+
return String(value ?? "")
|
|
1438
|
+
.replace(/&/g, "&")
|
|
1439
|
+
.replace(/"/g, """)
|
|
1440
|
+
.replace(/</g, "<")
|
|
1441
|
+
.replace(/>/g, ">");
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function svgAttribute(name, value) {
|
|
1445
|
+
if (value === false || value === null || value === undefined) return "";
|
|
1446
|
+
return ` ${name}="${escapeSvgAttribute(value === true ? "" : value)}"`;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function svgAttrs(attributes) {
|
|
1450
|
+
return Object.entries(attributes)
|
|
1451
|
+
.map(([name, value]) => svgAttribute(name, value))
|
|
1452
|
+
.join("");
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function serializeChannelEvidence(report) {
|
|
1456
|
+
const evidence = {};
|
|
1457
|
+
for (const channel of FACE_CONTROL_CHANNELS) {
|
|
1458
|
+
evidence[channel] = Object.freeze({
|
|
1459
|
+
controller: report.decisions[channel].controller,
|
|
1460
|
+
reads: report.decisions[channel].reads,
|
|
1461
|
+
frame: report.frame[channel],
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
return Object.freeze(evidence);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function serializeDecisionTraceAttributes(report, decisionTrace) {
|
|
1468
|
+
const latencyPhase = typeof report.sharedInputs?.latencyPhase === "string"
|
|
1469
|
+
? report.sharedInputs.latencyPhase
|
|
1470
|
+
: null;
|
|
1471
|
+
const transitionContext = decisionTrace.transitionContext || compactTransitionContext(report.sharedInputs);
|
|
1472
|
+
const transitionAgeMs = transitionContext.transitionAgeMs === null
|
|
1473
|
+
? null
|
|
1474
|
+
: formatSvgNumber(transitionContext.transitionAgeMs);
|
|
1475
|
+
|
|
1476
|
+
return Object.freeze({
|
|
1477
|
+
decisionTrace: decisionTrace.complete ? "complete" : "incomplete",
|
|
1478
|
+
decisionTraceChannels: decisionTrace.channels.join(" "),
|
|
1479
|
+
decisionTraceDecisions: String(decisionTrace.decisionCount),
|
|
1480
|
+
decisionTraceWarnings: String(decisionTrace.warningCount),
|
|
1481
|
+
decisionTraceRendererSafe: String(decisionTrace.rendererSafe),
|
|
1482
|
+
...(latencyPhase ? { latencyPhase } : {}),
|
|
1483
|
+
...(transitionContext.previousState ? { previousState: transitionContext.previousState } : {}),
|
|
1484
|
+
...(transitionContext.transitionEvent ? { transitionEvent: transitionContext.transitionEvent } : {}),
|
|
1485
|
+
...(transitionContext.transitionEvent && transitionAgeMs !== null ? { transitionAgeMs } : {}),
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function mouthPathForFrame(mouth) {
|
|
1490
|
+
const centerY = 126 + mouth.tension * 6 - mouth.openness * 8 - mouth.beat * 4;
|
|
1491
|
+
const open = mouth.openness * 18 + mouth.beat * 10;
|
|
1492
|
+
const smile = mouth.shape === "soft-smile" || mouth.shape === "release" ? 9 : 0;
|
|
1493
|
+
const downturn = mouth.shape === "downturned" ? -10 : 0;
|
|
1494
|
+
const width = 28 + mouth.activity * 12 - mouth.tension * 5;
|
|
1495
|
+
const leftX = 120 - width;
|
|
1496
|
+
const rightX = 120 + width;
|
|
1497
|
+
const curveY = centerY + open + smile + downturn;
|
|
1498
|
+
|
|
1499
|
+
if (mouth.shape === "pressed") {
|
|
1500
|
+
return `M${formatSvgNumber(leftX)} ${formatSvgNumber(centerY)} L${formatSvgNumber(rightX)} ${formatSvgNumber(centerY)}`;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return `M${formatSvgNumber(leftX)} ${formatSvgNumber(centerY)} C${formatSvgNumber(120 - width * 0.35)} ${formatSvgNumber(curveY)} ${formatSvgNumber(120 + width * 0.35)} ${formatSvgNumber(curveY)} ${formatSvgNumber(rightX)} ${formatSvgNumber(centerY)}`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function renderPresenceFaceSvg(snapshotOrState, options = {}) {
|
|
1507
|
+
const report = faceControllerFrameForPresence(snapshotOrState, options);
|
|
1508
|
+
const frame = report.frame;
|
|
1509
|
+
const width = Number.isFinite(Number(options.width)) ? Number(options.width) : 240;
|
|
1510
|
+
const height = Number.isFinite(Number(options.height)) ? Number(options.height) : 180;
|
|
1511
|
+
const className = options.className || "presence-face-svg";
|
|
1512
|
+
const title = options.title || `${report.state} reference face`;
|
|
1513
|
+
const faceX = 120 + frame.motion.offsetX * 18 + frame.posture.turn * 12;
|
|
1514
|
+
const faceY = 88 + frame.motion.offsetY * 12 - frame.posture.lean * 10;
|
|
1515
|
+
const eyeOpen = clamp(frame.blink.openness, 0.08, 1);
|
|
1516
|
+
const focus = clamp(frame.gaze.focus, 0, 1);
|
|
1517
|
+
const eyeShare = 10 + focus * 8;
|
|
1518
|
+
const lookX = frame.gaze.x * eyeShare;
|
|
1519
|
+
const lookY = frame.gaze.y * 6;
|
|
1520
|
+
const browLift = -frame.brows.lift * 14 + frame.brows.pinch * 5;
|
|
1521
|
+
const browPinch = frame.brows.pinch * 8;
|
|
1522
|
+
const browAsymmetry = frame.brows.asymmetry * 6;
|
|
1523
|
+
const mouthPath = mouthPathForFrame(frame.mouth);
|
|
1524
|
+
const decisionTrace = faceControllerDecisionTraceForFrame(report);
|
|
1525
|
+
const traceAttributes = serializeDecisionTraceAttributes(report, decisionTrace);
|
|
1526
|
+
const attributes = Object.freeze({
|
|
1527
|
+
state: report.state,
|
|
1528
|
+
expression: report.expression,
|
|
1529
|
+
channels: FACE_CONTROL_CHANNELS.join(" "),
|
|
1530
|
+
gazeTarget: frame.gaze.target,
|
|
1531
|
+
blinkOpenness: formatSvgNumber(frame.blink.openness),
|
|
1532
|
+
browsPinch: formatSvgNumber(frame.brows.pinch),
|
|
1533
|
+
mouthShape: frame.mouth.shape,
|
|
1534
|
+
postureLean: formatSvgNumber(frame.posture.lean),
|
|
1535
|
+
motionEnergy: formatSvgNumber(frame.motion.energy),
|
|
1536
|
+
motionScale: formatSvgNumber(resolveMotionScale(options), 1),
|
|
1537
|
+
...traceAttributes,
|
|
1538
|
+
});
|
|
1539
|
+
const channelEvidence = serializeChannelEvidence(report);
|
|
1540
|
+
const rootAttributes = {
|
|
1541
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
1542
|
+
viewBox: "0 0 240 180",
|
|
1543
|
+
width: formatSvgNumber(width, 240),
|
|
1544
|
+
height: formatSvgNumber(height, 180),
|
|
1545
|
+
role: "img",
|
|
1546
|
+
class: className,
|
|
1547
|
+
"aria-label": title,
|
|
1548
|
+
"data-presence-state": attributes.state,
|
|
1549
|
+
"data-face-expression": attributes.expression,
|
|
1550
|
+
"data-face-channels": attributes.channels,
|
|
1551
|
+
"data-gaze-target": attributes.gazeTarget,
|
|
1552
|
+
"data-blink-openness": attributes.blinkOpenness,
|
|
1553
|
+
"data-brows-pinch": attributes.browsPinch,
|
|
1554
|
+
"data-mouth-shape": attributes.mouthShape,
|
|
1555
|
+
"data-posture-lean": attributes.postureLean,
|
|
1556
|
+
"data-motion-energy": attributes.motionEnergy,
|
|
1557
|
+
"data-motion-scale": attributes.motionScale,
|
|
1558
|
+
"data-face-decision-trace": attributes.decisionTrace,
|
|
1559
|
+
"data-face-decision-trace-channels": attributes.decisionTraceChannels,
|
|
1560
|
+
"data-face-decision-trace-decisions": attributes.decisionTraceDecisions,
|
|
1561
|
+
"data-face-decision-trace-warnings": attributes.decisionTraceWarnings,
|
|
1562
|
+
"data-face-decision-trace-renderer-safe": attributes.decisionTraceRendererSafe,
|
|
1563
|
+
"data-face-latency-phase": attributes.latencyPhase,
|
|
1564
|
+
"data-face-previous-state": attributes.previousState,
|
|
1565
|
+
"data-face-transition-event": attributes.transitionEvent,
|
|
1566
|
+
"data-face-transition-age-ms": attributes.transitionAgeMs,
|
|
1567
|
+
};
|
|
1568
|
+
const svg = [
|
|
1569
|
+
`<svg${svgAttrs(rootAttributes)}>`,
|
|
1570
|
+
`<title>${escapeSvgAttribute(title)}</title>`,
|
|
1571
|
+
`<g transform="translate(${formatSvgNumber(faceX - 120)} ${formatSvgNumber(faceY - 88)})">`,
|
|
1572
|
+
`<ellipse cx="120" cy="88" rx="${formatSvgNumber(67 - frame.posture.recovery * 4)}" ry="${formatSvgNumber(72 + frame.posture.lean * 8)}" fill="none" stroke="currentColor" stroke-width="4"/>`,
|
|
1573
|
+
`<path d="M71 ${formatSvgNumber(61 + browLift - browAsymmetry)} C88 ${formatSvgNumber(52 + browLift)} ${formatSvgNumber(100 + browPinch)} ${formatSvgNumber(54 + browLift)} 111 ${formatSvgNumber(63 + browLift)}" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="4"/>`,
|
|
1574
|
+
`<path d="M129 ${formatSvgNumber(63 + browLift)} C${formatSvgNumber(140 - browPinch)} ${formatSvgNumber(54 + browLift)} 152 ${formatSvgNumber(52 + browLift)} 169 ${formatSvgNumber(61 + browLift + browAsymmetry)}" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="4"/>`,
|
|
1575
|
+
`<g transform="translate(${formatSvgNumber(lookX)} ${formatSvgNumber(lookY)}) scale(1 ${formatSvgNumber(eyeOpen)})">`,
|
|
1576
|
+
`<ellipse cx="88" cy="82" rx="12" ry="14" fill="currentColor"/>`,
|
|
1577
|
+
`<ellipse cx="152" cy="82" rx="12" ry="14" fill="currentColor"/>`,
|
|
1578
|
+
`</g>`,
|
|
1579
|
+
`<path d="${mouthPath}" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="${formatSvgNumber(4 + frame.mouth.activity * 2 + frame.mouth.tension)}"/>`,
|
|
1580
|
+
`</g>`,
|
|
1581
|
+
`</svg>`,
|
|
1582
|
+
].join("");
|
|
1583
|
+
|
|
1584
|
+
return Object.freeze({
|
|
1585
|
+
svg,
|
|
1586
|
+
state: report.state,
|
|
1587
|
+
expression: report.expression,
|
|
1588
|
+
frame: report.frame,
|
|
1589
|
+
frameReport: report,
|
|
1590
|
+
attributes,
|
|
1591
|
+
channelEvidence,
|
|
1592
|
+
decisionTrace,
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function faceControllerDecisionReportFromContext(context, options = {}) {
|
|
1597
|
+
const expression = faceExpressionForPresence(context.snapshotOrState, options);
|
|
1598
|
+
return Object.freeze({
|
|
1599
|
+
state: context.stateName,
|
|
1600
|
+
expression,
|
|
1601
|
+
sharedInputs: freezeSharedControlInputs(context.inputs),
|
|
1602
|
+
decisions: composeFaceControllerDecisions(context),
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function faceControllerDecisionsForPresence(snapshotOrState, options = {}) {
|
|
1607
|
+
return faceControllerDecisionReportFromContext(
|
|
1608
|
+
createFaceControllerContext(snapshotOrState, options),
|
|
1609
|
+
options,
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function faceControlsForPresence(snapshotOrState, options = {}) {
|
|
1614
|
+
const report = faceControllerDecisionsForPresence(snapshotOrState, options);
|
|
1615
|
+
return freezeControlsFromDecisions(report.expression, report.decisions);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function faceControllerFrameForPresence(snapshotOrState, options = {}) {
|
|
1619
|
+
const context = createFaceControllerContext(snapshotOrState, options);
|
|
1620
|
+
const report = faceControllerDecisionReportFromContext(context, options);
|
|
1621
|
+
const frameReport = {
|
|
1622
|
+
...report,
|
|
1623
|
+
frame: composeFaceControllerFrame(report, context, options),
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
return Object.freeze({
|
|
1627
|
+
...frameReport,
|
|
1628
|
+
coherence: faceControllerCoherenceForFrame(frameReport),
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function createFaceControllerRuntime(options = {}) {
|
|
1633
|
+
const baseOptions = { ...options };
|
|
1634
|
+
let lastControls = null;
|
|
1635
|
+
|
|
1636
|
+
return Object.freeze({
|
|
1637
|
+
getControls() {
|
|
1638
|
+
return lastControls;
|
|
1639
|
+
},
|
|
1640
|
+
update(snapshot, updateOptions = {}) {
|
|
1641
|
+
const controls = faceControlsForPresence(snapshot, { ...baseOptions, ...updateOptions });
|
|
1642
|
+
lastControls = controls;
|
|
1643
|
+
if (typeof baseOptions.update === "function") baseOptions.update(controls, snapshot);
|
|
1644
|
+
if (typeof updateOptions.update === "function") updateOptions.update(controls, snapshot);
|
|
1645
|
+
return controls;
|
|
1646
|
+
},
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function createFaceControllerFrameRuntime(options = {}) {
|
|
1651
|
+
const baseOptions = { ...options };
|
|
1652
|
+
let lastFrame = null;
|
|
1653
|
+
|
|
1654
|
+
return Object.freeze({
|
|
1655
|
+
getFrame() {
|
|
1656
|
+
return lastFrame;
|
|
1657
|
+
},
|
|
1658
|
+
update(snapshot, updateOptions = {}) {
|
|
1659
|
+
const frame = faceControllerFrameForPresence(snapshot, { ...baseOptions, ...updateOptions });
|
|
1660
|
+
lastFrame = frame;
|
|
1661
|
+
if (typeof baseOptions.update === "function") baseOptions.update(frame, snapshot);
|
|
1662
|
+
if (typeof updateOptions.update === "function") updateOptions.update(frame, snapshot);
|
|
1663
|
+
return frame;
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function createFaceRenderer(options = {}) {
|
|
1669
|
+
const render = typeof options.render === "function" ? options.render : null;
|
|
1670
|
+
const map = options.map || DEFAULT_FACE_MAP;
|
|
1671
|
+
let lastExpression = null;
|
|
1672
|
+
|
|
1673
|
+
return Object.freeze({
|
|
1674
|
+
getExpression() {
|
|
1675
|
+
return lastExpression;
|
|
1676
|
+
},
|
|
1677
|
+
render(snapshot) {
|
|
1678
|
+
const expression = faceExpressionForPresence(snapshot, { map });
|
|
1679
|
+
lastExpression = expression;
|
|
1680
|
+
if (render) render(expression, snapshot);
|
|
1681
|
+
return expression;
|
|
1682
|
+
},
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const api = Object.freeze({
|
|
1687
|
+
DEFAULT_FACE_CONTROL_PROFILE,
|
|
1688
|
+
FaceExpression,
|
|
1689
|
+
FACE_EXPRESSIONS,
|
|
1690
|
+
DEFAULT_FACE_MAP,
|
|
1691
|
+
FACE_CONTROL_CHANNELS,
|
|
1692
|
+
createFaceControllerFrameRuntime,
|
|
1693
|
+
createFaceControllerRuntime,
|
|
1694
|
+
createFaceRenderer,
|
|
1695
|
+
renderPresenceFaceSvg,
|
|
1696
|
+
faceControllerCoherenceForFrame,
|
|
1697
|
+
faceControllerDecisionTraceForFrame,
|
|
1698
|
+
faceControllerFrameForPresence,
|
|
1699
|
+
faceControllerDecisionsForPresence,
|
|
1700
|
+
faceControlsForPresence,
|
|
1701
|
+
faceExpressionForPresence,
|
|
1702
|
+
isFaceExpression,
|
|
1703
|
+
normalizeFaceExpression,
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
if (typeof module === "object" && module.exports) {
|
|
1707
|
+
module.exports = api;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
globalScope.AIPresenceFace = api;
|
|
1711
|
+
})(typeof globalThis !== "undefined" ? globalThis : window);
|