talking-head-studio 0.3.3 → 0.3.5
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/html.js +265 -5
- package/package.json +1 -1
package/dist/html.js
CHANGED
|
@@ -202,6 +202,7 @@ async function loadStaticFallback(loadedAvatarUrl) {
|
|
|
202
202
|
staticModel.position.sub(scaledCenter);
|
|
203
203
|
|
|
204
204
|
applyAccessories(pendingAccessoriesList);
|
|
205
|
+
motionInitFromRoot(staticModel);
|
|
205
206
|
emitLoading('ready', 100);
|
|
206
207
|
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
|
|
207
208
|
|
|
@@ -215,6 +216,7 @@ async function loadStaticFallback(loadedAvatarUrl) {
|
|
|
215
216
|
const delta = clock.getDelta();
|
|
216
217
|
if (staticMixer) staticMixer.update(delta);
|
|
217
218
|
tickVisemeDecay();
|
|
219
|
+
applyMotionBones();
|
|
218
220
|
controls.update();
|
|
219
221
|
renderer.render(scene, camera);
|
|
220
222
|
});
|
|
@@ -320,20 +322,21 @@ async function init() {
|
|
|
320
322
|
}
|
|
321
323
|
};
|
|
322
324
|
const headaudioUpdate = headaudio.update.bind(headaudio);
|
|
323
|
-
head.opt.update = (dt) => { headaudioUpdate(dt); tickVisemeDecay(); };
|
|
325
|
+
head.opt.update = (dt) => { headaudioUpdate(dt); tickVisemeDecay(); applyMotionBones(); };
|
|
324
326
|
log('HeadAudio ready (phoneme lip sync)');
|
|
325
327
|
} else {
|
|
326
328
|
log('HeadAudio skipped: AudioWorklet not supported in this WebView. Use sendViseme() from native TTS callbacks.');
|
|
327
|
-
head.opt.update = () => tickVisemeDecay();
|
|
329
|
+
head.opt.update = () => { tickVisemeDecay(); applyMotionBones(); };
|
|
328
330
|
}
|
|
329
331
|
} catch (err) {
|
|
330
332
|
log('HeadAudio unavailable, viseme/amplitude fallback active: ' + err.message);
|
|
331
|
-
head.opt.update = () => tickVisemeDecay();
|
|
333
|
+
head.opt.update = () => { tickVisemeDecay(); applyMotionBones(); };
|
|
332
334
|
}
|
|
333
335
|
|
|
334
336
|
startAudioInterception();
|
|
335
337
|
log('[ACC] init() complete, calling applyAccessories with ' + pendingAccessoriesList.length + ' pending items');
|
|
336
338
|
applyAccessories(pendingAccessoriesList);
|
|
339
|
+
if (head && head.armature) motionInitFromRoot(head.armature);
|
|
337
340
|
emitLoading('ready', 100);
|
|
338
341
|
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
|
|
339
342
|
}
|
|
@@ -814,6 +817,263 @@ async function applyAccessories(accessoriesList) {
|
|
|
814
817
|
}
|
|
815
818
|
}
|
|
816
819
|
|
|
820
|
+
/* ── MotionEngine ────────────────────────────────────────────────────────── */
|
|
821
|
+
/* Quaternion math helpers (no Three.js dep required) */
|
|
822
|
+
const QM = {
|
|
823
|
+
fromAxisAngle(ax, ay, az, angle) {
|
|
824
|
+
const s = Math.sin(angle / 2);
|
|
825
|
+
return { x: ax*s, y: ay*s, z: az*s, w: Math.cos(angle/2) };
|
|
826
|
+
},
|
|
827
|
+
multiply(a, b) {
|
|
828
|
+
return {
|
|
829
|
+
x: a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y,
|
|
830
|
+
y: a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x,
|
|
831
|
+
z: a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w,
|
|
832
|
+
w: a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z,
|
|
833
|
+
};
|
|
834
|
+
},
|
|
835
|
+
applyTo(bone, q) {
|
|
836
|
+
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
837
|
+
},
|
|
838
|
+
copyFrom(bone) {
|
|
839
|
+
const q = bone.quaternion;
|
|
840
|
+
return { x: q.x, y: q.y, z: q.z, w: q.w };
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
const MOTION_DEFS = {
|
|
845
|
+
groove: {
|
|
846
|
+
bpm: 120,
|
|
847
|
+
smile: 0.55,
|
|
848
|
+
label: 'Groove',
|
|
849
|
+
bones: {
|
|
850
|
+
hips: [{ ax:0, ay:0, az:1, amp:0.10, freq:1.0, phase:0 },
|
|
851
|
+
{ ax:0, ay:1, az:0, amp:0.05, freq:0.5, phase:0 }],
|
|
852
|
+
spine: [{ ax:0, ay:0, az:1, amp:0.08, freq:1.0, phase:0.3 },
|
|
853
|
+
{ ax:1, ay:0, az:0, amp:0.03, freq:2.0, phase:0 }],
|
|
854
|
+
spine2: [{ ax:0, ay:0, az:1, amp:0.06, freq:1.0, phase:0.6 }],
|
|
855
|
+
neck: [{ ax:0, ay:0, az:1, amp:0.05, freq:1.0, phase:Math.PI }],
|
|
856
|
+
head: [{ ax:1, ay:0, az:0, amp:0.06, freq:2.0, phase:0 },
|
|
857
|
+
{ ax:0, ay:0, az:1, amp:0.04, freq:1.0, phase:Math.PI }],
|
|
858
|
+
leftArm: [{ ax:0, ay:0, az:1, amp:0.22, freq:1.0, phase:Math.PI/2 }],
|
|
859
|
+
rightArm: [{ ax:0, ay:0, az:1, amp:0.22, freq:1.0, phase:-Math.PI/2 }],
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
wave: {
|
|
863
|
+
bpm: 90,
|
|
864
|
+
smile: 0.75,
|
|
865
|
+
label: 'Wave',
|
|
866
|
+
bones: {
|
|
867
|
+
hips: [{ ax:0, ay:0, az:1, amp:0.04, freq:0.5, phase:0 }],
|
|
868
|
+
spine: [{ ax:0, ay:0, az:1, amp:0.04, freq:0.5, phase:0.2 }],
|
|
869
|
+
head: [{ ax:0, ay:1, az:0, amp:0.12, freq:0.5, phase:0 },
|
|
870
|
+
{ ax:0, ay:0, az:1, amp:0.04, freq:0.5, phase:0.5 }],
|
|
871
|
+
rightShoulder:[{ ax:0, ay:0, az:1, amp:-0.6, freq:0, phase:0 }],
|
|
872
|
+
rightArm: [{ ax:0, ay:0, az:1, amp:-0.55, freq:0, phase:0 },
|
|
873
|
+
{ ax:1, ay:0, az:0, amp:0.35, freq:1.5, phase:0 }],
|
|
874
|
+
rightForeArm:[{ ax:1, ay:0, az:0, amp:0.4, freq:1.5, phase:Math.PI/3 }],
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
nod: {
|
|
878
|
+
bpm: 100,
|
|
879
|
+
smile: 0.4,
|
|
880
|
+
label: 'Head Bop',
|
|
881
|
+
bones: {
|
|
882
|
+
hips: [{ ax:0, ay:0, az:1, amp:0.04, freq:0.83, phase:0 }],
|
|
883
|
+
spine: [{ ax:1, ay:0, az:0, amp:0.04, freq:0.83, phase:0.2 },
|
|
884
|
+
{ ax:0, ay:0, az:1, amp:0.03, freq:0.83, phase:0.4 }],
|
|
885
|
+
neck: [{ ax:1, ay:0, az:0, amp:0.12, freq:1.67, phase:0 }],
|
|
886
|
+
head: [{ ax:1, ay:0, az:0, amp:0.16, freq:1.67, phase:Math.PI/6 },
|
|
887
|
+
{ ax:0, ay:1, az:0, amp:0.05, freq:0.83, phase:0 }],
|
|
888
|
+
leftArm:[{ ax:0, ay:0, az:1, amp:0.12, freq:0.83, phase:0 }],
|
|
889
|
+
rightArm:[{ ax:0, ay:0, az:1, amp:0.12, freq:0.83, phase:Math.PI }],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
idle: {
|
|
893
|
+
bpm: 60,
|
|
894
|
+
smile: 0.15,
|
|
895
|
+
label: 'Idle Sway',
|
|
896
|
+
bones: {
|
|
897
|
+
hips: [{ ax:0, ay:0, az:1, amp:0.035, freq:0.5, phase:0 }],
|
|
898
|
+
spine: [{ ax:0, ay:0, az:1, amp:0.028, freq:0.5, phase:0.3 },
|
|
899
|
+
{ ax:1, ay:0, az:0, amp:0.012, freq:0.25, phase:0 }],
|
|
900
|
+
spine2: [{ ax:0, ay:0, az:1, amp:0.018, freq:0.5, phase:0.5 }],
|
|
901
|
+
head: [{ ax:1, ay:0, az:0, amp:0.025, freq:0.25, phase:0 },
|
|
902
|
+
{ ax:0, ay:1, az:0, amp:0.015, freq:0.5, phase:0.8}],
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
celebrate: {
|
|
906
|
+
bpm: 140,
|
|
907
|
+
smile: 0.95,
|
|
908
|
+
label: 'Celebrate',
|
|
909
|
+
bones: {
|
|
910
|
+
hips: [{ ax:0, ay:0, az:1, amp:0.14, freq:1.17, phase:0 },
|
|
911
|
+
{ ax:1, ay:0, az:0, amp:0.06, freq:2.33, phase:0 }],
|
|
912
|
+
spine: [{ ax:0, ay:0, az:1, amp:0.10, freq:1.17, phase:0.4 },
|
|
913
|
+
{ ax:1, ay:0, az:0, amp:0.05, freq:2.33, phase:0.2 }],
|
|
914
|
+
spine2: [{ ax:0, ay:0, az:1, amp:0.07, freq:1.17, phase:0.7 }],
|
|
915
|
+
neck: [{ ax:1, ay:0, az:0, amp:0.06, freq:2.33, phase:0 }],
|
|
916
|
+
head: [{ ax:1, ay:0, az:0, amp:0.10, freq:2.33, phase:0.15 },
|
|
917
|
+
{ ax:0, ay:0, az:1, amp:0.06, freq:1.17, phase:Math.PI }],
|
|
918
|
+
leftShoulder: [{ ax:0, ay:0, az:1, amp: 0.5, freq:0, phase:0 }],
|
|
919
|
+
rightShoulder:[{ ax:0, ay:0, az:1, amp:-0.5, freq:0, phase:0 }],
|
|
920
|
+
leftArm: [{ ax:0, ay:0, az:1, amp: 0.55, freq:0, phase:0 },
|
|
921
|
+
{ ax:1, ay:0, az:0, amp:0.30, freq:2.33, phase:0 }],
|
|
922
|
+
rightArm:[{ ax:0, ay:0, az:1, amp:-0.55, freq:0, phase:0 },
|
|
923
|
+
{ ax:1, ay:0, az:0, amp:0.30, freq:2.33, phase:Math.PI }],
|
|
924
|
+
leftForeArm: [{ ax:1, ay:0, az:0, amp:0.25, freq:2.33, phase:0.4 }],
|
|
925
|
+
rightForeArm:[{ ax:1, ay:0, az:0, amp:0.25, freq:2.33, phase:0.4 }],
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
/* Runtime state */
|
|
931
|
+
let motionActive = false;
|
|
932
|
+
let motionKey = null;
|
|
933
|
+
let motionStartTime = null;
|
|
934
|
+
let motionBones = {};
|
|
935
|
+
let motionRestQuats = {};
|
|
936
|
+
let motionSmileTargets = [];
|
|
937
|
+
let motionBeatTimer = null;
|
|
938
|
+
let motionBeatIndex = 0;
|
|
939
|
+
|
|
940
|
+
/* Bone name map: logical key -> keywords to match in armature.
|
|
941
|
+
norm() strips punctuation and lowercases, so RPM names like
|
|
942
|
+
LeftShoulder, RightArm, LeftForeArm etc. all match correctly. */
|
|
943
|
+
const BONE_SEARCH = {
|
|
944
|
+
hips: ['hips','pelvis','hip','root'],
|
|
945
|
+
spine: ['spine','spine0','spine_0','spine_01','spine1_'],
|
|
946
|
+
spine2: ['spine2','spine_02','upperchest','chest','spine1'],
|
|
947
|
+
neck: ['neck'],
|
|
948
|
+
head: ['head'],
|
|
949
|
+
leftShoulder: ['leftshoulder','l_shoulder','shoulderleft','leftclavicle','l_clavicle'],
|
|
950
|
+
rightShoulder: ['rightshoulder','r_shoulder','shoulderright','rightclavicle','r_clavicle'],
|
|
951
|
+
leftArm: ['leftarm','l_arm','armleft','leftupperarm','l_upperarm'],
|
|
952
|
+
rightArm: ['rightarm','r_arm','armright','rightupperarm','r_upperarm'],
|
|
953
|
+
leftForeArm: ['leftforearm','l_forearm','forearmleft','leftlowerarm'],
|
|
954
|
+
rightForeArm: ['rightforearm','r_forearm','forearmright','rightlowerarm'],
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
function motionScanBones(root) {
|
|
958
|
+
motionBones = {}; motionRestQuats = {};
|
|
959
|
+
if (!root) return;
|
|
960
|
+
const norm = s => s.toLowerCase().replace(/[_\\s\\-\\.]/g, '');
|
|
961
|
+
root.traverse(child => {
|
|
962
|
+
if (!child.isBone) return;
|
|
963
|
+
const lk = norm(child.name);
|
|
964
|
+
for (const [key, kws] of Object.entries(BONE_SEARCH)) {
|
|
965
|
+
if (motionBones[key]) continue;
|
|
966
|
+
if (kws.some(kw => lk.includes(norm(kw)))) {
|
|
967
|
+
motionBones[key] = child;
|
|
968
|
+
motionRestQuats[key] = QM.copyFrom(child);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
const found = Object.keys(motionBones);
|
|
973
|
+
log('MotionEngine: ' + (found.length === 0
|
|
974
|
+
? 'No rigged bones detected — motion engine needs a humanoid skeleton.'
|
|
975
|
+
: found.length + ' bones mapped: ' + found.join(', ') + '.'));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function motionScanSmile(root) {
|
|
979
|
+
motionSmileTargets = [];
|
|
980
|
+
if (!root) return;
|
|
981
|
+
root.traverse(child => {
|
|
982
|
+
if (!child.isMesh || !child.morphTargetDictionary) return;
|
|
983
|
+
Object.keys(child.morphTargetDictionary).forEach(name => {
|
|
984
|
+
const lk = name.toLowerCase();
|
|
985
|
+
if (lk.includes('smile') || lk.includes('happy') || lk.includes('joy') || lk.includes('mouthsmile')) {
|
|
986
|
+
motionSmileTargets.push({ mesh: child, index: child.morphTargetDictionary[name] });
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function motionInitFromRoot(root) {
|
|
993
|
+
motionScanBones(root);
|
|
994
|
+
motionScanSmile(root);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function motionSetSmile(v) {
|
|
998
|
+
motionSmileTargets.forEach(t => { t.mesh.morphTargetInfluences[t.index] = v; });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/* BPM beat indicator — no-ops in WebView (no beat UI) */
|
|
1002
|
+
function motionStartBeat(bpm) {
|
|
1003
|
+
clearInterval(motionBeatTimer);
|
|
1004
|
+
motionBeatIndex = 0;
|
|
1005
|
+
/* beat UI elements not present in WebView — intentional no-op */
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function motionStopBeat() {
|
|
1009
|
+
clearInterval(motionBeatTimer);
|
|
1010
|
+
motionBeatTimer = null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/* ── applyMotionBones ────────────────────────────────────────
|
|
1014
|
+
Called every frame from inside the render loop
|
|
1015
|
+
(fallback: before renderer.render; TH: inside head.opt.update).
|
|
1016
|
+
Runs AFTER TH has written its own bones so our values are
|
|
1017
|
+
the last thing written before the draw call.
|
|
1018
|
+
─────────────────────────────────────────────────────────── */
|
|
1019
|
+
function applyMotionBones() {
|
|
1020
|
+
if (!motionActive) return;
|
|
1021
|
+
const def = MOTION_DEFS[motionKey];
|
|
1022
|
+
if (!def) return;
|
|
1023
|
+
const tSec = (performance.now() - motionStartTime) / 1000;
|
|
1024
|
+
|
|
1025
|
+
for (const [boneName, oscillators] of Object.entries(def.bones)) {
|
|
1026
|
+
const bone = motionBones[boneName];
|
|
1027
|
+
if (!bone) continue;
|
|
1028
|
+
const rest = motionRestQuats[boneName];
|
|
1029
|
+
let q = { ...rest };
|
|
1030
|
+
|
|
1031
|
+
for (const osc of oscillators) {
|
|
1032
|
+
const angle = osc.freq === 0
|
|
1033
|
+
? osc.amp
|
|
1034
|
+
: osc.amp * Math.sin(2 * Math.PI * osc.freq * tSec + osc.phase);
|
|
1035
|
+
q = QM.multiply(q, QM.fromAxisAngle(osc.ax, osc.ay, osc.az, angle));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
bone.quaternion.set(q.x, q.y, q.z, q.w);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
window.playMotion = function(key) {
|
|
1043
|
+
const def = MOTION_DEFS[key];
|
|
1044
|
+
if (!def) { log('MotionEngine: unknown motion key: ' + key); return; }
|
|
1045
|
+
const root = (head && head.armature) ? head.armature : staticModel;
|
|
1046
|
+
if (!root) { log('MotionEngine: no model loaded'); return; }
|
|
1047
|
+
|
|
1048
|
+
if (Object.keys(motionBones).length === 0) motionInitFromRoot(root);
|
|
1049
|
+
|
|
1050
|
+
window.stopMotion(false);
|
|
1051
|
+
|
|
1052
|
+
motionKey = key;
|
|
1053
|
+
motionActive = true;
|
|
1054
|
+
motionStartTime = performance.now();
|
|
1055
|
+
|
|
1056
|
+
motionStartBeat(def.bpm);
|
|
1057
|
+
motionSetSmile(def.smile);
|
|
1058
|
+
log('MotionEngine: playing ' + def.label + ' @ ' + def.bpm + ' BPM');
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
window.stopMotion = function(restore) {
|
|
1062
|
+
if (restore === undefined) restore = true;
|
|
1063
|
+
motionActive = false;
|
|
1064
|
+
motionStopBeat();
|
|
1065
|
+
|
|
1066
|
+
if (restore) {
|
|
1067
|
+
for (const [key, bone] of Object.entries(motionBones)) {
|
|
1068
|
+
const rest = motionRestQuats[key];
|
|
1069
|
+
if (rest) bone.quaternion.set(rest.x, rest.y, rest.z, rest.w);
|
|
1070
|
+
}
|
|
1071
|
+
motionSetSmile(0);
|
|
1072
|
+
log('MotionEngine: stopped');
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
/* ── End MotionEngine ─────────────────────────────────────── */
|
|
1076
|
+
|
|
817
1077
|
function onIncomingMessage(event) {
|
|
818
1078
|
try {
|
|
819
1079
|
const msg = JSON.parse(event.data);
|
|
@@ -857,8 +1117,8 @@ function onIncomingMessage(event) {
|
|
|
857
1117
|
} else if (msg.type === 'set_accessories') {
|
|
858
1118
|
applyAccessories(msg.accessories || []);
|
|
859
1119
|
} else if (msg.type === 'motion' && typeof msg.name === 'string') {
|
|
860
|
-
if (
|
|
861
|
-
|
|
1120
|
+
if (typeof window.playMotion === 'function') {
|
|
1121
|
+
window.playMotion(msg.name);
|
|
862
1122
|
}
|
|
863
1123
|
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
|
|
864
1124
|
log('motion dispatched: ' + msg.name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talking-head-studio",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|