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.
Files changed (363) hide show
  1. package/LICENSE +44 -0
  2. package/README.md +98 -0
  3. package/assets/characters/miyako/Expressions/exp_amazed01.exp3.json +1 -0
  4. package/assets/characters/miyako/Expressions/exp_angry01.exp3.json +1 -0
  5. package/assets/characters/miyako/Expressions/exp_angry02.exp3.json +1 -0
  6. package/assets/characters/miyako/Expressions/exp_angry03.exp3.json +1 -0
  7. package/assets/characters/miyako/Expressions/exp_angry04.exp3.json +1 -0
  8. package/assets/characters/miyako/Expressions/exp_angry05.exp3.json +1 -0
  9. package/assets/characters/miyako/Expressions/exp_anxiety01.exp3.json +1 -0
  10. package/assets/characters/miyako/Expressions/exp_bsmile01.exp3.json +1 -0
  11. package/assets/characters/miyako/Expressions/exp_bsmile02.exp3.json +1 -0
  12. package/assets/characters/miyako/Expressions/exp_cry01.exp3.json +1 -0
  13. package/assets/characters/miyako/Expressions/exp_cry02.exp3.json +1 -0
  14. package/assets/characters/miyako/Expressions/exp_cry03.exp3.json +1 -0
  15. package/assets/characters/miyako/Expressions/exp_cry04.exp3.json +1 -0
  16. package/assets/characters/miyako/Expressions/exp_heart01.exp3.json +1 -0
  17. package/assets/characters/miyako/Expressions/exp_idle01.exp3.json +1 -0
  18. package/assets/characters/miyako/Expressions/exp_idle02.exp3.json +1 -0
  19. package/assets/characters/miyako/Expressions/exp_idle03.exp3.json +1 -0
  20. package/assets/characters/miyako/Expressions/exp_jitome01.exp3.json +1 -0
  21. package/assets/characters/miyako/Expressions/exp_kime01.exp3.json +1 -0
  22. package/assets/characters/miyako/Expressions/exp_kirakira01.exp3.json +1 -0
  23. package/assets/characters/miyako/Expressions/exp_mal_angry01.exp3.json +1 -0
  24. package/assets/characters/miyako/Expressions/exp_mal_angry02.exp3.json +1 -0
  25. package/assets/characters/miyako/Expressions/exp_mal_idle01.exp3.json +1 -0
  26. package/assets/characters/miyako/Expressions/exp_mal_idle02.exp3.json +1 -0
  27. package/assets/characters/miyako/Expressions/exp_mal_idle03.exp3.json +1 -0
  28. package/assets/characters/miyako/Expressions/exp_mal_kime01.exp3.json +1 -0
  29. package/assets/characters/miyako/Expressions/exp_mal_kime02.exp3.json +1 -0
  30. package/assets/characters/miyako/Expressions/exp_mal_pale01.exp3.json +1 -0
  31. package/assets/characters/miyako/Expressions/exp_mal_pale02.exp3.json +1 -0
  32. package/assets/characters/miyako/Expressions/exp_mal_sad01.exp3.json +1 -0
  33. package/assets/characters/miyako/Expressions/exp_mal_shy01.exp3.json +1 -0
  34. package/assets/characters/miyako/Expressions/exp_mal_smile01.exp3.json +1 -0
  35. package/assets/characters/miyako/Expressions/exp_pair_angry1_maru_angry1.exp3.json +637 -0
  36. package/assets/characters/miyako/Expressions/exp_pair_angry2_maru_angry2.exp3.json +637 -0
  37. package/assets/characters/miyako/Expressions/exp_pair_bsmile1_maru_smile.exp3.json +637 -0
  38. package/assets/characters/miyako/Expressions/exp_pair_idle_maru_idle.exp3.json +602 -0
  39. package/assets/characters/miyako/Expressions/exp_pair_jitome_maru_kime1.exp3.json +637 -0
  40. package/assets/characters/miyako/Expressions/exp_pair_kime_maru_kime.exp3.json +637 -0
  41. package/assets/characters/miyako/Expressions/exp_pair_serious1_maru_idle3.exp3.json +637 -0
  42. package/assets/characters/miyako/Expressions/exp_pair_serious_maru_idle.exp3.json +637 -0
  43. package/assets/characters/miyako/Expressions/exp_pair_serious_maru_sad.exp3.json +637 -0
  44. package/assets/characters/miyako/Expressions/exp_pair_smile2_maru_kime2.exp3.json +637 -0
  45. package/assets/characters/miyako/Expressions/exp_pair_smile3_maru_kime1.exp3.json +637 -0
  46. package/assets/characters/miyako/Expressions/exp_pair_smile_maru_smile.exp3.json +637 -0
  47. package/assets/characters/miyako/Expressions/exp_pair_surprised2_maru_shy.exp3.json +637 -0
  48. package/assets/characters/miyako/Expressions/exp_pale01.exp3.json +1 -0
  49. package/assets/characters/miyako/Expressions/exp_pale02.exp3.json +1 -0
  50. package/assets/characters/miyako/Expressions/exp_panic01.exp3.json +1 -0
  51. package/assets/characters/miyako/Expressions/exp_puzzled01.exp3.json +1 -0
  52. package/assets/characters/miyako/Expressions/exp_sad01.exp3.json +1 -0
  53. package/assets/characters/miyako/Expressions/exp_sad02.exp3.json +1 -0
  54. package/assets/characters/miyako/Expressions/exp_sad03.exp3.json +1 -0
  55. package/assets/characters/miyako/Expressions/exp_salk01.exp3.json +1 -0
  56. package/assets/characters/miyako/Expressions/exp_serious01.exp3.json +1 -0
  57. package/assets/characters/miyako/Expressions/exp_serious02.exp3.json +1 -0
  58. package/assets/characters/miyako/Expressions/exp_shadow01.exp3.json +1 -0
  59. package/assets/characters/miyako/Expressions/exp_shock01.exp3.json +1 -0
  60. package/assets/characters/miyako/Expressions/exp_shy01.exp3.json +1 -0
  61. package/assets/characters/miyako/Expressions/exp_shy02.exp3.json +1 -0
  62. package/assets/characters/miyako/Expressions/exp_smile01.exp3.json +1 -0
  63. package/assets/characters/miyako/Expressions/exp_smile02.exp3.json +1 -0
  64. package/assets/characters/miyako/Expressions/exp_smile03.exp3.json +1 -0
  65. package/assets/characters/miyako/Expressions/exp_smile04.exp3.json +1 -0
  66. package/assets/characters/miyako/Expressions/exp_spin01.exp3.json +1 -0
  67. package/assets/characters/miyako/Expressions/exp_surprised01.exp3.json +1 -0
  68. package/assets/characters/miyako/Expressions/exp_surprised02.exp3.json +1 -0
  69. package/assets/characters/miyako/Expressions/exp_surprised03.exp3.json +1 -0
  70. package/assets/characters/miyako/Expressions/exp_thinking01.exp3.json +1 -0
  71. package/assets/characters/miyako/Expressions/exp_tired01.exp3.json +1 -0
  72. package/assets/characters/miyako/Expressions/exp_tired02.exp3.json +1 -0
  73. package/assets/characters/miyako/Motions/mtn_angry01_C.motion3.json +1 -0
  74. package/assets/characters/miyako/Motions/mtn_angry01_L.motion3.json +1 -0
  75. package/assets/characters/miyako/Motions/mtn_angry01_R.motion3.json +1 -0
  76. package/assets/characters/miyako/Motions/mtn_angry02_C.motion3.json +1 -0
  77. package/assets/characters/miyako/Motions/mtn_angry02_L.motion3.json +1 -0
  78. package/assets/characters/miyako/Motions/mtn_angry02_R.motion3.json +1 -0
  79. package/assets/characters/miyako/Motions/mtn_bye01_C.motion3.json +1 -0
  80. package/assets/characters/miyako/Motions/mtn_bye01_L.motion3.json +1 -0
  81. package/assets/characters/miyako/Motions/mtn_bye01_R.motion3.json +1 -0
  82. package/assets/characters/miyako/Motions/mtn_check01_C.motion3.json +1 -0
  83. package/assets/characters/miyako/Motions/mtn_check01_L.motion3.json +1 -0
  84. package/assets/characters/miyako/Motions/mtn_check01_R.motion3.json +1 -0
  85. package/assets/characters/miyako/Motions/mtn_cry01_C.motion3.json +1 -0
  86. package/assets/characters/miyako/Motions/mtn_cry01_L.motion3.json +1 -0
  87. package/assets/characters/miyako/Motions/mtn_cry01_R.motion3.json +1 -0
  88. package/assets/characters/miyako/Motions/mtn_denial01_C.motion3.json +1 -0
  89. package/assets/characters/miyako/Motions/mtn_denial01_L.motion3.json +1 -0
  90. package/assets/characters/miyako/Motions/mtn_denial01_R.motion3.json +1 -0
  91. package/assets/characters/miyako/Motions/mtn_idle01_C.motion3.json +1 -0
  92. package/assets/characters/miyako/Motions/mtn_idle01_L.motion3.json +1 -0
  93. package/assets/characters/miyako/Motions/mtn_idle01_R.motion3.json +1 -0
  94. package/assets/characters/miyako/Motions/mtn_idle02_C.motion3.json +1 -0
  95. package/assets/characters/miyako/Motions/mtn_idle02_L.motion3.json +1 -0
  96. package/assets/characters/miyako/Motions/mtn_idle02_R.motion3.json +1 -0
  97. package/assets/characters/miyako/Motions/mtn_kime01_C.motion3.json +1 -0
  98. package/assets/characters/miyako/Motions/mtn_kime01_L.motion3.json +1 -0
  99. package/assets/characters/miyako/Motions/mtn_kime01_R.motion3.json +1 -0
  100. package/assets/characters/miyako/Motions/mtn_look01_C.motion3.json +1 -0
  101. package/assets/characters/miyako/Motions/mtn_look01_L.motion3.json +1 -0
  102. package/assets/characters/miyako/Motions/mtn_look01_R.motion3.json +1 -0
  103. package/assets/characters/miyako/Motions/mtn_look02_C.motion3.json +1 -0
  104. package/assets/characters/miyako/Motions/mtn_look02_L.motion3.json +1 -0
  105. package/assets/characters/miyako/Motions/mtn_look02_R.motion3.json +1 -0
  106. package/assets/characters/miyako/Motions/mtn_mal_angry01_C.motion3.json +1 -0
  107. package/assets/characters/miyako/Motions/mtn_mal_angry01_L.motion3.json +1 -0
  108. package/assets/characters/miyako/Motions/mtn_mal_angry01_R.motion3.json +1 -0
  109. package/assets/characters/miyako/Motions/mtn_mal_bye01_C.motion3.json +1 -0
  110. package/assets/characters/miyako/Motions/mtn_mal_bye01_L.motion3.json +1 -0
  111. package/assets/characters/miyako/Motions/mtn_mal_bye01_R.motion3.json +1 -0
  112. package/assets/characters/miyako/Motions/mtn_mal_cry01_C.motion3.json +1 -0
  113. package/assets/characters/miyako/Motions/mtn_mal_cry01_L.motion3.json +1 -0
  114. package/assets/characters/miyako/Motions/mtn_mal_cry01_R.motion3.json +1 -0
  115. package/assets/characters/miyako/Motions/mtn_mal_happy01_C.motion3.json +1 -0
  116. package/assets/characters/miyako/Motions/mtn_mal_happy01_L.motion3.json +1 -0
  117. package/assets/characters/miyako/Motions/mtn_mal_happy01_R.motion3.json +1 -0
  118. package/assets/characters/miyako/Motions/mtn_mal_rampage01_C.motion3.json +1 -0
  119. package/assets/characters/miyako/Motions/mtn_mal_rampage01_L.motion3.json +1 -0
  120. package/assets/characters/miyako/Motions/mtn_mal_rampage01_R.motion3.json +1 -0
  121. package/assets/characters/miyako/Motions/mtn_mal_smile01_C.motion3.json +1 -0
  122. package/assets/characters/miyako/Motions/mtn_mal_smile01_L.motion3.json +1 -0
  123. package/assets/characters/miyako/Motions/mtn_mal_smile01_R.motion3.json +1 -0
  124. package/assets/characters/miyako/Motions/mtn_nod01_C.motion3.json +1 -0
  125. package/assets/characters/miyako/Motions/mtn_nod01_L.motion3.json +1 -0
  126. package/assets/characters/miyako/Motions/mtn_nod01_R.motion3.json +1 -0
  127. package/assets/characters/miyako/Motions/mtn_nod02_C.motion3.json +1 -0
  128. package/assets/characters/miyako/Motions/mtn_nod02_L.motion3.json +1 -0
  129. package/assets/characters/miyako/Motions/mtn_nod02_R.motion3.json +1 -0
  130. package/assets/characters/miyako/Motions/mtn_nod03_C.motion3.json +1 -0
  131. package/assets/characters/miyako/Motions/mtn_nod03_L.motion3.json +1 -0
  132. package/assets/characters/miyako/Motions/mtn_nod03_R.motion3.json +1 -0
  133. package/assets/characters/miyako/Motions/mtn_question01_C.motion3.json +1 -0
  134. package/assets/characters/miyako/Motions/mtn_question01_L.motion3.json +1 -0
  135. package/assets/characters/miyako/Motions/mtn_question01_R.motion3.json +1 -0
  136. package/assets/characters/miyako/Motions/mtn_sad01_C.motion3.json +1 -0
  137. package/assets/characters/miyako/Motions/mtn_sad01_L.motion3.json +1 -0
  138. package/assets/characters/miyako/Motions/mtn_sad01_R.motion3.json +1 -0
  139. package/assets/characters/miyako/Motions/mtn_serious01_C.motion3.json +1 -0
  140. package/assets/characters/miyako/Motions/mtn_serious01_L.motion3.json +1 -0
  141. package/assets/characters/miyako/Motions/mtn_serious01_R.motion3.json +1 -0
  142. package/assets/characters/miyako/Motions/mtn_smile01_C.motion3.json +1 -0
  143. package/assets/characters/miyako/Motions/mtn_smile01_L.motion3.json +1 -0
  144. package/assets/characters/miyako/Motions/mtn_smile01_R.motion3.json +1 -0
  145. package/assets/characters/miyako/Motions/mtn_smile02_C.motion3.json +1 -0
  146. package/assets/characters/miyako/Motions/mtn_smile02_L.motion3.json +1 -0
  147. package/assets/characters/miyako/Motions/mtn_smile02_R.motion3.json +1 -0
  148. package/assets/characters/miyako/Motions/mtn_surprised01_C.motion3.json +1 -0
  149. package/assets/characters/miyako/Motions/mtn_surprised01_L.motion3.json +1 -0
  150. package/assets/characters/miyako/Motions/mtn_surprised01_R.motion3.json +1 -0
  151. package/assets/characters/miyako/Motions/mtn_thinking01_C.motion3.json +1 -0
  152. package/assets/characters/miyako/Motions/mtn_thinking01_L.motion3.json +1 -0
  153. package/assets/characters/miyako/Motions/mtn_thinking01_R.motion3.json +1 -0
  154. package/assets/characters/miyako/Textures/texture_00.webp +0 -0
  155. package/assets/characters/miyako/Textures/texture_01.webp +0 -0
  156. package/assets/characters/miyako/Textures/texture_02.webp +0 -0
  157. package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.moc3 +0 -0
  158. package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.model3.json +1 -0
  159. package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.model3.json.bak +1 -0
  160. package/assets/characters/miyako/adv_live2d_miyako_014_casual_spring_01_marukun.physics3.json +1 -0
  161. package/assets/characters/miyako/model.json +23 -0
  162. package/assets/characters/viola/Expressions/exp_angry01.exp3.json +1 -0
  163. package/assets/characters/viola/Expressions/exp_bsmile01.exp3.json +1 -0
  164. package/assets/characters/viola/Expressions/exp_bsmile02.exp3.json +1 -0
  165. package/assets/characters/viola/Expressions/exp_cry01.exp3.json +1 -0
  166. package/assets/characters/viola/Expressions/exp_cry02.exp3.json +1 -0
  167. package/assets/characters/viola/Expressions/exp_idle01.exp3.json +1 -0
  168. package/assets/characters/viola/Expressions/exp_idle02.exp3.json +1 -0
  169. package/assets/characters/viola/Expressions/exp_idle03.exp3.json +1 -0
  170. package/assets/characters/viola/Expressions/exp_idle04.exp3.json +1 -0
  171. package/assets/characters/viola/Expressions/exp_kime01.exp3.json +1 -0
  172. package/assets/characters/viola/Expressions/exp_pale01.exp3.json +1 -0
  173. package/assets/characters/viola/Expressions/exp_pale02.exp3.json +1 -0
  174. package/assets/characters/viola/Expressions/exp_sad01.exp3.json +1 -0
  175. package/assets/characters/viola/Expressions/exp_sad02.exp3.json +1 -0
  176. package/assets/characters/viola/Expressions/exp_serious01.exp3.json +1 -0
  177. package/assets/characters/viola/Expressions/exp_serious02.exp3.json +1 -0
  178. package/assets/characters/viola/Expressions/exp_shadow01.exp3.json +1 -0
  179. package/assets/characters/viola/Expressions/exp_smile01.exp3.json +1 -0
  180. package/assets/characters/viola/Expressions/exp_smile02.exp3.json +1 -0
  181. package/assets/characters/viola/Expressions/exp_smile02_nomouth.exp3.json +330 -0
  182. package/assets/characters/viola/Expressions/exp_smile03.exp3.json +1 -0
  183. package/assets/characters/viola/Expressions/exp_surprised01.exp3.json +1 -0
  184. package/assets/characters/viola/Expressions/exp_surprised01_nomouth.exp3.json +330 -0
  185. package/assets/characters/viola/Expressions/exp_thinking01.exp3.json +1 -0
  186. package/assets/characters/viola/Expressions/exp_thinking01_nomouth.exp3.json +330 -0
  187. package/assets/characters/viola/Motions/mtn_angry01_C.motion3.json +1 -0
  188. package/assets/characters/viola/Motions/mtn_angry01_L.motion3.json +1 -0
  189. package/assets/characters/viola/Motions/mtn_angry01_R.motion3.json +1 -0
  190. package/assets/characters/viola/Motions/mtn_bye01_C.motion3.json +1 -0
  191. package/assets/characters/viola/Motions/mtn_bye01_L.motion3.json +1 -0
  192. package/assets/characters/viola/Motions/mtn_bye01_R.motion3.json +1 -0
  193. package/assets/characters/viola/Motions/mtn_check01_C.motion3.json +1 -0
  194. package/assets/characters/viola/Motions/mtn_check01_L.motion3.json +1 -0
  195. package/assets/characters/viola/Motions/mtn_check01_R.motion3.json +1 -0
  196. package/assets/characters/viola/Motions/mtn_cry01_C.motion3.json +1 -0
  197. package/assets/characters/viola/Motions/mtn_cry01_L.motion3.json +1 -0
  198. package/assets/characters/viola/Motions/mtn_cry01_R.motion3.json +1 -0
  199. package/assets/characters/viola/Motions/mtn_denial01_C.motion3.json +1 -0
  200. package/assets/characters/viola/Motions/mtn_denial01_L.motion3.json +1 -0
  201. package/assets/characters/viola/Motions/mtn_denial01_R.motion3.json +1 -0
  202. package/assets/characters/viola/Motions/mtn_idle01_C.motion3.json +1 -0
  203. package/assets/characters/viola/Motions/mtn_idle01_L.motion3.json +1 -0
  204. package/assets/characters/viola/Motions/mtn_idle01_R.motion3.json +1 -0
  205. package/assets/characters/viola/Motions/mtn_kime01_C.motion3.json +1 -0
  206. package/assets/characters/viola/Motions/mtn_kime01_L.motion3.json +1 -0
  207. package/assets/characters/viola/Motions/mtn_kime01_R.motion3.json +1 -0
  208. package/assets/characters/viola/Motions/mtn_look01_C.motion3.json +1 -0
  209. package/assets/characters/viola/Motions/mtn_look01_L.motion3.json +1 -0
  210. package/assets/characters/viola/Motions/mtn_look01_R.motion3.json +1 -0
  211. package/assets/characters/viola/Motions/mtn_nod01_C.motion3.json +1 -0
  212. package/assets/characters/viola/Motions/mtn_nod01_L.motion3.json +1 -0
  213. package/assets/characters/viola/Motions/mtn_nod01_R.motion3.json +1 -0
  214. package/assets/characters/viola/Motions/mtn_nod02_C.motion3.json +1 -0
  215. package/assets/characters/viola/Motions/mtn_nod02_L.motion3.json +1 -0
  216. package/assets/characters/viola/Motions/mtn_nod02_R.motion3.json +1 -0
  217. package/assets/characters/viola/Motions/mtn_question01_C.motion3.json +1 -0
  218. package/assets/characters/viola/Motions/mtn_question01_L.motion3.json +1 -0
  219. package/assets/characters/viola/Motions/mtn_question01_R.motion3.json +1 -0
  220. package/assets/characters/viola/Motions/mtn_sad01_C.motion3.json +1 -0
  221. package/assets/characters/viola/Motions/mtn_sad01_L.motion3.json +1 -0
  222. package/assets/characters/viola/Motions/mtn_sad01_R.motion3.json +1 -0
  223. package/assets/characters/viola/Motions/mtn_serious01_C.motion3.json +1 -0
  224. package/assets/characters/viola/Motions/mtn_serious01_L.motion3.json +1 -0
  225. package/assets/characters/viola/Motions/mtn_serious01_R.motion3.json +1 -0
  226. package/assets/characters/viola/Motions/mtn_smile01_C.motion3.json +1 -0
  227. package/assets/characters/viola/Motions/mtn_smile01_L.motion3.json +1 -0
  228. package/assets/characters/viola/Motions/mtn_smile01_R.motion3.json +1 -0
  229. package/assets/characters/viola/Motions/mtn_smile02_C.motion3.json +1 -0
  230. package/assets/characters/viola/Motions/mtn_smile02_L.motion3.json +1 -0
  231. package/assets/characters/viola/Motions/mtn_smile02_R.motion3.json +1 -0
  232. package/assets/characters/viola/Motions/mtn_surprised01_C.motion3.json +1 -0
  233. package/assets/characters/viola/Motions/mtn_surprised01_L.motion3.json +1 -0
  234. package/assets/characters/viola/Motions/mtn_surprised01_R.motion3.json +1 -0
  235. package/assets/characters/viola/Motions/mtn_thinking01_C.motion3.json +1 -0
  236. package/assets/characters/viola/Motions/mtn_thinking01_L.motion3.json +1 -0
  237. package/assets/characters/viola/Motions/mtn_thinking01_R.motion3.json +1 -0
  238. package/assets/characters/viola/Textures/texture_00.webp +0 -0
  239. package/assets/characters/viola/Textures/texture_01.webp +0 -0
  240. package/assets/characters/viola/adv_live2d_sub_viola_casual_spring_01.moc3 +0 -0
  241. package/assets/characters/viola/adv_live2d_sub_viola_casual_spring_01.model3.json +388 -0
  242. package/assets/characters/viola/adv_live2d_sub_viola_casual_spring_01.physics3.json +1 -0
  243. package/assets/characters/viola_school/Expressions/exp_angry01.exp3.json +1 -0
  244. package/assets/characters/viola_school/Expressions/exp_bsmile01.exp3.json +1 -0
  245. package/assets/characters/viola_school/Expressions/exp_bsmile02.exp3.json +1 -0
  246. package/assets/characters/viola_school/Expressions/exp_cry01.exp3.json +1 -0
  247. package/assets/characters/viola_school/Expressions/exp_cry02.exp3.json +1 -0
  248. package/assets/characters/viola_school/Expressions/exp_idle01.exp3.json +1 -0
  249. package/assets/characters/viola_school/Expressions/exp_idle02.exp3.json +1 -0
  250. package/assets/characters/viola_school/Expressions/exp_idle03.exp3.json +1 -0
  251. package/assets/characters/viola_school/Expressions/exp_idle04.exp3.json +1 -0
  252. package/assets/characters/viola_school/Expressions/exp_kime01.exp3.json +1 -0
  253. package/assets/characters/viola_school/Expressions/exp_pale01.exp3.json +1 -0
  254. package/assets/characters/viola_school/Expressions/exp_pale02.exp3.json +1 -0
  255. package/assets/characters/viola_school/Expressions/exp_sad01.exp3.json +1 -0
  256. package/assets/characters/viola_school/Expressions/exp_sad02.exp3.json +1 -0
  257. package/assets/characters/viola_school/Expressions/exp_serious01.exp3.json +1 -0
  258. package/assets/characters/viola_school/Expressions/exp_serious02.exp3.json +1 -0
  259. package/assets/characters/viola_school/Expressions/exp_shadow01.exp3.json +1 -0
  260. package/assets/characters/viola_school/Expressions/exp_smile01.exp3.json +1 -0
  261. package/assets/characters/viola_school/Expressions/exp_smile02.exp3.json +1 -0
  262. package/assets/characters/viola_school/Expressions/exp_smile02_nomouth.exp3.json +330 -0
  263. package/assets/characters/viola_school/Expressions/exp_smile03.exp3.json +1 -0
  264. package/assets/characters/viola_school/Expressions/exp_surprised01.exp3.json +1 -0
  265. package/assets/characters/viola_school/Expressions/exp_surprised01_nomouth.exp3.json +330 -0
  266. package/assets/characters/viola_school/Expressions/exp_thinking01.exp3.json +1 -0
  267. package/assets/characters/viola_school/Expressions/exp_thinking01_nomouth.exp3.json +330 -0
  268. package/assets/characters/viola_school/Motions/mtn_angry01_C.motion3.json +1 -0
  269. package/assets/characters/viola_school/Motions/mtn_angry01_L.motion3.json +1 -0
  270. package/assets/characters/viola_school/Motions/mtn_angry01_R.motion3.json +1 -0
  271. package/assets/characters/viola_school/Motions/mtn_bye01_C.motion3.json +1 -0
  272. package/assets/characters/viola_school/Motions/mtn_bye01_L.motion3.json +1 -0
  273. package/assets/characters/viola_school/Motions/mtn_bye01_R.motion3.json +1 -0
  274. package/assets/characters/viola_school/Motions/mtn_check01_C.motion3.json +1 -0
  275. package/assets/characters/viola_school/Motions/mtn_check01_L.motion3.json +1 -0
  276. package/assets/characters/viola_school/Motions/mtn_check01_R.motion3.json +1 -0
  277. package/assets/characters/viola_school/Motions/mtn_cry01_C.motion3.json +1 -0
  278. package/assets/characters/viola_school/Motions/mtn_cry01_L.motion3.json +1 -0
  279. package/assets/characters/viola_school/Motions/mtn_cry01_R.motion3.json +1 -0
  280. package/assets/characters/viola_school/Motions/mtn_denial01_C.motion3.json +1 -0
  281. package/assets/characters/viola_school/Motions/mtn_denial01_L.motion3.json +1 -0
  282. package/assets/characters/viola_school/Motions/mtn_denial01_R.motion3.json +1 -0
  283. package/assets/characters/viola_school/Motions/mtn_idle01_C.motion3.json +1 -0
  284. package/assets/characters/viola_school/Motions/mtn_idle01_L.motion3.json +1 -0
  285. package/assets/characters/viola_school/Motions/mtn_idle01_R.motion3.json +1 -0
  286. package/assets/characters/viola_school/Motions/mtn_kime01_C.motion3.json +1 -0
  287. package/assets/characters/viola_school/Motions/mtn_kime01_L.motion3.json +1 -0
  288. package/assets/characters/viola_school/Motions/mtn_kime01_R.motion3.json +1 -0
  289. package/assets/characters/viola_school/Motions/mtn_look01_C.motion3.json +1 -0
  290. package/assets/characters/viola_school/Motions/mtn_look01_L.motion3.json +1 -0
  291. package/assets/characters/viola_school/Motions/mtn_look01_R.motion3.json +1 -0
  292. package/assets/characters/viola_school/Motions/mtn_nod01_C.motion3.json +1 -0
  293. package/assets/characters/viola_school/Motions/mtn_nod01_L.motion3.json +1 -0
  294. package/assets/characters/viola_school/Motions/mtn_nod01_R.motion3.json +1 -0
  295. package/assets/characters/viola_school/Motions/mtn_nod02_C.motion3.json +1 -0
  296. package/assets/characters/viola_school/Motions/mtn_nod02_L.motion3.json +1 -0
  297. package/assets/characters/viola_school/Motions/mtn_nod02_R.motion3.json +1 -0
  298. package/assets/characters/viola_school/Motions/mtn_question01_C.motion3.json +1 -0
  299. package/assets/characters/viola_school/Motions/mtn_question01_L.motion3.json +1 -0
  300. package/assets/characters/viola_school/Motions/mtn_question01_R.motion3.json +1 -0
  301. package/assets/characters/viola_school/Motions/mtn_sad01_C.motion3.json +1 -0
  302. package/assets/characters/viola_school/Motions/mtn_sad01_L.motion3.json +1 -0
  303. package/assets/characters/viola_school/Motions/mtn_sad01_R.motion3.json +1 -0
  304. package/assets/characters/viola_school/Motions/mtn_serious01_C.motion3.json +1 -0
  305. package/assets/characters/viola_school/Motions/mtn_serious01_L.motion3.json +1 -0
  306. package/assets/characters/viola_school/Motions/mtn_serious01_R.motion3.json +1 -0
  307. package/assets/characters/viola_school/Motions/mtn_smile01_C.motion3.json +1 -0
  308. package/assets/characters/viola_school/Motions/mtn_smile01_L.motion3.json +1 -0
  309. package/assets/characters/viola_school/Motions/mtn_smile01_R.motion3.json +1 -0
  310. package/assets/characters/viola_school/Motions/mtn_smile02_C.motion3.json +1 -0
  311. package/assets/characters/viola_school/Motions/mtn_smile02_L.motion3.json +1 -0
  312. package/assets/characters/viola_school/Motions/mtn_smile02_R.motion3.json +1 -0
  313. package/assets/characters/viola_school/Motions/mtn_surprised01_C.motion3.json +1 -0
  314. package/assets/characters/viola_school/Motions/mtn_surprised01_L.motion3.json +1 -0
  315. package/assets/characters/viola_school/Motions/mtn_surprised01_R.motion3.json +1 -0
  316. package/assets/characters/viola_school/Motions/mtn_thinking01_C.motion3.json +1 -0
  317. package/assets/characters/viola_school/Motions/mtn_thinking01_L.motion3.json +1 -0
  318. package/assets/characters/viola_school/Motions/mtn_thinking01_R.motion3.json +1 -0
  319. package/assets/characters/viola_school/Textures/texture_00.webp +0 -0
  320. package/assets/characters/viola_school/Textures/texture_01.webp +0 -0
  321. package/assets/characters/viola_school/adv_live2d_sub_viola_school_winter_hs.moc3 +0 -0
  322. package/assets/characters/viola_school/adv_live2d_sub_viola_school_winter_hs.model3.json +388 -0
  323. package/assets/characters/viola_school/adv_live2d_sub_viola_school_winter_hs.physics3.json +1 -0
  324. package/assets/click/click.mp3 +0 -0
  325. package/assets/voice/README.txt +139 -0
  326. package/assets/voice/done-fc.opus +0 -0
  327. package/assets/voice/done-score.opus +0 -0
  328. package/assets/voice/idle-common02.opus +0 -0
  329. package/assets/voice/idle-levelup.opus +0 -0
  330. package/assets/voice/idle-marukun.opus +0 -0
  331. package/assets/voice/listening.opus +0 -0
  332. package/assets/voice/miyako-farewell.opus +0 -0
  333. package/assets/voice/thinking-comon.opus +0 -0
  334. package/assets/voice/thinking-gekisou.opus +0 -0
  335. package/assets/voice/viola-click1.opus +0 -0
  336. package/assets/voice/viola-click2.opus +0 -0
  337. package/assets/voice/viola-done1.opus +0 -0
  338. package/assets/voice/viola-done2.opus +0 -0
  339. package/assets/voice/viola-done3.opus +0 -0
  340. package/assets/voice/viola-farewell.opus +0 -0
  341. package/assets/voice/viola-idle1.opus +0 -0
  342. package/assets/voice/viola-idle2.opus +0 -0
  343. package/assets/voice/viola-idle3.opus +0 -0
  344. package/assets/voice/viola-idle4.opus +0 -0
  345. package/assets/voice/viola-listening.opus +0 -0
  346. package/assets/voice/viola-thinking1.opus +0 -0
  347. package/assets/voice/viola-thinking2.opus +0 -0
  348. package/assets/voice/viola-thinking3.opus +0 -0
  349. package/assets/voice/viola-working1.opus +0 -0
  350. package/assets/voice/viola-working2.opus +0 -0
  351. package/assets/voice/viola-working3.opus +0 -0
  352. package/assets/voice/working-combo.opus +0 -0
  353. package/assets/voice/working-skill.opus +0 -0
  354. package/assets/voice/working-training.opus +0 -0
  355. package/cordis.patch.yml +18 -0
  356. package/lib/client.js +5514 -0
  357. package/lib/client.live2d.js +67 -0
  358. package/lib/framing.js +284 -0
  359. package/lib/index.js +591 -0
  360. package/lib/machine.js +279 -0
  361. package/lib/persist.js +183 -0
  362. package/lib/routes.js +206 -0
  363. 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
+ }