dsh-live2dwaifu 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -1,98 +1,102 @@
1
- # dsh-live2dwaifu
2
-
3
- 把 **Live2D(Cubism 3 / 4)** 模型请进 DSH 网页的右下角。她会眨眼、会呼吸、头发会被风吹,说话时嘴一张一合,心情变了眉毛跟着动。
4
-
5
- 模型**不是内置的**——谁装谁放自己的。点卡片上的 ⚙,把模型文件夹整个选进去就行,文件留在这台机器上(`~/.dsh/dsh-live2d/models/`),一根网线都没往外走。
6
-
7
- > 给它一个模型,它给你一个会呼吸的角落。
8
-
9
- ![两种状态](docs/preview.png)
10
-
11
- | 样子 | 说明 |
12
- |---|---|
13
- | 展开 | 一张小卡片,上半身,底下一条气泡装台词 |
14
- | 收起 | 缩成一条,只剩名字和一个 ⌃ |
15
-
16
- ## 三十秒上手
17
-
18
- ```bash
19
- dsh plugin --profile desktop add dsh-live2dwaifu
20
- ```
21
-
22
- 重启 DSH,右下角出现一张小卡片 → 点 ⚙ → 点「导入模型」→ 选那个装着 `.model3.json` 的文件夹 → 完事。
23
-
24
- 导入的时候浏览器会一个一个把文件搬进去,模型大点就多等几秒,进度在底下会说话。
25
-
26
- ## 放自己的模型
27
-
28
- 1. 卡片右上角 ⚙ → 「导入模型」
29
- 2. 选**整个文件夹**(里面要有 `.model3.json`、`.moc3`、贴图,最好还有 `.physics3.json`)
30
- 3. 读完它会自己认出哪个是模型,并切过去
31
-
32
- 想放好几个也行,列表里点「用这个」换人,「删」送走。
33
-
34
- **能吃什么**:Cubism 3 / 4(`.model3.json` + `.moc3`)。
35
- **吃不了什么**:Cubism 2 的老格式(`.model.json` + `.moc`)。
36
-
37
- **没有动作文件也能活**:眨眼靠 EyeBlink 参数组,嘴动靠 `ParamMouthOpenY`,头发晃动是 `physics3.json` 自带的物理;表情是本插件拿标准参数(`ParamEyeLSmile`、`ParamMouthForm`、`ParamBrow*`、`ParamAngle*`)现摆的,一共七种:平静、开心、委屈、生气、害羞、无语、困。
38
-
39
- ## 让它说点什么
40
-
41
- 卡片里是个 iframe(`/dsh-live2d/viewer`)。别的插件、脚本、控制台都能指挥它:
42
-
43
- ```js
44
- const frame = document.querySelector('iframe.dsh-l2d-body');
45
-
46
- // 说话:逐字蹦出来,嘴跟着动
47
- frame.contentWindow.postMessage({ type: 'dsh-live2d/say', text: '窗台那盆番茄红了。', mood: '开心' }, '*');
48
-
49
- // 只换脸
50
- frame.contentWindow.postMessage({ type: 'dsh-live2d/mood', mood: '委屈' }, '*');
51
-
52
- // 打开模型管理
53
- frame.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
54
- ```
55
-
56
- 同源的 HTTP 接口,给不服 iframe 的人:
57
-
58
- | 方法 | 路径 | 作用 |
59
- |---|---|---|
60
- | GET | `/dsh-live2d/api/models` | 列出模型和当前选中的那只 |
61
- | POST | `/dsh-live2d/api/select` | `{id}` 换人 |
62
- | POST | `/dsh-live2d/api/import?model=<id>&path=<相对路径>` | 请求体就是文件字节 |
63
- | POST | `/dsh-live2d/api/commit` | `{id,name}` 扫一遍 model3 并选中 |
64
- | POST | `/dsh-live2d/api/delete` | `{id}` 删掉 |
65
-
66
- ## 东西都放哪了
67
-
68
- ```
69
- ~/.dsh/dsh-live2d/
70
- config.json # 现在轮到谁上场
71
- models/<id>/ # 模型文件,相对结构原样保留
72
- meta.json # { name, model3 }
73
- ```
74
-
75
- ## 一些常见的小脾气
76
-
77
- **右下角什么都没有?**
78
- 先重启一次 DSH;还不来就看 `cordis.patch.yml` 里有没有 `- id: dsh-live2dwaifu` 这一行。
79
-
80
- **导入了却不认得?**
81
- 多半是文件夹选得太靠上或太靠下,`.model3.json` 就在里面那一层,选它隔壁那层。
82
-
83
- **她只有半张脸?**
84
- 这个模型的画布比例跟面板不一样。面板是竖的,太宽或太扁的模型会被裁两边。
85
-
86
- **能放动作吗?**
87
- 暂时不能编排 `.motion3.json`,表情是固定的七种。想要更多情绪,给它发 `mood`,等以后的版本。
88
-
89
- ## 已知限制
90
-
91
- - 只支持 Cubism 3 / 4
92
- - 面板是浮层(`shell.overlay`),不是聊天区分栏;会盖住右下角,能收起
93
- - 七种表情,暂不支持自定义表情文件(`.exp3.json`)
94
- - 内置的 Cubism Core 是 Live2D Inc. 的 Redistributable Code,随包分发,使用受其 EULA 约束
95
-
96
- ---
97
-
98
- MIT © LFR · npm: [`dsh-live2dwaifu`](https://www.npmjs.com/package/dsh-live2dwaifu)
1
+ # dsh-live2dwaifu
2
+
3
+ 把 **Live2D(Cubism 3 / 4)** 模型请进 DSH 网页的右下角。她会眨眼、会呼吸、头发会被风吹,说话时嘴一张一合,心情变了眉毛跟着动。
4
+
5
+ 模型**不是内置的**——谁装谁放自己的。点卡片上的 ⚙,把模型文件夹整个选进去就行,文件留在这台机器上(`~/.dsh/dsh-live2d/models/`),一根网线都没往外走。
6
+
7
+ > 给它一个模型,它给你一个会呼吸的角落。
8
+
9
+ ![两种状态](docs/preview.png)
10
+
11
+ | 样子 | 说明 |
12
+ |---|---|
13
+ | 展开 | 一张小卡片,上半身,底下一条气泡装台词 |
14
+ | 收起 | 缩成一条,只剩名字和一个 ⌃ |
15
+
16
+ 右下角有个小三角,拖它就能改卡片大小;标题栏的 **◐** 一键换背景(透明 / 深色 / 浅色 / 纸质)。窗口大小、背景、开合状态都记在本机,下次还是这样。
17
+
18
+ ## 三十秒上手
19
+
20
+ ```bash
21
+ dsh plugin --profile desktop add dsh-live2dwaifu
22
+ ```
23
+
24
+ 重启 DSH,右下角出现一张小卡片 → 点 ⚙ → 点「导入模型」→ 选那个装着 `.model3.json` 的文件夹 → 完事。
25
+
26
+ 导入的时候浏览器会一个一个把文件搬进去,模型大点就多等几秒,进度在底下会说话。
27
+
28
+ ## 放自己的模型
29
+
30
+ 1. 卡片右上角 ⚙ → 「导入模型」
31
+ 2. 选**整个文件夹**(里面要有 `.model3.json`、`.moc3`、贴图,最好还有 `.physics3.json`)
32
+ 3. 读完它会自己认出哪个是模型,并切过去
33
+
34
+ 想放好几个也行,列表里点「用这个」换人,「删」送走。
35
+
36
+ **能吃什么**:Cubism 3 / 4(`.model3.json` + `.moc3`)。
37
+ **吃不了什么**:Cubism 2 的老格式(`.model.json` + `.moc`)。
38
+
39
+ **自定义表情**:模型文件夹里带 `.exp3.json` 的,导入后在 ⚙ 面板里会列出来,点一下就叠上去(Add / Multiply / Overwrite 三种混合照原样执行)。Cubism 4 的 `.exp3.json` 直接从原画软件导出即可。
40
+
41
+ **没有动作文件也能活**:眨眼靠 EyeBlink 参数组,嘴动靠 `ParamMouthOpenY`,头发晃动是 `physics3.json` 自带的物理;表情是本插件拿标准参数(`ParamEyeLSmile`、`ParamMouthForm`、`ParamBrow*`、`ParamAngle*`)现摆的,一共七种:平静、开心、委屈、生气、害羞、无语、困。
42
+
43
+ ## 让它说点什么
44
+
45
+ 卡片里是个 iframe(`/dsh-live2d/viewer`)。别的插件、脚本、控制台都能指挥它:
46
+
47
+ ```js
48
+ const frame = document.querySelector('iframe.dsh-l2d-body');
49
+
50
+ // 说话:逐字蹦出来,嘴跟着动
51
+ frame.contentWindow.postMessage({ type: 'dsh-live2d/say', text: '窗台那盆番茄红了。', mood: '开心' }, '*');
52
+
53
+ // 只换脸
54
+ frame.contentWindow.postMessage({ type: 'dsh-live2d/mood', mood: '委屈' }, '*');
55
+
56
+ // 打开模型管理
57
+ frame.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
58
+ ```
59
+
60
+ 同源的 HTTP 接口,给不服 iframe 的人:
61
+
62
+ | 方法 | 路径 | 作用 |
63
+ |---|---|---|
64
+ | GET | `/dsh-live2d/api/models` | 列出模型和当前选中的那只 |
65
+ | POST | `/dsh-live2d/api/select` | `{id}` 换人 |
66
+ | POST | `/dsh-live2d/api/import?model=<id>&path=<相对路径>` | 请求体就是文件字节 |
67
+ | POST | `/dsh-live2d/api/commit` | `{id,name}` 扫一遍 model3 并选中 |
68
+ | POST | `/dsh-live2d/api/delete` | `{id}` 删掉 |
69
+
70
+ ## 东西都放哪了
71
+
72
+ ```
73
+ ~/.dsh/dsh-live2d/
74
+ config.json # 现在轮到谁上场
75
+ models/<id>/ # 模型文件,相对结构原样保留
76
+ meta.json # { name, model3 }
77
+ ```
78
+
79
+ ## 一些常见的小脾气
80
+
81
+ **右下角什么都没有?**
82
+ 先重启一次 DSH;还不来就看 `cordis.patch.yml` 里有没有 `- id: dsh-live2dwaifu` 这一行。
83
+
84
+ **导入了却不认得?**
85
+ 多半是文件夹选得太靠上或太靠下,`.model3.json` 就在里面那一层,选它隔壁那层。
86
+
87
+ **她只有半张脸?**
88
+ 这个模型的画布比例跟面板不一样。面板是竖的,太宽或太扁的模型会被裁两边。
89
+
90
+ **能放动作吗?**
91
+ 暂时不能编排 `.motion3.json`,表情是固定的七种。想要更多情绪,给它发 `mood`,等以后的版本。
92
+
93
+ ## 已知限制
94
+
95
+ - 只支持 Cubism 3 / 4(表情只认 `.exp3.json`,不认 `.motion3.json` 里的表情轨)
96
+ - 面板是浮层(`shell.overlay`),不是聊天区分栏;会盖住右下角,能收起
97
+ - 内置七种情绪;自定义表情靠模型自带的 `.exp3.json`,一次只能叠一个
98
+ - 内置的 Cubism Core 是 Live2D Inc. 的 Redistributable Code,随包分发,使用受其 EULA 约束
99
+
100
+ ---
101
+
102
+ MIT © LFR · npm: [`dsh-live2dwaifu`](https://www.npmjs.com/package/dsh-live2dwaifu)
@@ -1,55 +1,61 @@
1
- <!DOCTYPE html>
2
- <html lang="zh">
3
- <head>
4
- <meta charset="utf-8" />
5
- <title>Live2D</title>
6
- <style>
7
- html, body { margin: 0; height: 100%; background: transparent; overflow: hidden;
8
- font: 12px/1.5 "Microsoft YaHei", system-ui, sans-serif; color: #d8dbe2; }
9
- body.standalone { background: #14161a; }
10
- #stage { display: block; width: 100%; height: 100%; }
11
- #bubble { position: absolute; left: 12px; right: 12px; bottom: 10px; min-height: 30px; box-sizing: border-box;
12
- background: rgba(24,27,33,.94); border: 1px solid #3a4049; border-radius: 10px; padding: 7px 10px;
13
- color: #dfe4ec; font-size: 13px; line-height: 1.45; }
14
- #hint { position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
15
- text-align: center; padding: 20px; color: #98a0ad; }
16
- #hint.show { display: flex; }
17
- #drawer { position: absolute; inset: 0; background: rgba(18,20,24,.97); display: none;
18
- flex-direction: column; padding: 12px; box-sizing: border-box; }
19
- #drawer.show { display: flex; }
20
- #drawer h2 { font-size: 12px; font-weight: 600; margin: 0 0 8px; color: #cfd4dd; letter-spacing: .5px; }
21
- #list { flex: 1; overflow-y: auto; margin-bottom: 8px; }
22
- .row { display: flex; align-items: center; gap: 6px; padding: 5px 6px; border-radius: 8px; }
23
- .row.cur { background: #2b3038; }
24
- .row span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
25
- .row button { background: #2f343d; color: #cfd4dd; border: 1px solid #3c424c; border-radius: 6px;
26
- padding: 2px 7px; cursor: pointer; font: inherit; }
27
- .row button:hover { background: #3a4049; }
28
- #actions { display: flex; gap: 6px; }
29
- #actions button { flex: 1; background: #2f343d; color: #cfd4dd; border: 1px solid #3c424c;
30
- border-radius: 8px; padding: 6px; cursor: pointer; font: inherit; }
31
- #actions button:hover { background: #3a4049; }
32
- #tip { color: #7f8794; font-size: 11px; margin: 6px 2px 0; }
33
- </style>
34
- </head>
35
- <body>
36
- <canvas id="stage"></canvas>
37
- <div id="bubble"></div>
38
- <div id="hint"><div>还没有模型。<br />点下面的「导入模型」,选中你的 Live2D 文件夹(含 .model3.json 那一层)。</div></div>
39
- <div id="drawer">
40
- <h2>模型</h2>
41
- <div id="list"></div>
42
- <div id="actions">
43
- <button id="import">导入模型</button>
44
- <button id="close">关掉</button>
45
- </div>
46
- <div id="tip">选整个文件夹,浏览器会自己把里面所有文件读进来。</div>
47
- <input id="files" type="file" webkitdirectory multiple style="display:none" />
48
- </div>
49
- <script>
50
- if (!location.search.includes('embed')) document.body.classList.add('standalone');
51
- </script>
52
- <script src="/dsh-live2d/assets/live2dcubismcore.min.js"></script>
53
- <script src="/dsh-live2d/assets/viewer.js"></script>
54
- </body>
55
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Live2D</title>
6
+ <style>
7
+ html, body { margin: 0; height: 100%; background: transparent; overflow: hidden;
8
+ font: 12px/1.5 "Microsoft YaHei", system-ui, sans-serif; color: #d8dbe2; }
9
+ body.standalone { background: #14161a; }
10
+ #stage { display: block; width: 100%; height: 100%; }
11
+ #bubble { position: absolute; left: 12px; right: 12px; bottom: 10px; min-height: 30px; box-sizing: border-box;
12
+ background: rgba(24,27,33,.94); border: 1px solid #3a4049; border-radius: 10px; padding: 7px 10px;
13
+ color: #dfe4ec; font-size: 13px; line-height: 1.45; }
14
+ #hint { position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
15
+ text-align: center; padding: 20px; color: #98a0ad; }
16
+ #hint.show { display: flex; }
17
+ #drawer { position: absolute; inset: 0; background: rgba(18,20,24,.97); display: none;
18
+ flex-direction: column; padding: 12px; box-sizing: border-box; }
19
+ #drawer.show { display: flex; }
20
+ #drawer h2 { font-size: 12px; font-weight: 600; margin: 0 0 8px; color: #cfd4dd; letter-spacing: .5px; }
21
+ #list { flex: 1; overflow-y: auto; margin-bottom: 8px; }
22
+ .row { display: flex; align-items: center; gap: 6px; padding: 5px 6px; border-radius: 8px; }
23
+ .row.cur { background: #2b3038; }
24
+ .row span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
25
+ .row button { background: #2f343d; color: #cfd4dd; border: 1px solid #3c424c; border-radius: 6px;
26
+ padding: 2px 7px; cursor: pointer; font: inherit; }
27
+ .row button:hover { background: #3a4049; }
28
+ #exps { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
29
+ #exps button { background: #2f343d; color: #cfd4dd; border: 1px solid #3c424c;
30
+ border-radius: 6px; padding: 3px 8px; cursor: pointer; font: inherit; }
31
+ #exps button.on { background: #3a5f8a; border-color: #4c7bb0; color: #eaf2ff; }
32
+ #exps .tipx { color: #6f7783; font-size: 11px; align-self: center; }
33
+ #actions { display: flex; gap: 6px; }
34
+ #actions button { flex: 1; background: #2f343d; color: #cfd4dd; border: 1px solid #3c424c;
35
+ border-radius: 8px; padding: 6px; cursor: pointer; font: inherit; }
36
+ #actions button:hover { background: #3a4049; }
37
+ #tip { color: #7f8794; font-size: 11px; margin: 6px 2px 0; }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <canvas id="stage"></canvas>
42
+ <div id="bubble"></div>
43
+ <div id="hint"><div>还没有模型。<br />点下面的「导入模型」,选中你的 Live2D 文件夹(含 .model3.json 那一层)。</div></div>
44
+ <div id="drawer">
45
+ <h2>模型</h2>
46
+ <div id="list"></div>
47
+ <div id="exps"></div>
48
+ <div id="actions">
49
+ <button id="import">导入模型</button>
50
+ <button id="close">关掉</button>
51
+ </div>
52
+ <div id="tip">选整个文件夹,浏览器会自己把里面所有文件读进来。</div>
53
+ <input id="files" type="file" webkitdirectory multiple style="display:none" />
54
+ </div>
55
+ <script>
56
+ if (!location.search.includes('embed')) document.body.classList.add('standalone');
57
+ </script>
58
+ <script src="/dsh-live2d/assets/live2dcubismcore.min.js"></script>
59
+ <script src="/dsh-live2d/assets/viewer.js"></script>
60
+ </body>
61
+ </html>
package/assets/viewer.js CHANGED
@@ -11238,9 +11238,9 @@ void main(void)
11238
11238
  const attribute = attributes[j2], buffer = buffers[attribute.buffer], glBuffer = buffer._glBuffers[CONTEXT_UID];
11239
11239
  if (program.attributeData[j2]) {
11240
11240
  lastBuffer !== glBuffer && (bufferSystem.bind(buffer), lastBuffer = glBuffer);
11241
- const location = program.attributeData[j2].location;
11242
- if (gl.enableVertexAttribArray(location), gl.vertexAttribPointer(
11243
- location,
11241
+ const location2 = program.attributeData[j2].location;
11242
+ if (gl.enableVertexAttribArray(location2), gl.vertexAttribPointer(
11243
+ location2,
11244
11244
  attribute.size,
11245
11245
  attribute.type || gl.FLOAT,
11246
11246
  attribute.normalized,
@@ -11248,7 +11248,7 @@ void main(void)
11248
11248
  attribute.start
11249
11249
  ), attribute.instance)
11250
11250
  if (this.hasInstance)
11251
- gl.vertexAttribDivisor(location, attribute.divisor);
11251
+ gl.vertexAttribDivisor(location2, attribute.divisor);
11252
11252
  else
11253
11253
  throw new Error("geometry error, GPU Instancing is not supported on this device");
11254
11254
  }
@@ -12957,14 +12957,14 @@ void main(void)
12957
12957
  * @param texture - Texture to bind
12958
12958
  * @param [location=0] - Location to bind at
12959
12959
  */
12960
- bind(texture, location = 0) {
12960
+ bind(texture, location2 = 0) {
12961
12961
  const { gl } = this;
12962
12962
  if (texture = texture?.castToBaseTexture(), texture?.valid && !texture.parentTextureArray) {
12963
12963
  texture.touched = this.renderer.textureGC.count;
12964
12964
  const glTexture = texture._glTextures[this.CONTEXT_UID] || this.initTexture(texture);
12965
- this.boundTextures[location] !== texture && (this.currentLocation !== location && (this.currentLocation = location, gl.activeTexture(gl.TEXTURE0 + location)), gl.bindTexture(texture.target, glTexture.texture)), glTexture.dirtyId !== texture.dirtyId ? (this.currentLocation !== location && (this.currentLocation = location, gl.activeTexture(gl.TEXTURE0 + location)), this.updateTexture(texture)) : glTexture.dirtyStyleId !== texture.dirtyStyleId && this.updateTextureStyle(texture), this.boundTextures[location] = texture;
12965
+ this.boundTextures[location2] !== texture && (this.currentLocation !== location2 && (this.currentLocation = location2, gl.activeTexture(gl.TEXTURE0 + location2)), gl.bindTexture(texture.target, glTexture.texture)), glTexture.dirtyId !== texture.dirtyId ? (this.currentLocation !== location2 && (this.currentLocation = location2, gl.activeTexture(gl.TEXTURE0 + location2)), this.updateTexture(texture)) : glTexture.dirtyStyleId !== texture.dirtyStyleId && this.updateTextureStyle(texture), this.boundTextures[location2] = texture;
12966
12966
  } else
12967
- this.currentLocation !== location && (this.currentLocation = location, gl.activeTexture(gl.TEXTURE0 + location)), gl.bindTexture(gl.TEXTURE_2D, this.emptyTextures[gl.TEXTURE_2D].texture), this.boundTextures[location] = null;
12967
+ this.currentLocation !== location2 && (this.currentLocation = location2, gl.activeTexture(gl.TEXTURE0 + location2)), gl.bindTexture(gl.TEXTURE_2D, this.emptyTextures[gl.TEXTURE_2D].texture), this.boundTextures[location2] = null;
12968
12968
  }
12969
12969
  /** Resets texture location and bound textures Actual `bind(null, i)` calls will be performed at next `unbind()` call */
12970
12970
  reset() {
@@ -18040,7 +18040,7 @@ void main()
18040
18040
  }
18041
18041
  return propagationPath.reverse(), propagationPath;
18042
18042
  }
18043
- hitTestMoveRecursive(currentTarget, eventMode, location, testFn, pruneFn, ignore = false) {
18043
+ hitTestMoveRecursive(currentTarget, eventMode, location2, testFn, pruneFn, ignore = false) {
18044
18044
  let shouldReturn = false;
18045
18045
  if (this._interactivePrune(currentTarget))
18046
18046
  return null;
@@ -18050,10 +18050,10 @@ void main()
18050
18050
  const child = children[i2], nestedHit = this.hitTestMoveRecursive(
18051
18051
  child,
18052
18052
  this._isInteractive(eventMode) ? eventMode : child.eventMode,
18053
- location,
18053
+ location2,
18054
18054
  testFn,
18055
18055
  pruneFn,
18056
- ignore || pruneFn(currentTarget, location)
18056
+ ignore || pruneFn(currentTarget, location2)
18057
18057
  );
18058
18058
  if (nestedHit) {
18059
18059
  if (nestedHit.length > 0 && !nestedHit[nestedHit.length - 1].parent)
@@ -18064,7 +18064,7 @@ void main()
18064
18064
  }
18065
18065
  }
18066
18066
  const isInteractiveMode = this._isInteractive(eventMode), isInteractiveTarget = currentTarget.isInteractive();
18067
- return isInteractiveMode && isInteractiveTarget && this._allInteractiveElements.push(currentTarget), ignore || this._hitElements.length > 0 ? null : shouldReturn ? this._hitElements : isInteractiveMode && !pruneFn(currentTarget, location) && testFn(currentTarget, location) ? isInteractiveTarget ? [currentTarget] : [] : null;
18067
+ return isInteractiveMode && isInteractiveTarget && this._allInteractiveElements.push(currentTarget), ignore || this._hitElements.length > 0 ? null : shouldReturn ? this._hitElements : isInteractiveMode && !pruneFn(currentTarget, location2) && testFn(currentTarget, location2) ? isInteractiveTarget ? [currentTarget] : [] : null;
18068
18068
  }
18069
18069
  /**
18070
18070
  * Recursive implementation for {@link PIXI.EventBoundary.hitTest hitTest}.
@@ -18080,8 +18080,8 @@ void main()
18080
18080
  * is the target itself and the last is {@link PIXI.EventBoundary.rootTarget rootTarget}. This is the opposite
18081
18081
  * order w.r.t. the propagation path. If no hit testing target is found, null is returned.
18082
18082
  */
18083
- hitTestRecursive(currentTarget, eventMode, location, testFn, pruneFn) {
18084
- if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))
18083
+ hitTestRecursive(currentTarget, eventMode, location2, testFn, pruneFn) {
18084
+ if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location2))
18085
18085
  return null;
18086
18086
  if ((currentTarget.eventMode === "dynamic" || eventMode === "dynamic") && (EventsTicker.pauseUpdate = false), currentTarget.interactiveChildren && currentTarget.children) {
18087
18087
  const children = currentTarget.children;
@@ -18089,7 +18089,7 @@ void main()
18089
18089
  const child = children[i2], nestedHit = this.hitTestRecursive(
18090
18090
  child,
18091
18091
  this._isInteractive(eventMode) ? eventMode : child.eventMode,
18092
- location,
18092
+ location2,
18093
18093
  testFn,
18094
18094
  pruneFn
18095
18095
  );
@@ -18102,7 +18102,7 @@ void main()
18102
18102
  }
18103
18103
  }
18104
18104
  const isInteractiveMode = this._isInteractive(eventMode), isInteractiveTarget = currentTarget.isInteractive();
18105
- return isInteractiveMode && testFn(currentTarget, location) ? isInteractiveTarget ? [currentTarget] : [] : null;
18105
+ return isInteractiveMode && testFn(currentTarget, location2) ? isInteractiveTarget ? [currentTarget] : [] : null;
18106
18106
  }
18107
18107
  _isInteractive(int) {
18108
18108
  return int === "static" || int === "dynamic";
@@ -18118,12 +18118,12 @@ void main()
18118
18118
  * @param displayObject
18119
18119
  * @param location
18120
18120
  */
18121
- hitPruneFn(displayObject, location) {
18122
- if (displayObject.hitArea && (displayObject.worldTransform.applyInverse(location, tempLocalMapping), !displayObject.hitArea.contains(tempLocalMapping.x, tempLocalMapping.y)))
18121
+ hitPruneFn(displayObject, location2) {
18122
+ if (displayObject.hitArea && (displayObject.worldTransform.applyInverse(location2, tempLocalMapping), !displayObject.hitArea.contains(tempLocalMapping.x, tempLocalMapping.y)))
18123
18123
  return true;
18124
18124
  if (displayObject._mask) {
18125
18125
  const maskObject = displayObject._mask.isMaskData ? displayObject._mask.maskObject : displayObject._mask;
18126
- if (maskObject && !maskObject.containsPoint?.(location))
18126
+ if (maskObject && !maskObject.containsPoint?.(location2))
18127
18127
  return true;
18128
18128
  }
18129
18129
  return false;
@@ -18134,8 +18134,8 @@ void main()
18134
18134
  * @param location
18135
18135
  * @returns - Whether `displayObject` passes hit testing for `location`.
18136
18136
  */
18137
- hitTestFn(displayObject, location) {
18138
- return displayObject.eventMode === "passive" ? false : displayObject.hitArea ? true : displayObject.containsPoint ? displayObject.containsPoint(location) : false;
18137
+ hitTestFn(displayObject, location2) {
18138
+ return displayObject.eventMode === "passive" ? false : displayObject.hitArea ? true : displayObject.containsPoint ? displayObject.containsPoint(location2) : false;
18139
18139
  }
18140
18140
  /**
18141
18141
  * Notify all the listeners to the event's `currentTarget`.
@@ -34329,12 +34329,12 @@ void main(void)\r
34329
34329
  bufferSystem.bind(buffer);
34330
34330
  lastBuffer = glBuffer;
34331
34331
  }
34332
- var location = program.attributeData[j2].location;
34333
- gl.enableVertexAttribArray(location);
34334
- gl.vertexAttribPointer(location, attribute.size, attribute.type || gl.FLOAT, attribute.normalized, attribute.stride, attribute.start);
34332
+ var location2 = program.attributeData[j2].location;
34333
+ gl.enableVertexAttribArray(location2);
34334
+ gl.vertexAttribPointer(location2, attribute.size, attribute.type || gl.FLOAT, attribute.normalized, attribute.stride, attribute.start);
34335
34335
  if (attribute.instance) {
34336
34336
  if (this.hasInstance) {
34337
- gl.vertexAttribDivisor(location, 1);
34337
+ gl.vertexAttribDivisor(location2, 1);
34338
34338
  } else {
34339
34339
  throw new Error("geometry error, GPU Instancing is not supported on this device");
34340
34340
  }
@@ -36544,39 +36544,39 @@ void main(void)\r
36544
36544
  this.bind(null, i2);
36545
36545
  }
36546
36546
  };
36547
- TextureSystem3.prototype.bind = function(texture, location) {
36548
- if (location === void 0) {
36549
- location = 0;
36547
+ TextureSystem3.prototype.bind = function(texture, location2) {
36548
+ if (location2 === void 0) {
36549
+ location2 = 0;
36550
36550
  }
36551
36551
  var gl = this.gl;
36552
36552
  texture = texture === null || texture === void 0 ? void 0 : texture.castToBaseTexture();
36553
36553
  if (texture && texture.valid && !texture.parentTextureArray) {
36554
36554
  texture.touched = this.renderer.textureGC.count;
36555
36555
  var glTexture = texture._glTextures[this.CONTEXT_UID] || this.initTexture(texture);
36556
- if (this.boundTextures[location] !== texture) {
36557
- if (this.currentLocation !== location) {
36558
- this.currentLocation = location;
36559
- gl.activeTexture(gl.TEXTURE0 + location);
36556
+ if (this.boundTextures[location2] !== texture) {
36557
+ if (this.currentLocation !== location2) {
36558
+ this.currentLocation = location2;
36559
+ gl.activeTexture(gl.TEXTURE0 + location2);
36560
36560
  }
36561
36561
  gl.bindTexture(texture.target, glTexture.texture);
36562
36562
  }
36563
36563
  if (glTexture.dirtyId !== texture.dirtyId) {
36564
- if (this.currentLocation !== location) {
36565
- this.currentLocation = location;
36566
- gl.activeTexture(gl.TEXTURE0 + location);
36564
+ if (this.currentLocation !== location2) {
36565
+ this.currentLocation = location2;
36566
+ gl.activeTexture(gl.TEXTURE0 + location2);
36567
36567
  }
36568
36568
  this.updateTexture(texture);
36569
36569
  } else if (glTexture.dirtyStyleId !== texture.dirtyStyleId) {
36570
36570
  this.updateTextureStyle(texture);
36571
36571
  }
36572
- this.boundTextures[location] = texture;
36572
+ this.boundTextures[location2] = texture;
36573
36573
  } else {
36574
- if (this.currentLocation !== location) {
36575
- this.currentLocation = location;
36576
- gl.activeTexture(gl.TEXTURE0 + location);
36574
+ if (this.currentLocation !== location2) {
36575
+ this.currentLocation = location2;
36576
+ gl.activeTexture(gl.TEXTURE0 + location2);
36577
36577
  }
36578
36578
  gl.bindTexture(gl.TEXTURE_2D, this.emptyTextures[gl.TEXTURE_2D].texture);
36579
- this.boundTextures[location] = null;
36579
+ this.boundTextures[location2] = null;
36580
36580
  }
36581
36581
  };
36582
36582
  TextureSystem3.prototype.reset = function() {
@@ -44308,6 +44308,10 @@ void main(void)\r
44308
44308
  var mood = "\u5E73\u9759";
44309
44309
  var mouth = 0;
44310
44310
  var state = { models: [], selected: null };
44311
+ var entryNow = null;
44312
+ var exps = [];
44313
+ var expParams = /* @__PURE__ */ new Map();
44314
+ var expName = null;
44311
44315
  var base = {};
44312
44316
  var BASE_IDS = ["ParamMouthForm", "ParamMouthOpenY", "ParamBrowLY", "ParamBrowRY", "ParamBrowLAngle", "ParamBrowRAngle", "ParamEyeLSmile", "ParamEyeRSmile", "ParamEyeLOpen", "ParamEyeROpen", "ParamEyeBallX", "ParamEyeBallY", "ParamAngleX", "ParamAngleY", "ParamAngleZ"];
44313
44317
  for (const id of BASE_IDS) base[id] = 0;
@@ -44322,8 +44326,6 @@ void main(void)\r
44322
44326
  }
44323
44327
  function applyMood(name) {
44324
44328
  mood = name;
44325
- for (const id of BASE_IDS) setP(id, base[id]);
44326
- for (const [id, v2] of Object.entries(PRESETS[name] || {})) setP(id, v2);
44327
44329
  }
44328
44330
  function fit() {
44329
44331
  if (!model) return;
@@ -44355,6 +44357,11 @@ void main(void)\r
44355
44357
  model = await Live2DModel.from(url3, { autoInteract: false });
44356
44358
  app.stage.addChild(model);
44357
44359
  core = model.internalModel.coreModel;
44360
+ entryNow = entry;
44361
+ exps = entry.expressions || [];
44362
+ expParams = /* @__PURE__ */ new Map();
44363
+ expName = null;
44364
+ renderExps();
44358
44365
  fit();
44359
44366
  applyMood("\u5E73\u9759");
44360
44367
  $2("hint").classList.remove("show");
@@ -44456,13 +44463,31 @@ void main(void)\r
44456
44463
  $2("import").onclick = () => $2("files").click();
44457
44464
  $2("close").onclick = () => $2("drawer").classList.remove("show");
44458
44465
  $2("files").onchange = (e2) => importFolder(e2.target.files);
44466
+ function moodValue(id) {
44467
+ const p2 = PRESETS[mood] || {};
44468
+ if (id in p2) return p2[id];
44469
+ return base[id] ?? 0;
44470
+ }
44459
44471
  app.ticker.add(() => {
44460
44472
  if (!core) return;
44461
44473
  const t2 = app.ticker.lastTime / 1e3;
44474
+ for (const id of BASE_IDS) setP(id, base[id]);
44475
+ for (const [id, v2] of Object.entries(PRESETS[mood] || {})) setP(id, v2);
44476
+ if (expParams.size) {
44477
+ for (const [id, e2] of expParams) {
44478
+ const cur = moodValue(id);
44479
+ let v2 = e2.value;
44480
+ if (e2.blend === "Add") v2 = cur + e2.value;
44481
+ else if (e2.blend === "Multiply") v2 = cur * e2.value;
44482
+ setP(id, v2);
44483
+ }
44484
+ }
44462
44485
  setP("ParamBreath", (Math.sin(t2 * 1.1) + 1) / 2);
44463
- setP("ParamAngleX", Math.sin(t2 * 0.43) * 4);
44464
- setP("ParamAngleY", Math.sin(t2 * 0.31 + 1) * 3);
44465
- setP("ParamBodyAngleX", Math.sin(t2 * 0.29) * 2);
44486
+ if (!window.__holdHead) {
44487
+ setP("ParamAngleX", Math.sin(t2 * 0.43) * 4);
44488
+ setP("ParamAngleY", Math.sin(t2 * 0.31 + 1) * 3);
44489
+ setP("ParamBodyAngleX", Math.sin(t2 * 0.29) * 2);
44490
+ }
44466
44491
  if (mouth > 0) {
44467
44492
  mouth = Math.max(0, mouth - app.ticker.deltaMS / 1e3);
44468
44493
  setP("ParamMouthOpenY", (Math.sin(t2 * 18) * 0.5 + 0.5) * 0.26 + 0.02);
@@ -44476,6 +44501,67 @@ void main(void)\r
44476
44501
  if (state.models.length && !$2("drawer").classList.contains("show")) idleTick();
44477
44502
  }, 9e3);
44478
44503
  });
44504
+ function renderExps() {
44505
+ const box = $2("exps");
44506
+ if (!box) return;
44507
+ box.innerHTML = "";
44508
+ const none = document.createElement("button");
44509
+ none.textContent = "\u65E0";
44510
+ none.className = expName === null ? "on" : "";
44511
+ none.onclick = () => {
44512
+ expParams = /* @__PURE__ */ new Map();
44513
+ expName = null;
44514
+ renderExps();
44515
+ };
44516
+ box.appendChild(none);
44517
+ if (!exps.length) {
44518
+ const tip = document.createElement("span");
44519
+ tip.className = "tipx";
44520
+ tip.textContent = "\u8FD9\u4E2A\u6A21\u578B\u6CA1\u5E26 .exp3.json";
44521
+ box.appendChild(tip);
44522
+ return;
44523
+ }
44524
+ for (const rel of exps) {
44525
+ const b2 = document.createElement("button");
44526
+ const label = rel.split("/").pop().replace(/\.exp3\.json$/i, "");
44527
+ b2.textContent = label;
44528
+ b2.className = expName === rel ? "on" : "";
44529
+ b2.onclick = () => activateExp(rel);
44530
+ box.appendChild(b2);
44531
+ }
44532
+ }
44533
+ async function activateExp(rel) {
44534
+ if (!entryNow) return;
44535
+ expName = rel;
44536
+ expParams = /* @__PURE__ */ new Map();
44537
+ try {
44538
+ const url3 = BASE + "/model/" + encodeURIComponent(entryNow.id) + "/" + encPath(rel);
44539
+ const data = await (await fetch(url3)).json();
44540
+ for (const p2 of data.Parameters || []) {
44541
+ expParams.set(p2.Id, { value: Number(p2.Value) || 0, blend: p2.Blend || "Add" });
44542
+ }
44543
+ } catch {
44544
+ }
44545
+ renderExps();
44546
+ }
44547
+ var BG = { none: "transparent", dark: "#171a20", light: "#eef1f5", paper: "#f4efe6" };
44548
+ function applyBg(name) {
44549
+ const c2 = BG[name] || "transparent";
44550
+ document.body.style.background = c2;
44551
+ document.body.dataset.bg = name;
44552
+ const b2 = document.getElementById("bubble");
44553
+ if (b2) {
44554
+ b2.style.background = name === "light" || name === "paper" ? "rgba(255,255,255,.92)" : "rgba(24,27,33,.94)";
44555
+ b2.style.color = name === "light" || name === "paper" ? "#22262c" : "#dfe4ec";
44556
+ b2.style.borderColor = name === "light" || name === "paper" ? "#cfd5dd" : "#3a4049";
44557
+ }
44558
+ }
44559
+ var bgParam = new URLSearchParams(location.search).get("bg") || "none";
44560
+ applyBg(bgParam);
44561
+ window.addEventListener("message", (e2) => {
44562
+ const d2 = e2.data || {};
44563
+ if (d2.type === "dsh-live2d/bg") applyBg(String(d2.bg || "none"));
44564
+ });
44479
44565
  })();
44480
44566
  /*! Bundled license information:
44481
44567
 
package/lib/client.js CHANGED
@@ -6,24 +6,27 @@
6
6
  const inject = ['slots'];
7
7
 
8
8
  const BASE = '/dsh-live2d';
9
- const W = 300;
10
- const H = 430;
9
+ const BGS = ['none', 'dark', 'light', 'paper'];
10
+ const BG_LABEL = { none: '透明', dark: '深色', light: '浅色', paper: '纸质' };
11
11
 
12
12
  const CSS = `
13
- .dsh-l2d-card{position:fixed;right:16px;bottom:16px;z-index:9000;width:${W}px;
13
+ .dsh-l2d-card{position:fixed;right:16px;bottom:16px;z-index:9000;
14
14
  border-radius:14px;overflow:hidden;border:1px solid #33373f;
15
15
  background:linear-gradient(180deg,#232830 0%,#171a20 100%);
16
16
  box-shadow:0 12px 40px rgba(0,0,0,.5);font:12px/1.5 "Microsoft YaHei",system-ui,sans-serif;
17
- transition:width .18s ease,height .18s ease}
18
- .dsh-l2d-card.collapsed{width:132px;height:36px}
17
+ display:flex;flex-direction:column}
18
+ .dsh-l2d-card.collapsed{width:146px!important;height:36px!important}
19
19
  .dsh-l2d-head{display:flex;align-items:center;gap:6px;padding:8px 10px;color:#9aa3b0;font-size:11px;
20
- letter-spacing:.5px;border-bottom:1px solid #2c313a;cursor:default;user-select:none}
20
+ letter-spacing:.5px;border-bottom:1px solid #2c313a;user-select:none;flex:none}
21
21
  .dsh-l2d-dot{width:6px;height:6px;border-radius:50%;background:#6aa9ff;flex:none}
22
22
  .dsh-l2d-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
23
23
  .dsh-l2d-btns{margin-left:auto;display:flex;gap:6px;flex:none}
24
24
  .dsh-l2d-btns button{background:none;border:0;color:#7d858f;font:inherit;cursor:pointer;padding:0 2px;line-height:1}
25
25
  .dsh-l2d-btns button:hover{color:#e2e6ee}
26
- .dsh-l2d-body{display:block;width:${W}px;height:${H}px;border:0;background:transparent}
26
+ .dsh-l2d-body{display:block;width:100%;flex:1;border:0;background:transparent}
27
+ .dsh-l2d-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;
28
+ background:linear-gradient(135deg,transparent 50%,#3c424c 50%,#3c424c 62%,transparent 62%);
29
+ opacity:.9}
27
30
  `;
28
31
 
29
32
  function ensureCss() {
@@ -34,32 +37,69 @@
34
37
  document.head.appendChild(s);
35
38
  }
36
39
 
40
+ function readSize() {
41
+ try {
42
+ const s = JSON.parse(localStorage.getItem('dsh-l2d-size') || '{}');
43
+ return { w: Number(s.w) || 300, h: Number(s.h) || 430 };
44
+ } catch {
45
+ return { w: 300, h: 430 };
46
+ }
47
+ }
48
+
37
49
  function Panel() {
38
50
  const [open, setOpen] = react.useState(() => localStorage.getItem('dsh-l2d-open') !== '0');
39
51
  const [title, setTitle] = react.useState('Live2D');
52
+ const [bg, setBg] = react.useState(() => localStorage.getItem('dsh-l2d-bg') || 'none');
53
+ const [size, setSize] = react.useState(readSize);
40
54
  const [nonce, setNonce] = react.useState(0);
41
55
  const ref = react.useRef(null);
42
56
 
57
+ react.useEffect(() => localStorage.setItem('dsh-l2d-open', open ? '1' : '0'), [open]);
58
+ react.useEffect(() => localStorage.setItem('dsh-l2d-bg', bg), [bg]);
59
+ react.useEffect(() => localStorage.setItem('dsh-l2d-size', JSON.stringify(size)), [size]);
43
60
  react.useEffect(() => {
44
- localStorage.setItem('dsh-l2d-open', open ? '1' : '0');
45
- }, [open]);
61
+ if (ref.current && ref.current.contentWindow) {
62
+ ref.current.contentWindow.postMessage({ type: 'dsh-live2d/bg', bg }, '*');
63
+ }
64
+ }, [bg, nonce]);
46
65
 
47
66
  react.useEffect(() => {
48
67
  fetch(BASE + '/api/models')
49
68
  .then((r) => r.json())
50
69
  .then((d) => {
51
70
  const m = (d.models || []).find((x) => x.id === d.selected) || (d.models || [])[0];
52
- if (m) setTitle(m.name || m.id);
53
- else setTitle('还没有模型');
71
+ setTitle(m ? m.name || m.id : '还没有模型');
54
72
  })
55
73
  .catch(() => setTitle('Live2D'));
56
74
  }, [nonce]);
57
75
 
76
+ const startDrag = (e) => {
77
+ e.preventDefault();
78
+ const sx = e.clientX;
79
+ const sy = e.clientY;
80
+ const w0 = size.w;
81
+ const h0 = size.h;
82
+ const move = (ev) => {
83
+ const w = Math.max(200, Math.min(720, w0 + (sx - ev.clientX)));
84
+ const hh = Math.max(220, Math.min(window.innerHeight - 80, h0 + (sy - ev.clientY)));
85
+ setSize({ w: Math.round(w), h: Math.round(hh) });
86
+ };
87
+ const up = () => {
88
+ window.removeEventListener('pointermove', move);
89
+ window.removeEventListener('pointerup', up);
90
+ };
91
+ window.addEventListener('pointermove', move);
92
+ window.addEventListener('pointerup', up);
93
+ };
94
+
58
95
  const manage = () => ref.current && ref.current.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
96
+ const cycleBg = () => setBg(BGS[(BGS.indexOf(bg) + 1) % BGS.length]);
97
+
98
+ const cardStyle = open ? { width: size.w + 'px', height: size.h + 'px' } : {};
59
99
 
60
100
  return h(
61
101
  'div',
62
- { className: 'dsh-l2d-card' + (open ? '' : ' collapsed') },
102
+ { className: 'dsh-l2d-card' + (open ? '' : ' collapsed'), style: cardStyle },
63
103
  h(
64
104
  'div',
65
105
  { className: 'dsh-l2d-head' },
@@ -69,6 +109,7 @@
69
109
  'span',
70
110
  { className: 'dsh-l2d-btns' },
71
111
  open && h('button', { title: '模型管理', onClick: manage }, '⚙'),
112
+ open && h('button', { title: '背景:' + BG_LABEL[bg], onClick: cycleBg }, '◐'),
72
113
  open && h('button', { title: '刷新', onClick: () => setNonce((n) => n + 1) }, '↻'),
73
114
  h('button', { title: open ? '收起' : '展开', onClick: () => setOpen(!open) }, open ? '⌄' : '⌃'),
74
115
  ),
@@ -78,10 +119,11 @@
78
119
  key: nonce,
79
120
  ref,
80
121
  className: 'dsh-l2d-body',
81
- src: BASE + '/viewer?embed=1',
122
+ src: BASE + '/viewer?embed=1&bg=' + encodeURIComponent(bg),
82
123
  allow: 'autoplay',
83
124
  })
84
125
  : null,
126
+ open ? h('div', { className: 'dsh-l2d-grip', onPointerDown: startDrag, title: '拖动改大小' }) : null,
85
127
  );
86
128
  }
87
129
 
package/lib/index.js CHANGED
@@ -1,219 +1,240 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, createReadStream } from 'node:fs';
2
- import { join, resolve, normalize, relative, extname, sep } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- const HERE = fileURLToPath(new URL('.', import.meta.url));
7
- const ASSETS = resolve(HERE, '..', 'assets');
8
- const ROOT = join(homedir(), '.dsh', 'dsh-live2d');
9
- const MODELS = join(ROOT, 'models');
10
- const CONFIG = join(ROOT, 'config.json');
11
- const PREFIX = '/dsh-live2d';
12
-
13
- const MIME = {
14
- '.html': 'text/html; charset=utf-8',
15
- '.js': 'text/javascript; charset=utf-8',
16
- '.json': 'application/json; charset=utf-8',
17
- '.png': 'image/png',
18
- '.jpg': 'image/jpeg',
19
- '.jpeg': 'image/jpeg',
20
- '.webp': 'image/webp',
21
- '.wasm': 'application/wasm',
22
- '.moc3': 'application/octet-stream',
23
- };
24
-
25
- export const name = 'dsh-live2d';
26
- export const inject = ['webServer'];
27
-
28
- function ensureDirs() {
29
- mkdirSync(MODELS, { recursive: true });
30
- }
31
-
32
- function readConfig() {
33
- try {
34
- return JSON.parse(readFileSync(CONFIG, 'utf8'));
35
- } catch {
36
- return {};
37
- }
38
- }
39
-
40
- function writeConfig(cfg) {
41
- ensureDirs();
42
- writeFileSync(CONFIG, JSON.stringify(cfg, null, 2), 'utf8');
43
- }
44
-
45
- /** 目录里找 model3.json(最多两层) */
46
- function findModel3(dir) {
47
- const out = [];
48
- const walk = (d, depth) => {
49
- if (depth > 2) return;
50
- let entries = [];
51
- try {
52
- entries = readdirSync(d, { withFileTypes: true });
53
- } catch {
54
- return;
55
- }
56
- for (const e of entries) {
57
- const p = join(d, e.name);
58
- if (e.isDirectory()) walk(p, depth + 1);
59
- else if (e.name.toLowerCase().endsWith('.model3.json')) out.push(relative(dir, p));
60
- }
61
- };
62
- walk(dir, 0);
63
- return out[0];
64
- }
65
-
66
- function readMeta(id) {
67
- try {
68
- return JSON.parse(readFileSync(join(MODELS, id, 'meta.json'), 'utf8'));
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- function listModels() {
75
- ensureDirs();
76
- const list = [];
77
- for (const e of readdirSync(MODELS, { withFileTypes: true })) {
78
- if (!e.isDirectory()) continue;
79
- const dir = join(MODELS, e.name);
80
- const meta = readMeta(e.name);
81
- const model3 = meta?.model3 ?? findModel3(dir);
82
- if (!model3) continue;
83
- list.push({ id: e.name, name: meta?.name ?? e.name, model3 });
84
- }
85
- return list;
86
- }
87
-
88
- function safeJoin(base, rel) {
89
- const clean = normalize(rel).replace(/^([/\\]|\.\.[/\\])+/, '');
90
- const full = resolve(base, clean);
91
- if (!full.startsWith(resolve(base) + sep)) return null;
92
- return full;
93
- }
94
-
95
- function send(res, status, body, type) {
96
- res.writeHead(status, { 'content-type': type || 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
97
- res.end(body);
98
- }
99
-
100
- function sendJson(res, status, obj) {
101
- send(res, status, JSON.stringify(obj), 'application/json; charset=utf-8');
102
- }
103
-
104
- function sendFile(res, file, type) {
105
- if (!existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found');
106
- res.writeHead(200, {
107
- 'content-type': type || MIME[extname(file).toLowerCase()] || 'application/octet-stream',
108
- 'cache-control': 'no-store',
109
- });
110
- createReadStream(file).pipe(res);
111
- }
112
-
113
- async function readBody(req, limit = 512 * 1024 * 1024) {
114
- const chunks = [];
115
- let size = 0;
116
- for await (const c of req) {
117
- size += c.length;
118
- if (size > limit) throw new Error('too large');
119
- chunks.push(c);
120
- }
121
- return Buffer.concat(chunks);
122
- }
123
-
124
- async function handle(req, res) {
125
- const url = new URL(req.url ?? '/', 'http://localhost');
126
- const path = url.pathname.replace(PREFIX, '') || '/';
127
- const method = (req.method ?? 'GET').toUpperCase();
128
-
129
- // 观看页
130
- if (path === '/' || path === '/viewer' || path === '/viewer.html') {
131
- return sendFile(res, join(ASSETS, 'viewer.html'), MIME['.html']);
132
- }
133
-
134
- // 内置静态资源
135
- if (path.startsWith('/assets/')) {
136
- const file = safeJoin(ASSETS, path.slice('/assets/'.length));
137
- return file ? sendFile(res, file) : send(res, 403, 'forbidden');
138
- }
139
-
140
- // 模型文件
141
- if (path.startsWith('/model/')) {
142
- const rest = path.slice('/model/'.length);
143
- const slash = rest.indexOf('/');
144
- if (slash < 0) return send(res, 404, 'not found');
145
- const id = decodeURIComponent(rest.slice(0, slash));
146
- const file = safeJoin(join(MODELS, id), decodeURIComponent(rest.slice(slash + 1)));
147
- return file ? sendFile(res, file) : send(res, 403, 'forbidden');
148
- }
149
-
150
- // API
151
- if (path === '/api/models' && method === 'GET') {
152
- const cfg = readConfig();
153
- return sendJson(res, 200, { models: listModels(), selected: cfg.selected ?? null });
154
- }
155
-
156
- if (path === '/api/select' && method === 'POST') {
157
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
158
- writeConfig({ ...readConfig(), selected: body.id ?? null });
159
- return sendJson(res, 200, { ok: true });
160
- }
161
-
162
- if (path === '/api/import' && method === 'POST') {
163
- const id = String(url.searchParams.get('model') ?? '').replace(/[^\w.-]/g, '_');
164
- const rel = url.searchParams.get('path') ?? '';
165
- if (!id || !rel) return sendJson(res, 400, { error: 'model/path required' });
166
- const dest = safeJoin(join(MODELS, id), rel);
167
- if (!dest) return sendJson(res, 403, { error: 'bad path' });
168
- mkdirSync(resolve(dest, '..'), { recursive: true });
169
- writeFileSync(dest, await readBody(req));
170
- return sendJson(res, 200, { ok: true });
171
- }
172
-
173
- if (path === '/api/commit' && method === 'POST') {
174
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
175
- const id = String(body.id ?? '');
176
- const dir = join(MODELS, id);
177
- const model3 = findModel3(dir);
178
- if (!model3) return sendJson(res, 400, { error: '没有找到 .model3.json' });
179
- const meta = { name: body.name || id, model3 };
180
- writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
181
- writeConfig({ ...readConfig(), selected: id });
182
- return sendJson(res, 200, { ok: true, id, model3 });
183
- }
184
-
185
- if (path === '/api/delete' && method === 'POST') {
186
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
187
- const id = String(body.id ?? '');
188
- const dir = safeJoin(MODELS, id);
189
- if (dir && existsSync(dir)) {
190
- const { rmSync } = await import('node:fs');
191
- rmSync(dir, { recursive: true, force: true });
192
- const cfg = readConfig();
193
- if (cfg.selected === id) writeConfig({ ...cfg, selected: listModels()[0]?.id ?? null });
194
- }
195
- return sendJson(res, 200, { ok: true });
196
- }
197
-
198
- return send(res, 404, 'not found');
199
- }
200
-
201
- export function apply(ctx) {
202
- ensureDirs();
203
- ctx.effect(
204
- () =>
205
- ctx.webServer.register({
206
- kind: 'prefix',
207
- path: PREFIX,
208
- handler: async (req, res) => {
209
- try {
210
- await handle(req, res);
211
- } catch (e) {
212
- if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
213
- else res.end();
214
- }
215
- },
216
- }),
217
- 'dsh-live2d: /dsh-live2d 路由',
218
- );
219
- }
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, createReadStream } from 'node:fs';
2
+ import { join, resolve, normalize, relative, extname, sep } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
7
+ const ASSETS = resolve(HERE, '..', 'assets');
8
+ const ROOT = join(homedir(), '.dsh', 'dsh-live2d');
9
+ const MODELS = join(ROOT, 'models');
10
+ const CONFIG = join(ROOT, 'config.json');
11
+ const PREFIX = '/dsh-live2d';
12
+
13
+ const MIME = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.json': 'application/json; charset=utf-8',
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.webp': 'image/webp',
21
+ '.wasm': 'application/wasm',
22
+ '.moc3': 'application/octet-stream',
23
+ };
24
+
25
+ export const name = 'dsh-live2d';
26
+ export const inject = ['webServer'];
27
+
28
+ function ensureDirs() {
29
+ mkdirSync(MODELS, { recursive: true });
30
+ }
31
+
32
+ function readConfig() {
33
+ try {
34
+ return JSON.parse(readFileSync(CONFIG, 'utf8'));
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ function writeConfig(cfg) {
41
+ ensureDirs();
42
+ writeFileSync(CONFIG, JSON.stringify(cfg, null, 2), 'utf8');
43
+ }
44
+
45
+ /** 目录里找指定后缀的文件(最多两层),返回相对路径 */
46
+ function findFiles(dir, suffix) {
47
+ const out = [];
48
+ const walk = (d, depth) => {
49
+ if (depth > 2) return;
50
+ let entries = [];
51
+ try {
52
+ entries = readdirSync(d, { withFileTypes: true });
53
+ } catch {
54
+ return;
55
+ }
56
+ for (const e of entries) {
57
+ const p = join(d, e.name);
58
+ if (e.isDirectory()) walk(p, depth + 1);
59
+ else if (e.name.toLowerCase().endsWith(suffix)) out.push(relative(dir, p));
60
+ }
61
+ };
62
+ walk(dir, 0);
63
+ return out.sort();
64
+ }
65
+
66
+ /** 目录里找 model3.json(最多两层) */
67
+ function findModel3(dir) {
68
+ const out = [];
69
+ const walk = (d, depth) => {
70
+ if (depth > 2) return;
71
+ let entries = [];
72
+ try {
73
+ entries = readdirSync(d, { withFileTypes: true });
74
+ } catch {
75
+ return;
76
+ }
77
+ for (const e of entries) {
78
+ const p = join(d, e.name);
79
+ if (e.isDirectory()) walk(p, depth + 1);
80
+ else if (e.name.toLowerCase().endsWith('.model3.json')) out.push(relative(dir, p));
81
+ }
82
+ };
83
+ walk(dir, 0);
84
+ return out[0];
85
+ }
86
+
87
+ function readMeta(id) {
88
+ try {
89
+ return JSON.parse(readFileSync(join(MODELS, id, 'meta.json'), 'utf8'));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function listModels() {
96
+ ensureDirs();
97
+ const list = [];
98
+ for (const e of readdirSync(MODELS, { withFileTypes: true })) {
99
+ if (!e.isDirectory()) continue;
100
+ const dir = join(MODELS, e.name);
101
+ const meta = readMeta(e.name);
102
+ const model3 = meta?.model3 ?? findModel3(dir);
103
+ if (!model3) continue;
104
+ list.push({ id: e.name, name: meta?.name ?? e.name, model3, expressions: meta?.expressions ?? [] });
105
+ }
106
+ return list;
107
+ }
108
+
109
+ function safeJoin(base, rel) {
110
+ const clean = normalize(rel).replace(/^([/\\]|\.\.[/\\])+/, '');
111
+ const full = resolve(base, clean);
112
+ if (!full.startsWith(resolve(base) + sep)) return null;
113
+ return full;
114
+ }
115
+
116
+ function send(res, status, body, type) {
117
+ res.writeHead(status, { 'content-type': type || 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
118
+ res.end(body);
119
+ }
120
+
121
+ function sendJson(res, status, obj) {
122
+ send(res, status, JSON.stringify(obj), 'application/json; charset=utf-8');
123
+ }
124
+
125
+ function sendFile(res, file, type) {
126
+ if (!existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found');
127
+ res.writeHead(200, {
128
+ 'content-type': type || MIME[extname(file).toLowerCase()] || 'application/octet-stream',
129
+ 'cache-control': 'no-store',
130
+ });
131
+ createReadStream(file).pipe(res);
132
+ }
133
+
134
+ async function readBody(req, limit = 512 * 1024 * 1024) {
135
+ const chunks = [];
136
+ let size = 0;
137
+ for await (const c of req) {
138
+ size += c.length;
139
+ if (size > limit) throw new Error('too large');
140
+ chunks.push(c);
141
+ }
142
+ return Buffer.concat(chunks);
143
+ }
144
+
145
+ async function handle(req, res) {
146
+ const url = new URL(req.url ?? '/', 'http://localhost');
147
+ const path = url.pathname.replace(PREFIX, '') || '/';
148
+ const method = (req.method ?? 'GET').toUpperCase();
149
+
150
+ // 观看页
151
+ if (path === '/' || path === '/viewer' || path === '/viewer.html') {
152
+ return sendFile(res, join(ASSETS, 'viewer.html'), MIME['.html']);
153
+ }
154
+
155
+ // 内置静态资源
156
+ if (path.startsWith('/assets/')) {
157
+ const file = safeJoin(ASSETS, path.slice('/assets/'.length));
158
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
159
+ }
160
+
161
+ // 模型文件
162
+ if (path.startsWith('/model/')) {
163
+ const rest = path.slice('/model/'.length);
164
+ const slash = rest.indexOf('/');
165
+ if (slash < 0) return send(res, 404, 'not found');
166
+ const id = decodeURIComponent(rest.slice(0, slash));
167
+ const file = safeJoin(join(MODELS, id), decodeURIComponent(rest.slice(slash + 1)));
168
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
169
+ }
170
+
171
+ // API
172
+ if (path === '/api/models' && method === 'GET') {
173
+ const cfg = readConfig();
174
+ return sendJson(res, 200, { models: listModels(), selected: cfg.selected ?? null });
175
+ }
176
+
177
+ if (path === '/api/select' && method === 'POST') {
178
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
179
+ writeConfig({ ...readConfig(), selected: body.id ?? null });
180
+ return sendJson(res, 200, { ok: true });
181
+ }
182
+
183
+ if (path === '/api/import' && method === 'POST') {
184
+ const id = String(url.searchParams.get('model') ?? '').replace(/[^\w.-]/g, '_');
185
+ const rel = url.searchParams.get('path') ?? '';
186
+ if (!id || !rel) return sendJson(res, 400, { error: 'model/path required' });
187
+ const dest = safeJoin(join(MODELS, id), rel);
188
+ if (!dest) return sendJson(res, 403, { error: 'bad path' });
189
+ mkdirSync(resolve(dest, '..'), { recursive: true });
190
+ writeFileSync(dest, await readBody(req));
191
+ return sendJson(res, 200, { ok: true });
192
+ }
193
+
194
+ if (path === '/api/commit' && method === 'POST') {
195
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
196
+ const id = String(body.id ?? '');
197
+ const dir = join(MODELS, id);
198
+ const model3 = findModel3(dir);
199
+ if (!model3) return sendJson(res, 400, { error: '没有找到 .model3.json' });
200
+ const meta = { name: body.name || id, model3, expressions: findFiles(dir, '.exp3.json') };
201
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
202
+ writeConfig({ ...readConfig(), selected: id });
203
+ return sendJson(res, 200, { ok: true, id, model3 });
204
+ }
205
+
206
+ if (path === '/api/delete' && method === 'POST') {
207
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
208
+ const id = String(body.id ?? '');
209
+ const dir = safeJoin(MODELS, id);
210
+ if (dir && existsSync(dir)) {
211
+ const { rmSync } = await import('node:fs');
212
+ rmSync(dir, { recursive: true, force: true });
213
+ const cfg = readConfig();
214
+ if (cfg.selected === id) writeConfig({ ...cfg, selected: listModels()[0]?.id ?? null });
215
+ }
216
+ return sendJson(res, 200, { ok: true });
217
+ }
218
+
219
+ return send(res, 404, 'not found');
220
+ }
221
+
222
+ export function apply(ctx) {
223
+ ensureDirs();
224
+ ctx.effect(
225
+ () =>
226
+ ctx.webServer.register({
227
+ kind: 'prefix',
228
+ path: PREFIX,
229
+ handler: async (req, res) => {
230
+ try {
231
+ await handle(req, res);
232
+ } catch (e) {
233
+ if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
234
+ else res.end();
235
+ }
236
+ },
237
+ }),
238
+ 'dsh-live2d: /dsh-live2d 路由',
239
+ );
240
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-live2dwaifu",
3
- "version": "0.1.2",
4
- "description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹,文件留在这台机器上。",
3
+ "version": "0.1.3",
4
+ "description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情、窗口大小可拖、背景可换。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {