talking-head-studio 0.2.7 → 0.2.8

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.
@@ -9,6 +9,10 @@ const html_1 = require("./html");
9
9
  exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onReady, onError, style, }, ref) => {
10
10
  const webViewRef = (0, react_1.useRef)(null);
11
11
  const readyRef = (0, react_1.useRef)(false);
12
+ const pendingMoodRef = (0, react_1.useRef)(mood);
13
+ const pendingHairColorRef = (0, react_1.useRef)(hairColor);
14
+ const pendingSkinColorRef = (0, react_1.useRef)(skinColor);
15
+ const pendingEyeColorRef = (0, react_1.useRef)(eyeColor);
12
16
  const accessoriesRef = (0, react_1.useRef)(accessories);
13
17
  // The WebView HTML is built once from stable initial values.
14
18
  // avatarUrl + authToken changing causes a controlled key-based remount.
@@ -31,14 +35,36 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
31
35
  sendViseme: (viseme, weight = 1.0) => post({ type: 'viseme', viseme, weight }),
32
36
  scheduleVisemes: (schedule) => post({ type: 'schedule_visemes', schedule }),
33
37
  clearVisemes: () => post({ type: 'clear_visemes' }),
34
- setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
35
- setHairColor: (color) => post({ type: 'hair_color', value: color }),
36
- setSkinColor: (color) => post({ type: 'skin_color', value: color }),
37
- setEyeColor: (color) => post({ type: 'eye_color', value: color }),
38
- setAccessories: (newAccessories) => post({ type: 'set_accessories', accessories: newAccessories }),
38
+ setMood: (nextMood) => {
39
+ pendingMoodRef.current = nextMood;
40
+ if (readyRef.current)
41
+ post({ type: 'mood', value: nextMood });
42
+ },
43
+ setHairColor: (color) => {
44
+ pendingHairColorRef.current = color;
45
+ if (readyRef.current)
46
+ post({ type: 'hair_color', value: color });
47
+ },
48
+ setSkinColor: (color) => {
49
+ pendingSkinColorRef.current = color;
50
+ if (readyRef.current)
51
+ post({ type: 'skin_color', value: color });
52
+ },
53
+ setEyeColor: (color) => {
54
+ pendingEyeColorRef.current = color;
55
+ if (readyRef.current)
56
+ post({ type: 'eye_color', value: color });
57
+ },
58
+ setAccessories: (newAccessories) => {
59
+ accessoriesRef.current = newAccessories;
60
+ if (readyRef.current) {
61
+ post({ type: 'set_accessories', accessories: newAccessories });
62
+ }
63
+ },
39
64
  }), [post]);
40
65
  // Sync mood via postMessage only — never causes a WebView reload
41
66
  (0, react_1.useEffect)(() => {
67
+ pendingMoodRef.current = mood;
42
68
  if (readyRef.current)
43
69
  post({ type: 'mood', value: mood });
44
70
  }, [mood, post]);
@@ -49,14 +75,17 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
49
75
  }
50
76
  }, [accessories, post]);
51
77
  (0, react_1.useEffect)(() => {
78
+ pendingHairColorRef.current = hairColor;
52
79
  if (hairColor && readyRef.current)
53
80
  post({ type: 'hair_color', value: hairColor });
54
81
  }, [hairColor, post]);
55
82
  (0, react_1.useEffect)(() => {
83
+ pendingSkinColorRef.current = skinColor;
56
84
  if (skinColor && readyRef.current)
57
85
  post({ type: 'skin_color', value: skinColor });
58
86
  }, [skinColor, post]);
59
87
  (0, react_1.useEffect)(() => {
88
+ pendingEyeColorRef.current = eyeColor;
60
89
  if (eyeColor && readyRef.current)
61
90
  post({ type: 'eye_color', value: eyeColor });
62
91
  }, [eyeColor, post]);
@@ -83,7 +112,19 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
83
112
  const msg = JSON.parse(event.nativeEvent.data);
