talking-head-studio 0.3.4 → 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.
Files changed (2) hide show
  1. package/dist/html.js +265 -9
  2. 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,25 +322,22 @@ 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
- // Register motionEngine so dispatchMotion('wave_right') calls head.playGesture
339
- (window as any).motionEngine = {
340
- play: async (name: string) => { if (head) await (head as any).playGesture(name); },
341
- };
342
341
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'ready' }));
343
342
  }
344
343
  } catch (err) {
@@ -818,6 +817,263 @@ async function applyAccessories(accessoriesList) {
818
817
  }
819
818
  }
820
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
+
821
1077
  function onIncomingMessage(event) {
822
1078
  try {
823
1079
  const msg = JSON.parse(event.data);
@@ -861,8 +1117,8 @@ function onIncomingMessage(event) {
861
1117
  } else if (msg.type === 'set_accessories') {
862
1118
  applyAccessories(msg.accessories || []);
863
1119
  } else if (msg.type === 'motion' && typeof msg.name === 'string') {
864
- if ((window as any).motionEngine) {
865
- (window as any).motionEngine.play(msg.name).catch(() => {});
1120
+ if (typeof window.playMotion === 'function') {
1121
+ window.playMotion(msg.name);
866
1122
  }
867
1123
  window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'avatarState', state: 'motion:' + msg.name }));
868
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.4",
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",