dsh-pet-live2d-viola 1.0.0
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/LICENSE +44 -0
- package/README.md +98 -0
- package/assets/characters/miyako/Expressions/exp_amazed01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_angry01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_angry02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_angry03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_angry04.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_angry05.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_anxiety01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_bsmile01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_bsmile02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_cry01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_cry02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_cry03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_cry04.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_heart01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_idle01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_idle02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_idle03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_jitome01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_kime01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_kirakira01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_angry01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_angry02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_idle01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_idle02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_idle03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_kime01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_kime02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_pale01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_pale02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_sad01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_shy01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_mal_smile01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_pair_angry1_maru_angry1.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_angry2_maru_angry2.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_bsmile1_maru_smile.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_idle_maru_idle.exp3.json +602 -0
- package/assets/characters/miyako/Expressions/exp_pair_jitome_maru_kime1.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_kime_maru_kime.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_serious1_maru_idle3.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_serious_maru_idle.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_serious_maru_sad.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_smile2_maru_kime2.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_smile3_maru_kime1.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_smile_maru_smile.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pair_surprised2_maru_shy.exp3.json +637 -0
- package/assets/characters/miyako/Expressions/exp_pale01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_pale02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_panic01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_puzzled01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_sad01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_sad02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_sad03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_salk01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_serious01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_serious02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_shadow01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_shock01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_shy01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_shy02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_smile01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_smile02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_smile03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_smile04.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_spin01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_surprised01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_surprised02.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_surprised03.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_thinking01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_tired01.exp3.json +1 -0
- package/assets/characters/miyako/Expressions/exp_tired02.exp3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_angry01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_angry01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_angry01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_angry02_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_angry02_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_angry02_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_bye01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_bye01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_bye01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_check01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_check01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_check01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_cry01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_cry01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_cry01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_denial01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_denial01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_denial01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_idle01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_idle01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_idle01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_idle02_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_idle02_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_idle02_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_kime01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_kime01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_kime01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_look01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_look01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_look01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_look02_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_look02_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_look02_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_angry01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_angry01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_angry01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_bye01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_bye01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_bye01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_cry01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_cry01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_cry01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_happy01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_happy01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_happy01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_rampage01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_rampage01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_rampage01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_smile01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_smile01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_mal_smile01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod02_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod02_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod02_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod03_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod03_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_nod03_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_question01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_question01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_question01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_sad01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_sad01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_sad01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_serious01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_serious01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_serious01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_smile01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_smile01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_smile01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_smile02_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_smile02_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_smile02_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_surprised01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_surprised01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_surprised01_R.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_thinking01_C.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_thinking01_L.motion3.json +1 -0
- package/assets/characters/miyako/Motions/mtn_thinking01_R.motion3.json +1 -0
- package/assets/characters/miyako/Textures/texture_00.webp +0 -0
- package/assets/characters/miyako/Textures/texture_01.webp +0 -0
- package/assets/characters/miyako/Textures/texture_02.webp +0 -0
- package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.moc3 +0 -0
- package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.model3.json +1 -0
- package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.model3.json.bak +1 -0
- package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.physics3.json +1 -0
- package/assets/characters/miyako/model.json +23 -0
- package/assets/characters/viola/Expressions/exp_angry01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_bsmile01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_bsmile02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_cry01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_cry02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_idle01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_idle02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_idle03.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_idle04.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_kime01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_pale01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_pale02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_sad01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_sad02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_serious01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_serious02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_shadow01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_smile01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_smile02.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_smile02_nomouth.exp3.json +330 -0
- package/assets/characters/viola/Expressions/exp_smile03.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_surprised01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_surprised01_nomouth.exp3.json +330 -0
- package/assets/characters/viola/Expressions/exp_thinking01.exp3.json +1 -0
- package/assets/characters/viola/Expressions/exp_thinking01_nomouth.exp3.json +330 -0
- package/assets/characters/viola/Motions/mtn_angry01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_angry01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_angry01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_bye01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_bye01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_bye01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_check01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_check01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_check01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_cry01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_cry01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_cry01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_denial01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_denial01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_denial01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_idle01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_idle01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_idle01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_kime01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_kime01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_kime01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_look01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_look01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_look01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_nod01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_nod01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_nod01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_nod02_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_nod02_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_nod02_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_question01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_question01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_question01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_sad01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_sad01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_sad01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_serious01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_serious01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_serious01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_smile01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_smile01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_smile01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_smile02_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_smile02_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_smile02_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_surprised01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_surprised01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_surprised01_R.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_thinking01_C.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_thinking01_L.motion3.json +1 -0
- package/assets/characters/viola/Motions/mtn_thinking01_R.motion3.json +1 -0
- package/assets/characters/viola/Textures/texture_00.webp +0 -0
- package/assets/characters/viola/Textures/texture_01.webp +0 -0
- package/assets/characters/viola/adv_live2d_sub_viola_casual_spring_01.moc3 +0 -0
- package/assets/characters/viola/adv_live2d_sub_viola_casual_spring_01.model3.json +388 -0
- package/assets/characters/viola/adv_live2d_sub_viola_casual_spring_01.physics3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_angry01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_bsmile01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_bsmile02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_cry01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_cry02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_idle01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_idle02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_idle03.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_idle04.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_kime01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_pale01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_pale02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_sad01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_sad02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_serious01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_serious02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_shadow01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_smile01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_smile02.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_smile02_nomouth.exp3.json +330 -0
- package/assets/characters/viola_school/Expressions/exp_smile03.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_surprised01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_surprised01_nomouth.exp3.json +330 -0
- package/assets/characters/viola_school/Expressions/exp_thinking01.exp3.json +1 -0
- package/assets/characters/viola_school/Expressions/exp_thinking01_nomouth.exp3.json +330 -0
- package/assets/characters/viola_school/Motions/mtn_angry01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_angry01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_angry01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_bye01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_bye01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_bye01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_check01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_check01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_check01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_cry01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_cry01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_cry01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_denial01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_denial01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_denial01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_idle01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_idle01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_idle01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_kime01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_kime01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_kime01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_look01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_look01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_look01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_nod01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_nod01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_nod01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_nod02_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_nod02_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_nod02_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_question01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_question01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_question01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_sad01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_sad01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_sad01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_serious01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_serious01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_serious01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_smile01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_smile01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_smile01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_smile02_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_smile02_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_smile02_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_surprised01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_surprised01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_surprised01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_thinking01_C.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_thinking01_L.motion3.json +1 -0
- package/assets/characters/viola_school/Motions/mtn_thinking01_R.motion3.json +1 -0
- package/assets/characters/viola_school/Textures/texture_00.webp +0 -0
- package/assets/characters/viola_school/Textures/texture_01.webp +0 -0
- package/assets/characters/viola_school/adv_live2d_sub_viola_school_winter_hs.moc3 +0 -0
- package/assets/characters/viola_school/adv_live2d_sub_viola_school_winter_hs.model3.json +388 -0
- package/assets/characters/viola_school/adv_live2d_sub_viola_school_winter_hs.physics3.json +1 -0
- package/assets/click/click.mp3 +0 -0
- package/assets/voice/README.txt +139 -0
- package/assets/voice/done-fc.opus +0 -0
- package/assets/voice/done-score.opus +0 -0
- package/assets/voice/idle-common02.opus +0 -0
- package/assets/voice/idle-levelup.opus +0 -0
- package/assets/voice/idle-marukun.opus +0 -0
- package/assets/voice/listening.opus +0 -0
- package/assets/voice/miyako-farewell.opus +0 -0
- package/assets/voice/thinking-comon.opus +0 -0
- package/assets/voice/thinking-gekisou.opus +0 -0
- package/assets/voice/viola-click1.opus +0 -0
- package/assets/voice/viola-click2.opus +0 -0
- package/assets/voice/viola-done1.opus +0 -0
- package/assets/voice/viola-done2.opus +0 -0
- package/assets/voice/viola-done3.opus +0 -0
- package/assets/voice/viola-farewell.opus +0 -0
- package/assets/voice/viola-idle1.opus +0 -0
- package/assets/voice/viola-idle2.opus +0 -0
- package/assets/voice/viola-idle3.opus +0 -0
- package/assets/voice/viola-idle4.opus +0 -0
- package/assets/voice/viola-listening.opus +0 -0
- package/assets/voice/viola-thinking1.opus +0 -0
- package/assets/voice/viola-thinking2.opus +0 -0
- package/assets/voice/viola-thinking3.opus +0 -0
- package/assets/voice/viola-working1.opus +0 -0
- package/assets/voice/viola-working2.opus +0 -0
- package/assets/voice/viola-working3.opus +0 -0
- package/assets/voice/working-combo.opus +0 -0
- package/assets/voice/working-skill.opus +0 -0
- package/assets/voice/working-training.opus +0 -0
- package/cordis.patch.yml +18 -0
- package/lib/client.js +5514 -0
- package/lib/client.live2d.js +67 -0
- package/lib/framing.js +284 -0
- package/lib/index.js +591 -0
- package/lib/machine.js +279 -0
- package/lib/persist.js +183 -0
- package/lib/routes.js +206 -0
- package/package.json +57 -0
package/lib/machine.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pet's session-driven state machine.
|
|
3
|
+
*
|
|
4
|
+
* It consumes host session events and reduces them to one coarse phase plus a
|
|
5
|
+
* monotonically increasing `revision`. The browser polls the phase and fires a
|
|
6
|
+
* motion/expression whenever `revision` advances; keeping the mapping in one
|
|
7
|
+
* place means the client never guesses intent from a bare phase name.
|
|
8
|
+
* @module dsh-pet-live2d-viola/machine
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Phase definitions: the motion the pet plays on entering each phase, what it
|
|
13
|
+
* says, and which motions it drifts through while the phase lasts. Motion names
|
|
14
|
+
* are the model's own motion groups (`<group>_C` is the centre/body part, `_L`/`_R`
|
|
15
|
+
* the additive layers).
|
|
16
|
+
*
|
|
17
|
+
* There is deliberately NO expression here. Her face changes only when one is
|
|
18
|
+
* picked from the menu, so a phase change moves her body and leaves the chosen
|
|
19
|
+
* expression alone.
|
|
20
|
+
*
|
|
21
|
+
* `idleMotions` is the set the pet picks from at random for the phase's STEADY
|
|
22
|
+
* state, distinct from `motion`, the one-shot flourish on entering it. A phase
|
|
23
|
+
* such as `working` can last minutes, so without this the pet would fall back to
|
|
24
|
+
* the neutral idle drift the moment the entry clip ended and stop looking busy.
|
|
25
|
+
*
|
|
26
|
+
* The entry clip plays at NORMAL priority and the drift at IDLE, so the flourish
|
|
27
|
+
* always wins when both come due.
|
|
28
|
+
*/
|
|
29
|
+
export const PHASES = {
|
|
30
|
+
idle: {
|
|
31
|
+
/**
|
|
32
|
+
* Every phase carries text, including idle. It is not a transient message: while she is
|
|
33
|
+
* idle this is what the bubble reads, so the window is never blank during the state she
|
|
34
|
+
* spends most of her time in.
|
|
35
|
+
*/
|
|
36
|
+
bubble: "待机中…",
|
|
37
|
+
motion: "mtn_idle01_C",
|
|
38
|
+
/**
|
|
39
|
+
* EMPTY on purpose: the drift is parked until Viola's own motions are chosen. An empty pool
|
|
40
|
+
* makes the drift a no-op, so she stands still rather than playing another character's set.
|
|
41
|
+
*
|
|
42
|
+
* The client mirrors this list exactly — `verify-phases` compares the two, because a drift set
|
|
43
|
+
* that only exists on one side silently does nothing. The client additionally weights them by
|
|
44
|
+
* clip length; see IDLE_WEIGHTS there.
|
|
45
|
+
*/
|
|
46
|
+
idleMotions: [],
|
|
47
|
+
label: "idle"
|
|
48
|
+
},
|
|
49
|
+
/** The user just spoke: an acknowledging nod. */
|
|
50
|
+
listening: {
|
|
51
|
+
/**
|
|
52
|
+
* Matches the voice line played on entry, so what is written is what is heard.
|
|
53
|
+
*
|
|
54
|
+
* Taken from the character's own `VoiceLive_14/Live_Miyako_Start_01` (繁体中文 line
|
|
55
|
+
* 「准备已经做好了」), which is what the client plays alongside it.
|
|
56
|
+
*/
|
|
57
|
+
bubble: "准备已经做好了",
|
|
58
|
+
motion: "mtn_nod02_C",
|
|
59
|
+
/*
|
|
60
|
+
* Nodding while the message is being taken in. Randomised like the other short phases: a
|
|
61
|
+
* message is acknowledged in LISTEN_HOLD_MS (1.4 s), far less than the drift interval, so a
|
|
62
|
+
* fixed entry motion would leave the second clip unreachable.
|
|
63
|
+
*
|
|
64
|
+
* These are the two nods — `nod02` (3.47 s) and `nod01` (2.70 s). `nod03` is deliberately not
|
|
65
|
+
* used here.
|
|
66
|
+
*/
|
|
67
|
+
randomMotions: ["mtn_nod02_C", "mtn_nod01_C"],
|
|
68
|
+
idleMotions: ["mtn_nod02_C", "mtn_nod01_C"],
|
|
69
|
+
label: "listening"
|
|
70
|
+
},
|
|
71
|
+
/**
|
|
72
|
+
* The model is reasoning: one of three poses, chosen at random.
|
|
73
|
+
*
|
|
74
|
+
* No bubble. This phase used to read 「让我想想…」 on a loop for as long as the model took, which
|
|
75
|
+
* says nothing the user cannot already see — the pet is visibly thinking. The field is kept as an
|
|
76
|
+
* empty string rather than deleted so the table shape stays uniform, and the client already hides
|
|
77
|
+
* the bubble when a phase carries no text.
|
|
78
|
+
*
|
|
79
|
+
* The occasional spoken line still shows its own caption: those go through the overlay window, not
|
|
80
|
+
* through this field, so removing the bubble does not silence them.
|
|
81
|
+
*/
|
|
82
|
+
thinking: {
|
|
83
|
+
bubble: "",
|
|
84
|
+
motion: "mtn_thinking01_C",
|
|
85
|
+
/*
|
|
86
|
+
* Randomised for the same reason as `done`: the drift does not fire for at least 9 s, and a
|
|
87
|
+
* thinking spell is usually shorter than that, so the drift set was effectively unreachable
|
|
88
|
+
* and every spell opened with the identical pose.
|
|
89
|
+
*/
|
|
90
|
+
randomMotions: ["mtn_thinking01_C", "mtn_serious01_C", "mtn_question01_C"],
|
|
91
|
+
idleMotions: ["mtn_thinking01_C", "mtn_serious01_C", "mtn_question01_C"],
|
|
92
|
+
label: "thinking"
|
|
93
|
+
},
|
|
94
|
+
/** A tool call is in flight: one of three poses, chosen at random. Silent, like `thinking`. */
|
|
95
|
+
working: {
|
|
96
|
+
bubble: "",
|
|
97
|
+
motion: "mtn_serious01_C",
|
|
98
|
+
// EMPTY on purpose, like `idle`: parked until Viola's own working motions are chosen.
|
|
99
|
+
randomMotions: [],
|
|
100
|
+
idleMotions: [],
|
|
101
|
+
label: "working"
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* A turn finished: one of three celebrations, chosen at random.
|
|
105
|
+
*
|
|
106
|
+
* No phase bubble. It said 「完成啦!」 on every completion, which the celebration itself already
|
|
107
|
+
* communicates — and a caption that is always the same stops being information.
|
|
108
|
+
*
|
|
109
|
+
* The spoken line still shows its own caption: those go through the overlay window, not through this
|
|
110
|
+
* field, so removing the phase text does not silence her.
|
|
111
|
+
*/
|
|
112
|
+
done: {
|
|
113
|
+
bubble: "",
|
|
114
|
+
motion: "mtn_kime01_C",
|
|
115
|
+
/**
|
|
116
|
+
* The celebration actually played, picked at random each time.
|
|
117
|
+
*
|
|
118
|
+
* A single fixed `motion` was the whole problem: `done` only lasts TRANSIENT_HOLD_MS, and the
|
|
119
|
+
* drift does not fire for at least 9 s, so the three-motion set below could never be reached
|
|
120
|
+
* — every completion played `kime01` and nothing else. The client picks one of these on
|
|
121
|
+
* entry instead.
|
|
122
|
+
*
|
|
123
|
+
* `motion` is kept as the fallback for a client that does not know about this field, and so
|
|
124
|
+
* the "every phase has an entry motion" invariant still holds.
|
|
125
|
+
*/
|
|
126
|
+
randomMotions: ["mtn_check01_C", "mtn_nod02_C", "mtn_kime01_C"],
|
|
127
|
+
idleMotions: ["mtn_check01_C", "mtn_nod02_C", "mtn_kime01_C"],
|
|
128
|
+
label: "done"
|
|
129
|
+
},
|
|
130
|
+
/** Something errored: one of four reactions, chosen at random. */
|
|
131
|
+
error: {
|
|
132
|
+
bubble: "出错了…我再试试",
|
|
133
|
+
motion: "mtn_sad01_C",
|
|
134
|
+
/**
|
|
135
|
+
* Same reasoning as `done`: this phase also settles after TRANSIENT_HOLD_MS, which is far
|
|
136
|
+
* shorter than the drift interval, so a fixed entry motion left the drift set unreachable and
|
|
137
|
+
* every failure looked identical.
|
|
138
|
+
*/
|
|
139
|
+
randomMotions: ["mtn_denial01_C", "mtn_angry01_C", "mtn_cry01_C", "mtn_sad01_C"],
|
|
140
|
+
idleMotions: ["mtn_denial01_C", "mtn_angry01_C", "mtn_cry01_C", "mtn_sad01_C"],
|
|
141
|
+
label: "error"
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** How long a transient phase (done/error) holds before settling back to idle. */
|
|
146
|
+
export const TRANSIENT_HOLD_MS = 2600;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How long the `listening` pose is guaranteed to stay up. A real turn often
|
|
150
|
+
* starts within a few hundred milliseconds of the user's message, and without
|
|
151
|
+
* this hold the nod would be replaced before it is visible.
|
|
152
|
+
*/
|
|
153
|
+
export const LISTEN_HOLD_MS = 1400;
|
|
154
|
+
|
|
155
|
+
/** The state machine: pure bookkeeping, no timers of its own. */
|
|
156
|
+
export class PetMachine {
|
|
157
|
+
/** @param options - hold durations, overridable from plugin config. */
|
|
158
|
+
constructor(options = {}) {
|
|
159
|
+
this.phase = "idle";
|
|
160
|
+
this.revision = 0;
|
|
161
|
+
this.lastEventAt = 0;
|
|
162
|
+
this.sessions = new Set();
|
|
163
|
+
this.liveSessions = 0;
|
|
164
|
+
this.holdUntil = 0;
|
|
165
|
+
this.settleTimer = null;
|
|
166
|
+
this.transientHoldMs = options.transientHoldMs ?? TRANSIENT_HOLD_MS;
|
|
167
|
+
this.listenHoldMs = options.listenHoldMs ?? LISTEN_HOLD_MS;
|
|
168
|
+
this.timer = options.timer ?? { set: (fn, ms) => setTimeout(fn, ms), clear: (handle) => clearTimeout(handle) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Move to a phase, bumping the revision only on a real change. */
|
|
172
|
+
enter(phase, nowMs, options = {}) {
|
|
173
|
+
if (PHASES[phase] === undefined) return false;
|
|
174
|
+
const changed = this.phase !== phase;
|
|
175
|
+
const forced = options.force === true;
|
|
176
|
+
if (!changed && !forced) return false;
|
|
177
|
+
this.phase = phase;
|
|
178
|
+
this.revision += 1;
|
|
179
|
+
this.lastEventAt = nowMs;
|
|
180
|
+
this.holdUntil = options.holdMs === undefined ? 0 : nowMs + options.holdMs;
|
|
181
|
+
this.clearSettle();
|
|
182
|
+
if (phase === "done" || phase === "error") {
|
|
183
|
+
this.settleTimer = this.timer.set(() => {
|
|
184
|
+
this.settleTimer = null;
|
|
185
|
+
/*
|
|
186
|
+
* Always settle, regardless of live sessions.
|
|
187
|
+
*
|
|
188
|
+
* This used to return early while `liveSessions > 0`, which meant the phase never
|
|
189
|
+
* left `done` in normal use — the GUI holds a session open for as long as it is
|
|
190
|
+
* running, so the condition was effectively always true. The pet then stayed on the
|
|
191
|
+
* `done` animation set forever, and because that set is what the idle drift picks
|
|
192
|
+
* from, she kept replaying the completion flourish instead of going back to resting.
|
|
193
|
+
*
|
|
194
|
+
* Waiting for work to finish belongs to the activity events, which move the phase to
|
|
195
|
+
* `thinking`/`working` on their own; this timer only ends the flourish.
|
|
196
|
+
*/
|
|
197
|
+
this.enter("idle", Date.now());
|
|
198
|
+
}, this.transientHoldMs);
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Cancel a pending settle-to-idle. */
|
|
204
|
+
clearSettle() {
|
|
205
|
+
if (this.settleTimer !== null) {
|
|
206
|
+
this.timer.clear(this.settleTimer);
|
|
207
|
+
this.settleTimer = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Track a session becoming live. */
|
|
212
|
+
onSessionActive(sessionId) {
|
|
213
|
+
if (sessionId !== undefined) this.sessions.add(sessionId);
|
|
214
|
+
this.liveSessions = this.sessions.size;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Track a session ending. */
|
|
218
|
+
onSessionDisposed(sessionId) {
|
|
219
|
+
if (sessionId !== undefined) this.sessions.delete(sessionId);
|
|
220
|
+
else this.sessions.clear();
|
|
221
|
+
this.liveSessions = this.sessions.size;
|
|
222
|
+
if (this.liveSessions === 0 && (this.phase === "thinking" || this.phase === "working" || this.phase === "listening")) {
|
|
223
|
+
this.enter("idle", Date.now());
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Reduce one host session event to a phase transition. Unknown events are
|
|
229
|
+
* ignored, so a new core event type can never break the pet.
|
|
230
|
+
* @returns whether the phase changed.
|
|
231
|
+
*/
|
|
232
|
+
onSessionEvent(event, nowMs = Date.now()) {
|
|
233
|
+
const type = event?.type;
|
|
234
|
+
const data = event?.data ?? {};
|
|
235
|
+
switch (type) {
|
|
236
|
+
case "user/message":
|
|
237
|
+
if (data.source?.kind !== undefined && data.source.kind !== "user") return false;
|
|
238
|
+
return this.enter("listening", nowMs, { holdMs: this.listenHoldMs, force: true });
|
|
239
|
+
case "turn/start":
|
|
240
|
+
if (nowMs < this.holdUntil) return false;
|
|
241
|
+
return this.enter("thinking", nowMs);
|
|
242
|
+
case "step/start":
|
|
243
|
+
if (nowMs < this.holdUntil) return false;
|
|
244
|
+
return this.enter("thinking", nowMs);
|
|
245
|
+
case "tool/call":
|
|
246
|
+
return this.enter("working", nowMs);
|
|
247
|
+
case "tool/result":
|
|
248
|
+
if (this.phase !== "working") return false;
|
|
249
|
+
return this.enter("thinking", nowMs);
|
|
250
|
+
case "turn/end":
|
|
251
|
+
return this.enter("done", nowMs, { holdMs: this.transientHoldMs, force: true });
|
|
252
|
+
case "error":
|
|
253
|
+
return this.enter("error", nowMs, { holdMs: this.transientHoldMs, force: true });
|
|
254
|
+
default:
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Snapshot for the API/`view()` layer. */
|
|
260
|
+
view(nowMs = Date.now()) {
|
|
261
|
+
const definition = PHASES[this.phase] ?? PHASES.idle;
|
|
262
|
+
const settled = this.holdUntil !== 0 && nowMs >= this.holdUntil;
|
|
263
|
+
return {
|
|
264
|
+
phase: this.phase,
|
|
265
|
+
revision: this.revision,
|
|
266
|
+
bubble: definition.bubble,
|
|
267
|
+
holding: !settled && this.holdUntil !== 0,
|
|
268
|
+
sessions: this.liveSessions,
|
|
269
|
+
lastEventAt: this.lastEventAt
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Release the settle timer. */
|
|
274
|
+
dispose() {
|
|
275
|
+
this.clearSettle();
|
|
276
|
+
this.sessions.clear();
|
|
277
|
+
this.liveSessions = 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/lib/persist.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the Live2D pet: one small JSON file under $DSH_HOME holding
|
|
3
|
+
* the layout the user dragged/resized the pet into, plus its name. Deliberately
|
|
4
|
+
* minimal — atomic rename writes, tolerant reads (a missing or corrupt file
|
|
5
|
+
* falls back to defaults) — so a bad file can never keep the pet from loading.
|
|
6
|
+
* @module dsh-pet-live2d-viola/persist
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** How much of the character the pet surface frames. */
|
|
12
|
+
export const CROP_PRESETS = ["full", "threeQuarter", "bust"];
|
|
13
|
+
/** Default framing: the whole character, so nothing is ever cut off. */
|
|
14
|
+
export const DEFAULT_CROP = "full";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Layout defaults.
|
|
18
|
+
*
|
|
19
|
+
* The horizontal offset is NOT the usual corner position: this pet is meant to stand beside the other
|
|
20
|
+
* Live2D pet, and both would otherwise default to `right: 32` and land on exactly the same spot.
|
|
21
|
+
*
|
|
22
|
+
* The other pet's surface is `height * SURFACE_ASPECT` wide — 360 * 0.8357 ≈ 301 px — so 370 clears her by
|
|
23
|
+
* a small margin. Matching `bottom` keeps the two on a shared ground line rather than at different heights,
|
|
24
|
+
* which is what makes them read as standing next to each other.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_LAYOUT = {
|
|
27
|
+
visible: true,
|
|
28
|
+
height: 360,
|
|
29
|
+
right: 370,
|
|
30
|
+
bottom: 16,
|
|
31
|
+
locked: false,
|
|
32
|
+
scale: 1,
|
|
33
|
+
opacity: 1,
|
|
34
|
+
crop: DEFAULT_CROP,
|
|
35
|
+
/**
|
|
36
|
+
* Draw the framing boundary around the surface.
|
|
37
|
+
*
|
|
38
|
+
* Off by default: it is a diagnostic overlay for seeing where the surface ends
|
|
39
|
+
* and how the character sits inside it, not something a user wants on all the
|
|
40
|
+
* time. Purely visual — it never receives pointer events, so toggling it cannot
|
|
41
|
+
* change dragging, zooming or tapping.
|
|
42
|
+
*/
|
|
43
|
+
showFrame: false,
|
|
44
|
+
/**
|
|
45
|
+
* Automatic eye blinking.
|
|
46
|
+
*
|
|
47
|
+
* ON by default. This model declares no `EyeBlink` group in its `model3.json`, so the runtime
|
|
48
|
+
* never builds a blink controller and she does not blink out of the box — the client supplies
|
|
49
|
+
* its own against the standard `ParamEyeLOpen` / `ParamEyeROpen`.
|
|
50
|
+
*/
|
|
51
|
+
blink: true,
|
|
52
|
+
/** Eyes and head follow the pointer anywhere on screen. */
|
|
53
|
+
gaze: true,
|
|
54
|
+
/**
|
|
55
|
+
* Playback volume for every spoken clip, 0..1.
|
|
56
|
+
*
|
|
57
|
+
* A layout field rather than a separate setting so it persists through the same path as the rest of
|
|
58
|
+
* the pet's state and shows up in `GET /api/pet-live2d-viola/state` with no extra plumbing. 0 means muted,
|
|
59
|
+
* which is why the clamp range starts at 0 rather than at a positive floor like `opacity` does.
|
|
60
|
+
*/
|
|
61
|
+
voiceVolume: 1
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Default pet name, used until the user renames her. */
|
|
65
|
+
export const DEFAULT_PET_NAME = "viola";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Directory (relative to the package root) holding one subdirectory per character.
|
|
69
|
+
*
|
|
70
|
+
* The plugin ships more than one model and switches between them from the menu, so the models live in a
|
|
71
|
+
* tree keyed by character id rather than in a single fixed directory.
|
|
72
|
+
*/
|
|
73
|
+
export const CHARACTERS_DIR = join("assets", "characters");
|
|
74
|
+
|
|
75
|
+
/** Character shown on a fresh install. */
|
|
76
|
+
export const DEFAULT_MODEL = "viola";
|
|
77
|
+
|
|
78
|
+
/** Layout bounds, mirrored by the plugin Config schema and the set-layout route. */
|
|
79
|
+
export const LAYOUT_LIMITS = {
|
|
80
|
+
height: { min: 120, max: 1200 },
|
|
81
|
+
inset: { min: 0, max: 100000 },
|
|
82
|
+
scale: { min: 0.35, max: 2.5 },
|
|
83
|
+
opacity: { min: 0.15, max: 1 },
|
|
84
|
+
/** 0 is a legitimate value here — it means "mute the voice" — so the range starts there. */
|
|
85
|
+
voiceVolume: { min: 0, max: 1 }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Clamp a number into a range, tolerating non-finite input. */
|
|
89
|
+
function clampNumber(value, fallback, min, max) {
|
|
90
|
+
const n = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
91
|
+
return Math.min(max, Math.max(min, n));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fresh default record. */
|
|
95
|
+
export function emptyPersist() {
|
|
96
|
+
return { name: DEFAULT_PET_NAME, character: DEFAULT_MODEL, layout: { ...DEFAULT_LAYOUT } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve the persistence directory ($DSH_HOME, else ~/.dsh). */
|
|
100
|
+
export function petHomeDir(env = process.env, home = undefined) {
|
|
101
|
+
if (typeof env?.DSH_HOME === "string" && env.DSH_HOME !== "") return env.DSH_HOME;
|
|
102
|
+
return home ?? ".";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize an untrusted layout patch into a valid layout, clamped exactly like
|
|
107
|
+
* the API route does so persisted and served values never diverge.
|
|
108
|
+
* @param patch - a partial or malformed layout object.
|
|
109
|
+
* @param base - the layout to fall back to per field.
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeLayout(patch, base = DEFAULT_LAYOUT) {
|
|
112
|
+
const raw = patch !== null && typeof patch === "object" ? patch : {};
|
|
113
|
+
return {
|
|
114
|
+
visible: typeof raw.visible === "boolean" ? raw.visible : base.visible,
|
|
115
|
+
height: Math.round(clampNumber(raw.height, base.height, LAYOUT_LIMITS.height.min, LAYOUT_LIMITS.height.max)),
|
|
116
|
+
right: Math.round(clampNumber(raw.right, base.right, LAYOUT_LIMITS.inset.min, LAYOUT_LIMITS.inset.max)),
|
|
117
|
+
bottom: Math.round(clampNumber(raw.bottom, base.bottom, LAYOUT_LIMITS.inset.min, LAYOUT_LIMITS.inset.max)),
|
|
118
|
+
locked: typeof raw.locked === "boolean" ? raw.locked : base.locked,
|
|
119
|
+
scale: clampNumber(raw.scale, base.scale, LAYOUT_LIMITS.scale.min, LAYOUT_LIMITS.scale.max),
|
|
120
|
+
opacity: clampNumber(raw.opacity, base.opacity, LAYOUT_LIMITS.opacity.min, LAYOUT_LIMITS.opacity.max),
|
|
121
|
+
// An unknown framing name degrades to the previous one rather than
|
|
122
|
+
// reaching the browser, where it would render an unframed model.
|
|
123
|
+
crop: CROP_PRESETS.includes(raw.crop) ? raw.crop : base.crop,
|
|
124
|
+
showFrame: typeof raw.showFrame === "boolean" ? raw.showFrame : base.showFrame,
|
|
125
|
+
blink: typeof raw.blink === "boolean" ? raw.blink : base.blink,
|
|
126
|
+
gaze: typeof raw.gaze === "boolean" ? raw.gaze : base.gaze,
|
|
127
|
+
voiceVolume: clampNumber(raw.voiceVolume, base.voiceVolume, LAYOUT_LIMITS.voiceVolume.min, LAYOUT_LIMITS.voiceVolume.max)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read the persisted record. Never throws: any unreadable, unparsable or
|
|
133
|
+
* implausible field degrades to its default.
|
|
134
|
+
* @param readFile - sync reader (injected so the module stays testable).
|
|
135
|
+
* @param file - absolute path of the state file.
|
|
136
|
+
*/
|
|
137
|
+
export function loadPersist(readFile, file) {
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(readFile(file, "utf8"));
|
|
141
|
+
} catch {
|
|
142
|
+
return emptyPersist();
|
|
143
|
+
}
|
|
144
|
+
const base = emptyPersist();
|
|
145
|
+
if (parsed === null || typeof parsed !== "object") return base;
|
|
146
|
+
const name = typeof parsed.name === "string" && parsed.name.trim() !== "" ? parsed.name.trim().slice(0, 24) : base.name;
|
|
147
|
+
/*
|
|
148
|
+
* The stored character is carried through as a plain string and validated LATER, against the characters
|
|
149
|
+
* actually on disk — this function is deliberately filesystem-free so a corrupt file cannot throw. An id
|
|
150
|
+
* that no longer exists is therefore caught by the service, which falls back to the default.
|
|
151
|
+
*/
|
|
152
|
+
const character = typeof parsed.character === "string" && parsed.character.trim() !== "" ? parsed.character.trim() : base.character;
|
|
153
|
+
return { name, character, layout: normalizeLayout(parsed.layout, base.layout) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write the record atomically (temp file + rename) so a crash mid-write cannot
|
|
158
|
+
* leave a truncated state file behind.
|
|
159
|
+
*
|
|
160
|
+
* `io` is a PORT, not a set of `fs` functions: its keys must be `mkdir`, `writeFile` and `rename`. Reading the keys
|
|
161
|
+
* off `fs` by their own names does not work, and that is exactly how the pet's settings were lost — the call site
|
|
162
|
+
* passed `{ mkdirSync, writeFileSync, renameSync }` while this function reads `io.mkdir`, `io.writeFile` and
|
|
163
|
+
* `io.rename`, so every write threw `TypeError: io.mkdir is not a function`, the `catch` swallowed it, and this
|
|
164
|
+
* returned false. Nothing checks the return value, so the failure was completely silent: measured, the state
|
|
165
|
+
* directory was still absent after a full session of dragging, resizing and switching character.
|
|
166
|
+
*
|
|
167
|
+
* @param io - `{ mkdir(dir), writeFile(path, data, encoding), rename(from, to) }`.
|
|
168
|
+
* @param file - destination path.
|
|
169
|
+
* @param dir - directory to create first, including parents.
|
|
170
|
+
* @param record - the value to serialise.
|
|
171
|
+
* @returns whether the write succeeded. Callers should surface a false.
|
|
172
|
+
*/
|
|
173
|
+
export function savePersist(io, file, dir, record) {
|
|
174
|
+
try {
|
|
175
|
+
io.mkdir(dir);
|
|
176
|
+
const tmp = `${file}.tmp`;
|
|
177
|
+
io.writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
178
|
+
io.rename(tmp, file);
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host HTTP surfaces of the Live2D pet: the model asset tree and a tiny JSON
|
|
3
|
+
* API the browser polls.
|
|
4
|
+
*
|
|
5
|
+
* Assets are served from a prefix route (`/pet-live2d-viola/assets/<model>/*`) so the
|
|
6
|
+
* whole Cubism tree — model3.json, moc3, physics, motions, expressions, WebP
|
|
7
|
+
* textures — resolves with plain relative fetches, exactly as the Live2D
|
|
8
|
+
* runtime expects. Paths are resolved and re-checked against the asset root, so
|
|
9
|
+
* encoded traversal cannot escape it.
|
|
10
|
+
* @module dsh-pet-live2d-viola/routes
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createReadStream, statSync } from "node:fs";
|
|
14
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Browser-facing base path of the pet API. */
|
|
17
|
+
export const PET_API_PREFIX = "/api/pet-live2d-viola";
|
|
18
|
+
/** Browser-facing base path of the model asset tree. */
|
|
19
|
+
export const PET_ASSET_PREFIX = "/pet-live2d-viola/assets";
|
|
20
|
+
|
|
21
|
+
/** Browser-facing base path of the voice clip tree. */
|
|
22
|
+
export const PET_VOICE_PREFIX = "/pet-live2d-viola/voice";
|
|
23
|
+
|
|
24
|
+
/** Browser-facing base path of the click sound. */
|
|
25
|
+
export const PET_CLICK_PREFIX = "/pet-live2d-viola/click";
|
|
26
|
+
|
|
27
|
+
/** Content types for every file kind a Cubism model tree contains. */
|
|
28
|
+
const MIME = {
|
|
29
|
+
".json": "application/json; charset=utf-8",
|
|
30
|
+
".moc3": "application/octet-stream",
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".jpg": "image/jpeg",
|
|
34
|
+
".jpeg": "image/jpeg",
|
|
35
|
+
".mp3": "audio/mpeg",
|
|
36
|
+
".opus": "audio/ogg",
|
|
37
|
+
".wav": "audio/wav",
|
|
38
|
+
".ogg": "audio/ogg",
|
|
39
|
+
".txt": "text/plain; charset=utf-8"
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** One JSON response, never cached (the pet's state is polled). */
|
|
43
|
+
function sendJson(res, status, body) {
|
|
44
|
+
const text = JSON.stringify(body);
|
|
45
|
+
res.writeHead(status, {
|
|
46
|
+
"content-type": "application/json; charset=utf-8",
|
|
47
|
+
"content-length": String(Buffer.byteLength(text)),
|
|
48
|
+
"cache-control": "no-store"
|
|
49
|
+
});
|
|
50
|
+
res.end(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read a bounded JSON request body. */
|
|
54
|
+
function readJsonBody(req, limit = 32 * 1024) {
|
|
55
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let size = 0;
|
|
58
|
+
req.on("data", (chunk) => {
|
|
59
|
+
size += chunk.length;
|
|
60
|
+
if (size > limit) {
|
|
61
|
+
rejectBody(new Error("body-too-large"));
|
|
62
|
+
req.destroy();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
chunks.push(chunk);
|
|
66
|
+
});
|
|
67
|
+
req.on("end", () => {
|
|
68
|
+
if (chunks.length === 0) {
|
|
69
|
+
resolveBody({});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
74
|
+
} catch {
|
|
75
|
+
rejectBody(new Error("invalid-json"));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
req.on("error", rejectBody);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Wrap an async JSON handler as a route handler. */
|
|
83
|
+
function jsonRoute(method, path, run) {
|
|
84
|
+
return {
|
|
85
|
+
kind: "exact",
|
|
86
|
+
path,
|
|
87
|
+
handler: (req, res) => {
|
|
88
|
+
if (req.method !== method) {
|
|
89
|
+
sendJson(res, 405, { ok: false, error: "method-not-allowed" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const body = method === "POST" ? readJsonBody(req) : Promise.resolve({});
|
|
93
|
+
body
|
|
94
|
+
.then((parsed) => run(parsed))
|
|
95
|
+
.then(
|
|
96
|
+
(value) => sendJson(res, 200, value),
|
|
97
|
+
(error) => sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve one request path under the asset root, or `undefined` when it escapes.
|
|
105
|
+
* @param assetRoot - absolute asset directory.
|
|
106
|
+
* @param relative - the URL path after the prefix, already percent-decoded.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveAsset(assetRoot, relative) {
|
|
109
|
+
const cleaned = normalize(relative).replace(/^([/\\])+/, "");
|
|
110
|
+
const target = resolve(join(assetRoot, cleaned));
|
|
111
|
+
const root = resolve(assetRoot);
|
|
112
|
+
if (target !== root && !target.startsWith(root + sep)) return undefined;
|
|
113
|
+
return target;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a read-only prefix route that serves one directory tree.
|
|
118
|
+
*
|
|
119
|
+
* Extracted so the model assets and the voice clips share one tested implementation: the path
|
|
120
|
+
* normalisation in {@link resolveAsset} is the security-relevant part, and duplicating it would
|
|
121
|
+
* mean two places to get the same traversal guard wrong.
|
|
122
|
+
*
|
|
123
|
+
* @param prefix - browser-facing base path, e.g. `/pet-live2d-viola/assets`.
|
|
124
|
+
* @param root - absolute directory to serve.
|
|
125
|
+
* @param immutableHint - the cache-control comment shown in the header block.
|
|
126
|
+
*/
|
|
127
|
+
function staticTreeRoute(prefix, root, immutableHint) {
|
|
128
|
+
return {
|
|
129
|
+
kind: "prefix",
|
|
130
|
+
path: prefix,
|
|
131
|
+
handler: (req, res) => {
|
|
132
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
133
|
+
res.writeHead(405).end();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let relative;
|
|
137
|
+
try {
|
|
138
|
+
relative = decodeURIComponent(new URL(req.url, "http://localhost").pathname.slice(prefix.length));
|
|
139
|
+
} catch {
|
|
140
|
+
res.writeHead(400).end();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const file = resolveAsset(root, relative);
|
|
144
|
+
const stat = file === undefined ? undefined : statSync(file, { throwIfNoEntry: false });
|
|
145
|
+
if (file === undefined || stat?.isFile() !== true) {
|
|
146
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.writeHead(200, {
|
|
150
|
+
"content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
|
|
151
|
+
"content-length": String(stat.size),
|
|
152
|
+
"cache-control": "public, max-age=86400",
|
|
153
|
+
// The tree is immutable for a given package version.
|
|
154
|
+
"x-immutable": immutableHint
|
|
155
|
+
});
|
|
156
|
+
if (req.method === "HEAD") {
|
|
157
|
+
res.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
createReadStream(file).pipe(res);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build every route for one pet host.
|
|
167
|
+
* @param deps - the pet service plus the absolute asset, voice and click roots.
|
|
168
|
+
* @returns route objects ready for `ctx.webServer.register`.
|
|
169
|
+
*/
|
|
170
|
+
export function makePetRoutes(deps) {
|
|
171
|
+
const { pet, assetRoot, voiceRoot, clickRoot } = deps;
|
|
172
|
+
|
|
173
|
+
const api = [
|
|
174
|
+
jsonRoute("GET", `${PET_API_PREFIX}/state`, () => pet.view()),
|
|
175
|
+
// The full phase table, so the animation set for every work state can be inspected
|
|
176
|
+
// without driving the session through each one.
|
|
177
|
+
jsonRoute("GET", `${PET_API_PREFIX}/phases`, () => ({ ok: true, phases: pet.phaseTable() })),
|
|
178
|
+
jsonRoute("POST", `${PET_API_PREFIX}/layout`, (body) => pet.setLayout(body)),
|
|
179
|
+
jsonRoute("POST", `${PET_API_PREFIX}/name`, (body) => pet.setName(body?.name)),
|
|
180
|
+
jsonRoute("POST", `${PET_API_PREFIX}/event`, (body) => pet.reportClientEvent(body))
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* The voice clips get their own prefix rather than a folder inside the model tree.
|
|
185
|
+
*
|
|
186
|
+
* The model is addressed as `/pet-live2d-viola/assets/<settingsFile>`, so `assetRoot` has to BE the
|
|
187
|
+
* model directory; adding a sibling folder under it would both pollute that tree and make the
|
|
188
|
+
* model's own paths depend on where the voice files happen to sit.
|
|
189
|
+
*/
|
|
190
|
+
const routes = [
|
|
191
|
+
...api,
|
|
192
|
+
staticTreeRoute(PET_ASSET_PREFIX, assetRoot, "model")
|
|
193
|
+
];
|
|
194
|
+
if (typeof voiceRoot === "string" && voiceRoot !== "") {
|
|
195
|
+
routes.push(staticTreeRoute(PET_VOICE_PREFIX, voiceRoot, "voice"));
|
|
196
|
+
}
|
|
197
|
+
/*
|
|
198
|
+
* The click sound gets its own prefix for the same reason the voice clips do, plus one more: it is a
|
|
199
|
+
* different MEDIA TYPE (`.mp3`, not `.opus`), and keeping it out of the voice tree means the voice
|
|
200
|
+
* directory stays "the spoken lines" rather than "audio of any kind".
|
|
201
|
+
*/
|
|
202
|
+
if (typeof clickRoot === "string" && clickRoot !== "") {
|
|
203
|
+
routes.push(staticTreeRoute(PET_CLICK_PREFIX, clickRoot, "click"));
|
|
204
|
+
}
|
|
205
|
+
return routes;
|
|
206
|
+
}
|