84
113
  if (msg.type === 'ready') {
85
114
  readyRef.current = true;
86
- // Flush pending props that may have arrived before WebView was ready
115
+ // Flush pending appearance updates that arrived before the WebView was ready.
116
+ if (pendingMoodRef.current) {
117
+ post({ type: 'mood', value: pendingMoodRef.current });
118
+ }
119
+ if (pendingHairColorRef.current) {
120
+ post({ type: 'hair_color', value: pendingHairColorRef.current });
121
+ }
122
+ if (pendingSkinColorRef.current) {
123
+ post({ type: 'skin_color', value: pendingSkinColorRef.current });
124
+ }
125
+ if (pendingEyeColorRef.current) {
126
+ post({ type: 'eye_color', value: pendingEyeColorRef.current });
127
+ }
87
128
  if (accessoriesRef.current?.length) {
88
129
  post({ type: 'set_accessories', accessories: accessoriesRef.current });
89
130
  }
@@ -51,6 +51,10 @@ const iframeStyle = {
51
51
  exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onReady, onError, style, }, ref) => {
52
52
  const iframeRef = (0, react_1.useRef)(null);
53
53
  const readyRef = (0, react_1.useRef)(false);
54
+ const pendingMoodRef = (0, react_1.useRef)(mood);
55
+ const pendingHairColorRef = (0, react_1.useRef)(hairColor);
56
+ const pendingSkinColorRef = (0, react_1.useRef)(skinColor);
57
+ const pendingEyeColorRef = (0, react_1.useRef)(eyeColor);
54
58
  const accessoriesRef = (0, react_1.useRef)(accessories);
55
59
  (0, react_1.useEffect)(() => {
56
60
  accessoriesRef.current = accessories;
@@ -62,13 +66,35 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
62
66
  sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
63
67
  scheduleVisemes: (schedule) => post({ type: 'schedule_visemes', schedule }),
64
68
  clearVisemes: () => post({ type: 'clear_visemes' }),
65
- setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
66
- setHairColor: (color) => post({ type: 'hair_color', value: color }),
67
- setSkinColor: (color) => post({ type: 'skin_color', value: color }),
68
- setEyeColor: (color) => post({ type: 'eye_color', value: color }),
69
- setAccessories: (newAccessories) => post({ type: 'set_accessories', accessories: newAccessories }),
69
+ setMood: (nextMood) => {
70
+ pendingMoodRef.current = nextMood;
71
+ if (readyRef.current)
72
+ post({ type: 'mood', value: nextMood });
73
+ },
74
+ setHairColor: (color) => {
75
+ pendingHairColorRef.current = color;
76
+ if (readyRef.current)
77
+ post({ type: 'hair_color', value: color });
78
+ },
79
+ setSkinColor: (color) => {
80
+ pendingSkinColorRef.current = color;
81
+ if (readyRef.current)
82
+ post({ type: 'skin_color', value: color });
83
+ },
84
+ setEyeColor: (color) => {
85
+ pendingEyeColorRef.current = color;
86
+ if (readyRef.current)
87
+ post({ type: 'eye_color', value: color });
88
+ },
89
+ setAccessories: (newAccessories) => {
90
+ accessoriesRef.current = newAccessories;
91
+ if (readyRef.current) {
92
+ post({ type: 'set_accessories', accessories: newAccessories });
93
+ }
94
+ },
70
95
  }), [post]);
71
96
  (0, react_1.useEffect)(() => {
97
+ pendingMoodRef.current = mood;
72
98
  if (readyRef.current)
73
99
  post({ type: 'mood', value: mood });
74
100
  }, [mood, post]);
@@ -78,14 +104,17 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
78
104
  }
79
105
  }, [accessories, post]);
80
106
  (0, react_1.useEffect)(() => {
107
+ pendingHairColorRef.current = hairColor;
81
108
  if (hairColor && readyRef.current)
82
109
  post({ type: 'hair_color', value: hairColor });
83
110
  }, [hairColor, post]);
84
111
  (0, react_1.useEffect)(() => {
112
+ pendingSkinColorRef.current = skinColor;
85
113
  if (skinColor && readyRef.current)
86
114
  post({ type: 'skin_color', value: skinColor });
87
115
  }, [skinColor, post]);
