talking-head-studio 0.3.1 → 0.3.3
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/dist/TalkingHead.d.ts +3 -0
- package/dist/TalkingHead.js +7 -1
- package/dist/TalkingHead.web.d.ts +2 -0
- package/dist/TalkingHead.web.js +5 -1
- package/dist/html.js +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/tts/useDirectVisemeStream.d.ts +4 -0
- package/dist/tts/useDirectVisemeStream.js +137 -71
- package/dist/tts/useMotionMarkers.d.ts +2 -0
- package/dist/tts/useMotionMarkers.js +24 -0
- package/package.json +1 -1
package/dist/TalkingHead.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export interface TalkingHeadProps {
|
|
|
54
54
|
onLoadingChange?: (state: TalkingHeadLoadingState) => void;
|
|
55
55
|
onReady?: () => void;
|
|
56
56
|
onError?: (message: string) => void;
|
|
57
|
+
onAvatarState?: (state: string) => void;
|
|
57
58
|
style?: StyleProp<ViewStyle>;
|
|
58
59
|
/** Base URL for vendored assets. When set, replaces all cdn.jsdelivr.net references. */
|
|
59
60
|
vendorBaseUrl?: string | null;
|
|
@@ -92,5 +93,7 @@ export interface TalkingHeadRef {
|
|
|
92
93
|
setSkinColor: (color: string) => void;
|
|
93
94
|
setEyeColor: (color: string) => void;
|
|
94
95
|
setAccessories: (accessories: TalkingHeadAccessory[]) => void;
|
|
96
|
+
/** Dispatch a named motion/gesture to the avatar (e.g. 'wave_right', 'dance_idle'). */
|
|
97
|
+
dispatchMotion: (name: string) => void;
|
|
95
98
|
}
|
|
96
99
|
export declare const TalkingHead: React.ForwardRefExoticComponent<TalkingHeadProps & React.RefAttributes<TalkingHeadRef>>;
|
package/dist/TalkingHead.js
CHANGED
|
@@ -6,7 +6,7 @@ const react_1 = require("react");
|
|
|
6
6
|
const react_native_1 = require("react-native");
|
|
7
7
|
const react_native_webview_1 = require("react-native-webview");
|
|
8
8
|
const html_1 = require("./html");
|
|
9
|
-
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, style, vendorBaseUrl, }, ref) => {
|
|
9
|
+
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, style, vendorBaseUrl, }, ref) => {
|
|
10
10
|
const webViewRef = (0, react_1.useRef)(null);
|
|
11
11
|
const readyRef = (0, react_1.useRef)(false);
|
|
12
12
|
const firstMessageSeenRef = (0, react_1.useRef)(false);
|
|
@@ -23,6 +23,8 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
23
23
|
onReadyRef.current = onReady;
|
|
24
24
|
const onErrorRef = (0, react_1.useRef)(onError);
|
|
25
25
|
onErrorRef.current = onError;
|
|
26
|
+
const onAvatarStateRef = (0, react_1.useRef)(onAvatarState);
|
|
27
|
+
onAvatarStateRef.current = onAvatarState;
|
|
26
28
|
// The WebView HTML is built once from stable initial values.
|
|
27
29
|
// avatarUrl + authToken changing causes a controlled key-based remount.
|
|
28
30
|
// All other prop changes (mood, colors, accessories) go via postMessage.
|
|
@@ -124,6 +126,7 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
124
126
|
post({ type: 'set_accessories', accessories: newAccessories });
|
|
125
127
|
}
|
|
126
128
|
},
|
|
129
|
+
dispatchMotion: (name) => post({ type: 'motion', name }),
|
|
127
130
|
}), [post]);
|
|
128
131
|
// Sync mood via postMessage only — never causes a WebView reload
|
|
129
132
|
(0, react_1.useEffect)(() => {
|
|
@@ -220,6 +223,9 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
220
223
|
else if (msg.type === 'error') {
|
|
221
224
|
onErrorRef.current?.(msg.message);
|
|
222
225
|
}
|
|
226
|
+
else if (msg.type === 'avatarState') {
|
|
227
|
+
onAvatarStateRef.current?.(msg.state);
|
|
228
|
+
}
|
|
223
229
|
else if (msg.type === 'log') {
|
|
224
230
|
console.log('[TalkingHead]', msg.message);
|
|
225
231
|
}
|
|
@@ -23,6 +23,7 @@ export interface TalkingHeadProps {
|
|
|
23
23
|
onLoadingChange?: (state: TalkingHeadLoadingState) => void;
|
|
24
24
|
onReady?: () => void;
|
|
25
25
|
onError?: (message: string) => void;
|
|
26
|
+
onAvatarState?: (state: string) => void;
|
|
26
27
|
style?: React.CSSProperties;
|
|
27
28
|
}
|
|
28
29
|
export interface TalkingHeadRef {
|
|
@@ -34,5 +35,6 @@ export interface TalkingHeadRef {
|
|
|
34
35
|
setSkinColor: (color: string) => void;
|
|
35
36
|
setEyeColor: (color: string) => void;
|
|
36
37
|
setAccessories: (accessories: TalkingHeadAccessory[]) => void;
|
|
38
|
+
dispatchMotion: (name: string) => void;
|
|
37
39
|
}
|
|
38
40
|
export declare const TalkingHead: React.ForwardRefExoticComponent<TalkingHeadProps & React.RefAttributes<TalkingHeadRef>>;
|
package/dist/TalkingHead.web.js
CHANGED
|
@@ -48,7 +48,7 @@ const iframeStyle = {
|
|
|
48
48
|
border: 'none',
|
|
49
49
|
backgroundColor: 'transparent',
|
|
50
50
|
};
|
|
51
|
-
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, style, }, ref) => {
|
|
51
|
+
exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onLoadingChange, onReady, onError, onAvatarState, style, }, ref) => {
|
|
52
52
|
const iframeRef = (0, react_1.useRef)(null);
|
|
53
53
|
const readyRef = (0, react_1.useRef)(false);
|
|
54
54
|
const pendingMoodRef = (0, react_1.useRef)(mood);
|
|
@@ -97,6 +97,7 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
97
97
|
post({ type: 'set_accessories', accessories: newAccessories });
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
|
+
dispatchMotion: (name) => post({ type: 'motion', name }),
|
|
100
101
|
}), [post]);
|
|
101
102
|
(0, react_1.useEffect)(() => {
|
|
102
103
|
pendingMoodRef.current = mood;
|
|
@@ -188,6 +189,9 @@ exports.TalkingHead = (0, react_1.forwardRef)(({ avatarUrl, authToken, mood = 'n
|
|
|
188
189
|
else if (msg.type === 'error') {
|
|
189
190
|
onError?.(msg.message);
|
|
190
191
|
}
|
|
192
|
+
else if (msg.type === 'avatarState') {
|
|
193
|
+
onAvatarState?.(msg.state);
|
|
194
|
+
}
|
|
191
195
|
else if (msg.type === 'log') {
|
|
192
196
|
console.log('[TalkingHead]', msg.message);
|
|
193
197
|
}
|
package/dist/html.js
CHANGED
|
@@ -856,6 +856,12 @@ function onIncomingMessage(event) {
|
|
|
856
856
|
EYE_COLOR = msg.value; applyColorOverrides();
|
|
857
857
|
} else if (msg.type === 'set_accessories') {
|
|
858
858
|
applyAccessories(msg.accessories || []);
|
|
859
|
+
} else if (msg.type === 'motion' && typeof msg.name === 'string') {
|
|
860
|
+
if ((window as any).motionEngine) {
|
|
861
|
+
(window as any).motionEngine.play(msg.name).catch(() => {});
|
|
862
|
+
}
|
|
863
|
+
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
|
|
864
|
+
log('motion dispatched: ' + msg.name);
|
|
859
865
|
}
|
|
860
866
|
} catch (err) {
|
|
861
867
|
log('Message parse error: ' + err);
|
package/dist/index.d.ts
CHANGED
|
@@ -10,3 +10,4 @@ export { TalkingHeadVisualization } from './TalkingHeadVisualization';
|
|
|
10
10
|
export type { TalkingHeadVisualizationRef } from './TalkingHeadVisualization';
|
|
11
11
|
export { useDirectVisemeStream } from './tts/useDirectVisemeStream';
|
|
12
12
|
export type { VisemeStreamPayload } from './tts/useDirectVisemeStream';
|
|
13
|
+
export { useMotionMarkers } from './tts/useMotionMarkers';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.useDirectVisemeStream = exports.TalkingHeadVisualization = exports.normalizeAppearance = exports.pickTargetForMaterialName = exports.applyAppearanceToObject3D = exports.TalkingHead = void 0;
|
|
17
|
+
exports.useMotionMarkers = exports.useDirectVisemeStream = exports.TalkingHeadVisualization = exports.normalizeAppearance = exports.pickTargetForMaterialName = exports.applyAppearanceToObject3D = exports.TalkingHead = void 0;
|
|
18
18
|
var TalkingHead_1 = require("./TalkingHead");
|
|
19
19
|
Object.defineProperty(exports, "TalkingHead", { enumerable: true, get: function () { return TalkingHead_1.TalkingHead; } });
|
|
20
20
|
// Export appearance utilities, but exclude AvatarAppearance — the canonical
|
|
@@ -31,3 +31,5 @@ var TalkingHeadVisualization_1 = require("./TalkingHeadVisualization");
|
|
|
31
31
|
Object.defineProperty(exports, "TalkingHeadVisualization", { enumerable: true, get: function () { return TalkingHeadVisualization_1.TalkingHeadVisualization; } });
|
|
32
32
|
var useDirectVisemeStream_1 = require("./tts/useDirectVisemeStream");
|
|
33
33
|
Object.defineProperty(exports, "useDirectVisemeStream", { enumerable: true, get: function () { return useDirectVisemeStream_1.useDirectVisemeStream; } });
|
|
34
|
+
var useMotionMarkers_1 = require("./tts/useMotionMarkers");
|
|
35
|
+
Object.defineProperty(exports, "useMotionMarkers", { enumerable: true, get: function () { return useMotionMarkers_1.useMotionMarkers; } });
|
|
@@ -14,6 +14,10 @@ type OpenStreamOptions = {
|
|
|
14
14
|
*
|
|
15
15
|
* Uses fetch() with streaming response body instead of EventSource because
|
|
16
16
|
* React Native does not have a reliable EventSource polyfill.
|
|
17
|
+
*
|
|
18
|
+
* Retries on transient failures (network blip, 503) with exponential backoff
|
|
19
|
+
* up to STREAM_RETRY_BUDGET_MS. Aborts cleanly when a new requestId arrives
|
|
20
|
+
* or the component unmounts.
|
|
17
21
|
*/
|
|
18
22
|
export declare function useDirectVisemeStream(onVisemes: (payload: VisemeStreamPayload) => void): {
|
|
19
23
|
openStream: ({ requestId, ttsBaseUrl }: OpenStreamOptions) => void;
|
|
@@ -3,108 +3,91 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.useDirectVisemeStream = useDirectVisemeStream;
|
|
4
4
|
const react_1 = require("react");
|
|
5
5
|
const fetch_1 = require("expo/fetch");
|
|
6
|
+
// How long to keep retrying a stream before giving up (ms).
|
|
7
|
+
const STREAM_RETRY_BUDGET_MS = 3000;
|
|
8
|
+
// Initial retry delay; doubles each attempt up to MAX_RETRY_DELAY_MS.
|
|
9
|
+
const INITIAL_RETRY_DELAY_MS = 150;
|
|
10
|
+
const MAX_RETRY_DELAY_MS = 1000;
|
|
6
11
|
/**
|
|
7
12
|
* Opens a direct SSE connection to the TTS server to receive viseme data,
|
|
8
13
|
* bypassing the agent data channel relay.
|
|
9
14
|
*
|
|
10
15
|
* Uses fetch() with streaming response body instead of EventSource because
|
|
11
16
|
* React Native does not have a reliable EventSource polyfill.
|
|
17
|
+
*
|
|
18
|
+
* Retries on transient failures (network blip, 503) with exponential backoff
|
|
19
|
+
* up to STREAM_RETRY_BUDGET_MS. Aborts cleanly when a new requestId arrives
|
|
20
|
+
* or the component unmounts.
|
|
12
21
|
*/
|
|
13
22
|
function useDirectVisemeStream(onVisemes) {
|
|
14
|
-
// Track the current abort controller keyed by requestId so we can detect
|
|
15
|
-
// when a new requestId arrives and tear down the previous stream.
|
|
16
23
|
const abortControllerRef = (0, react_1.useRef)(null);
|
|
17
|
-
const activeRequestIdRef = (0, react_1.useRef)(null);
|
|
18
24
|
const onVisemesRef = (0, react_1.useRef)(onVisemes);
|
|
19
25
|
// Keep callback ref up to date without requiring it in openStream's dep array
|
|
20
26
|
(0, react_1.useEffect)(() => {
|
|
21
27
|
onVisemesRef.current = onVisemes;
|
|
22
28
|
});
|
|
23
29
|
const openStream = (0, react_1.useCallback)(({ requestId, ttsBaseUrl }) => {
|
|
24
|
-
// Abort any existing stream
|
|
30
|
+
// Abort any existing stream for a previous request
|
|
25
31
|
if (abortControllerRef.current) {
|
|
26
32
|
abortControllerRef.current.abort();
|
|
27
33
|
abortControllerRef.current = null;
|
|
28
34
|
}
|
|
29
|
-
activeRequestIdRef.current = requestId;
|
|
30
35
|
const controller = new AbortController();
|
|
31
36
|
abortControllerRef.current = controller;
|
|
32
37
|
const { signal } = controller;
|
|
33
38
|
// Strip trailing /v1 if present so we don't double it
|
|
34
|
-
const base = ttsBaseUrl.replace(/\/v1\/?$/,
|
|
39
|
+
const base = ttsBaseUrl.replace(/\/v1\/?$/, "");
|
|
35
40
|
const url = `${base}/v1/audio/visemes/${encodeURIComponent(requestId)}/stream`;
|
|
36
41
|
(async () => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
const decoder = new TextDecoder();
|
|
52
|
-
let buffer = "";
|
|
53
|
-
let pendingEvent = null;
|
|
54
|
-
while (true) {
|
|
55
|
-
const { done, value } = await reader.read();
|
|
56
|
-
if (done)
|
|
57
|
-
break;
|
|
58
|
-
buffer += decoder.decode(value, { stream: true });
|
|
59
|
-
// Split on newlines, keeping the remainder (incomplete line) in buffer
|
|
60
|
-
const lines = buffer.split("\n");
|
|
61
|
-
buffer = lines.pop() ?? "";
|
|
62
|
-
for (const rawLine of lines) {
|
|
63
|
-
const line = rawLine.trimEnd();
|
|
64
|
-
if (line.startsWith("event:")) {
|
|
65
|
-
pendingEvent = line.slice("event:".length).trim();
|
|
66
|
-
}
|
|
67
|
-
else if (line.startsWith("data:")) {
|
|
68
|
-
if (pendingEvent === "visemes") {
|
|
69
|
-
const jsonText = line.slice("data:".length).trim();
|
|
70
|
-
try {
|
|
71
|
-
const payload = JSON.parse(jsonText);
|
|
72
|
-
console.log("[VisemeSSE] received", {
|
|
73
|
-
requestId,
|
|
74
|
-
cues: Array.isArray(payload.cues) ? payload.cues.length : 0,
|
|
75
|
-
durationMs: payload.durationMs ?? null,
|
|
76
|
-
});
|
|
77
|
-
onVisemesRef.current(payload);
|
|
78
|
-
}
|
|
79
|
-
catch (parseErr) {
|
|
80
|
-
console.warn("[VisemeSSE] JSON parse error", parseErr);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Reset pending event after consuming the data line
|
|
84
|
-
pendingEvent = null;
|
|
85
|
-
}
|
|
86
|
-
else if (line === "") {
|
|
87
|
-
// Empty line = end of SSE message block; reset pending event
|
|
88
|
-
pendingEvent = null;
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
let retryDelay = INITIAL_RETRY_DELAY_MS;
|
|
44
|
+
while (!signal.aborted) {
|
|
45
|
+
try {
|
|
46
|
+
const response = await (0, fetch_1.fetch)(url, {
|
|
47
|
+
headers: { Accept: "text/event-stream" },
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const retryable = response.status === 503 || response.status === 502 || response.status === 429;
|
|
52
|
+
if (!retryable || Date.now() - startedAt >= STREAM_RETRY_BUDGET_MS) {
|
|
53
|
+
console.warn("[VisemeSSE] Non-OK response, giving up", { requestId, status: response.status });
|
|
54
|
+
return;
|
|
89
55
|
}
|
|
56
|
+
console.warn("[VisemeSSE] Retryable error, backing off", { requestId, status: response.status, retryDelay });
|
|
57
|
+
await sleep(retryDelay, signal);
|
|
58
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
|
|
59
|
+
continue;
|
|
90
60
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
61
|
+
const reader = response.body?.getReader();
|
|
62
|
+
if (!reader) {
|
|
63
|
+
console.warn("[VisemeSSE] No response body reader", { requestId });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await readSseStream(reader, signal, (payload) => {
|
|
67
|
+
onVisemesRef.current(payload);
|
|
68
|
+
});
|
|
69
|
+
// Stream ended cleanly — done.
|
|
70
|
+
console.log("[VisemeSSE] stream ended", { requestId });
|
|
97
71
|
return;
|
|
98
72
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err?.name === "AbortError" || signal.aborted)
|
|
75
|
+
return;
|
|
76
|
+
const elapsed = Date.now() - startedAt;
|
|
77
|
+
if (elapsed >= STREAM_RETRY_BUDGET_MS) {
|
|
78
|
+
console.warn("[VisemeSSE] stream error, retry budget exhausted", { requestId, err });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.warn("[VisemeSSE] stream error, retrying", { requestId, retryDelay, err });
|
|
82
|
+
await sleep(retryDelay, signal);
|
|
83
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
|
|
105
84
|
}
|
|
106
85
|
}
|
|
107
|
-
})()
|
|
86
|
+
})().finally(() => {
|
|
87
|
+
if (abortControllerRef.current === controller) {
|
|
88
|
+
abortControllerRef.current = null;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
108
91
|
}, []);
|
|
109
92
|
// Clean up on unmount
|
|
110
93
|
(0, react_1.useEffect)(() => {
|
|
@@ -117,3 +100,86 @@ function useDirectVisemeStream(onVisemes) {
|
|
|
117
100
|
}, []);
|
|
118
101
|
return { openStream };
|
|
119
102
|
}
|
|
103
|
+
// ─── SSE parser ──────────────────────────────────────────────────────────────
|
|
104
|
+
/**
|
|
105
|
+
* Reads an SSE stream to completion, dispatching `event: visemes` messages.
|
|
106
|
+
*
|
|
107
|
+
* Follows the SSE spec: fields accumulate per-message block; an empty line
|
|
108
|
+
* dispatches the block. Handles streams that end without a trailing newline
|
|
109
|
+
* by flushing the remaining buffer on EOF.
|
|
110
|
+
*/
|
|
111
|
+
async function readSseStream(reader, signal, onVisemes) {
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
let buffer = "";
|
|
114
|
+
// Per-message accumulator (reset on empty-line dispatch)
|
|
115
|
+
let eventType = null;
|
|
116
|
+
let dataLines = [];
|
|
117
|
+
const dispatchBlock = () => {
|
|
118
|
+
if (eventType === "visemes" && dataLines.length > 0) {
|
|
119
|
+
const jsonText = dataLines.join("\n");
|
|
120
|
+
try {
|
|
121
|
+
const payload = JSON.parse(jsonText);
|
|
122
|
+
console.log("[VisemeSSE] received", {
|
|
123
|
+
requestId: payload.requestId,
|
|
124
|
+
cues: Array.isArray(payload.cues) ? payload.cues.length : 0,
|
|
125
|
+
durationMs: payload.durationMs ?? null,
|
|
126
|
+
});
|
|
127
|
+
onVisemes(payload);
|
|
128
|
+
}
|
|
129
|
+
catch (parseErr) {
|
|
130
|
+
console.warn("[VisemeSSE] JSON parse error", parseErr);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
eventType = null;
|
|
134
|
+
dataLines = [];
|
|
135
|
+
};
|
|
136
|
+
const processLines = (chunk) => {
|
|
137
|
+
const lines = chunk.split("\n");
|
|
138
|
+
for (const rawLine of lines) {
|
|
139
|
+
const line = rawLine.trimEnd();
|
|
140
|
+
if (line === "") {
|
|
141
|
+
// Empty line = end of SSE message block → dispatch
|
|
142
|
+
dispatchBlock();
|
|
143
|
+
}
|
|
144
|
+
else if (line.startsWith("event:")) {
|
|
145
|
+
eventType = line.slice("event:".length).trim();
|
|
146
|
+
}
|
|
147
|
+
else if (line.startsWith("data:")) {
|
|
148
|
+
dataLines.push(line.slice("data:".length).trimStart());
|
|
149
|
+
}
|
|
150
|
+
// Ignore comment lines (":"), id:, retry: fields
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
while (!signal.aborted) {
|
|
154
|
+
const { done, value } = await reader.read();
|
|
155
|
+
if (done) {
|
|
156
|
+
// Flush any remaining buffered content without a trailing newline
|
|
157
|
+
if (buffer) {
|
|
158
|
+
processLines(buffer + "\n");
|
|
159
|
+
buffer = "";
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
164
|
+
// Combine with any previous incomplete line, then split on newlines.
|
|
165
|
+
// Keep the last (potentially incomplete) segment in the buffer.
|
|
166
|
+
const combined = buffer + chunk;
|
|
167
|
+
const lastNewline = combined.lastIndexOf("\n");
|
|
168
|
+
if (lastNewline === -1) {
|
|
169
|
+
buffer = combined;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
buffer = combined.slice(lastNewline + 1);
|
|
173
|
+
processLines(combined.slice(0, lastNewline + 1));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
177
|
+
function sleep(ms, signal) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const id = setTimeout(resolve, ms);
|
|
180
|
+
signal.addEventListener("abort", () => {
|
|
181
|
+
clearTimeout(id);
|
|
182
|
+
reject(Object.assign(new Error("AbortError"), { name: "AbortError" }));
|
|
183
|
+
}, { once: true });
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useMotionMarkers = useMotionMarkers;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
/**
|
|
6
|
+
* Parses ::marker_name:: tokens out of LLM transcript text, fires the
|
|
7
|
+
* corresponding motion on the avatar ref, and returns the cleaned text
|
|
8
|
+
* (markers stripped) ready to pass to TTS.
|
|
9
|
+
*
|
|
10
|
+
* Supports optional arguments: ::wave_right(slow):: — the arg string is
|
|
11
|
+
* forwarded as the second parameter of dispatchMotion for future use.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const parseMarkers = useMotionMarkers(avatarRef);
|
|
15
|
+
* const cleanText = parseMarkers(rawLlmText); // fires motions, strips markers
|
|
16
|
+
* tts.speak(cleanText);
|
|
17
|
+
*/
|
|
18
|
+
const MARKER_RE = /::([a-z][a-z0-9_]*)(?:\(([^)]*)\))?::/g;
|
|
19
|
+
function useMotionMarkers(ref) {
|
|
20
|
+
return (0, react_1.useCallback)((text) => text.replace(MARKER_RE, (_, name) => {
|
|
21
|
+
ref.current?.dispatchMotion(name);
|
|
22
|
+
return '';
|
|
23
|
+
}), [ref]);
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talking-head-studio",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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",
|