talking-head-studio 0.3.4 → 0.3.6
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/filament/editor/FilamentEditor.d.ts +16 -0
- package/dist/filament/editor/FilamentEditor.js +889 -0
- package/dist/filament/editor/FilamentEditor.web.d.ts +19 -0
- package/dist/filament/editor/FilamentEditor.web.js +57 -0
- package/dist/filament/editor/PrecisionPanel.d.ts +1 -0
- package/dist/filament/editor/PrecisionPanel.js +262 -0
- package/dist/filament/editor/boneSnap.d.ts +10 -0
- package/dist/filament/editor/boneSnap.js +107 -0
- package/dist/filament/editor/index.d.ts +5 -0
- package/dist/filament/editor/index.js +19 -0
- package/dist/filament/editor/studioTheme.d.ts +86 -0
- package/dist/filament/editor/studioTheme.js +89 -0
- package/dist/html.js +265 -9
- package/package.json +11 -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 (
|
|
865
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.6",
|
|
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",
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
"react-native": "./dist/filament/index.js",
|
|
42
42
|
"types": "./dist/filament/index.d.ts",
|
|
43
43
|
"default": "./dist/filament/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./filament/editor": {
|
|
46
|
+
"react-native": "./dist/filament/editor/index.js",
|
|
47
|
+
"types": "./dist/filament/editor/index.d.ts",
|
|
48
|
+
"default": "./dist/filament/editor/index.js"
|
|
44
49
|
}
|
|
45
50
|
},
|
|
46
51
|
"files": [
|
|
@@ -139,6 +144,7 @@
|
|
|
139
144
|
"@babel/core": "^7.29.0",
|
|
140
145
|
"@babel/preset-env": "^7.29.0",
|
|
141
146
|
"@babel/preset-typescript": "^7.28.5",
|
|
147
|
+
"@expo/vector-icons": "^15.1.1",
|
|
142
148
|
"@react-native/babel-preset": "^0.84.1",
|
|
143
149
|
"@testing-library/react-native": "^13.3.3",
|
|
144
150
|
"@types/jest": "^30.0.0",
|
|
@@ -152,12 +158,16 @@
|
|
|
152
158
|
"eslint-plugin-react": "^7.37.5",
|
|
153
159
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
154
160
|
"eslint-plugin-react-native": "^5.0.0",
|
|
161
|
+
"expo-haptics": "^55.0.9",
|
|
155
162
|
"express": "^5.2.1",
|
|
156
163
|
"jest": "^30.2.0",
|
|
157
164
|
"jest-environment-jsdom": "^30.2.0",
|
|
158
165
|
"metro-react-native-babel-preset": "^0.77.0",
|
|
166
|
+
"moti": "^0.30.0",
|
|
159
167
|
"multer": "^2.1.0",
|
|
160
168
|
"prettier": "^3.8.1",
|
|
169
|
+
"react-native-gesture-handler": "^2.30.0",
|
|
170
|
+
"react-native-reanimated": "^4.2.3",
|
|
161
171
|
"react-native-webview": "^13.16.0",
|
|
162
172
|
"react-test-renderer": "^19.2.4",
|
|
163
173
|
"ts-jest": "^29.4.6"
|