88
116
  (0, react_1.useEffect)(() => {
117
+ pendingEyeColorRef.current = eyeColor;
89
118
  if (eyeColor && readyRef.current)
90
119
  post({ type: 'eye_color', value: eyeColor });
91
120
  }, [eyeColor, post]);
@@ -124,6 +153,18 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
124
153
  const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
125
154
  if (msg.type === 'ready') {
126
155
  readyRef.current = true;
156
+ if (pendingMoodRef.current) {
157
+ post({ type: 'mood', value: pendingMoodRef.current });
158
+ }
159
+ if (pendingHairColorRef.current) {
160
+ post({ type: 'hair_color', value: pendingHairColorRef.current });
161
+ }
162
+ if (pendingSkinColorRef.current) {
163
+ post({ type: 'skin_color', value: pendingSkinColorRef.current });
164
+ }
165
+ if (pendingEyeColorRef.current) {
166
+ post({ type: 'eye_color', value: pendingEyeColorRef.current });
167
+ }
127
168
  if (accessoriesRef.current?.length) {
128
169
  post({ type: 'set_accessories', accessories: accessoriesRef.current });
129
170
  }
package/dist/html.js CHANGED
@@ -1,10 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildAvatarHtml = buildAvatarHtml;
4
- const VALID_MOODS = new Set(['neutral', 'happy', 'sad', 'angry', 'excited', 'thinking', 'concerned', 'surprised']);
4
+ const UPSTREAM_SAFE_MOOD_MAP = {
5
+ neutral: 'neutral',
6
+ happy: 'happy',
7
+ sad: 'sad',
8
+ angry: 'angry',
9
+ excited: 'happy',
10
+ thinking: 'neutral',
11
+ concerned: 'sad',
12
+ surprised: 'happy',
13
+ };
5
14
  function buildAvatarHtml(config) {
6
- // Sanitize mood at build time so the WebView never receives an invalid value
7
- const safeMood = VALID_MOODS.has(config.mood) ? config.mood : 'neutral';
15
+ const safeMood = UPSTREAM_SAFE_MOOD_MAP[config.mood] ?? 'neutral';
8
16
  return `
9
17
  <!DOCTYPE html>
10
18
  <html>
@@ -33,6 +41,7 @@ const TALKING_HEAD_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.
33
41
  const HEAD_AUDIO_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@v0.1.0-alpha/dist/headaudio.min.mjs';
34
42
  const HEAD_AUDIO_WORKLET = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@v0.1.0-alpha/dist/headworklet.min.mjs';
35
43
  const HEAD_AUDIO_MODEL = 'https://cdn.jsdelivr.net/gh/met4citizen/HeadAudio@v0.1.0-alpha/dist/model-en-mixed.bin';
44
+ const MOOD_MAP = ${JSON.stringify(UPSTREAM_SAFE_MOOD_MAP)};
36
45
 
37
46
  let AVATAR_URL = ${JSON.stringify(config.avatarUrl)};
38
47
  const INITIAL_MOOD = ${JSON.stringify(safeMood)};
@@ -640,8 +649,7 @@ function onIncomingMessage(event) {
640
649
  } else if (msg.type === 'clear_visemes') {
641
650
  clearScheduledVisemes();
642
651
  } else if (msg.type === 'mood' && head) {
643
- const moodMap = { neutral:'neutral', happy:'happy', sad:'sad', angry:'angry', excited:'happy', thinking:'neutral', concerned:'sad', surprised:'happy' };
644
- head.setMood(moodMap[msg.value] || 'neutral');
652
+ head.setMood(MOOD_MAP[msg.value] || 'neutral');
645
653
  } else if (msg.type === 'hair_color') {
646
654
  HAIR_COLOR = msg.value; applyColorOverrides();
647
655
  } else if (msg.type === 'skin_color') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-head-studio",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Cross-platform 3D avatar component for React Native & web — lip-sync, gestures, accessories, and LLM integration. Powered by TalkingHead + Three.js.",
5
5
  "main": "dist/index.web.js",
6
6
  "browser": "dist/index.web.js",