dsh-pet-live2d 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +56 -0
- package/README.md +425 -0
- package/cordis.patch.yml +8 -0
- package/docs/README.md +14 -0
- package/docs/downscale-fix.png +0 -0
- package/docs/preview.png +0 -0
- package/lib/client.js +3525 -0
- package/lib/index.js +1027 -0
- package/lib/live2d-vendor.js +997 -0
- package/package.json +61 -0
- package/pets/ds-whale-girl/LICENSE +37 -0
- package/pets/ds-whale-girl/README.md +67 -0
- package/pets/ds-whale-girl/c_0120.model3.json +249 -0
- package/pets/ds-whale-girl/catalog.json +310 -0
- package/pets/ds-whale-girl/expressions/angry.exp3.json +55 -0
- package/pets/ds-whale-girl/expressions/blank-eyes.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/cat-paws.exp3.json +20 -0
- package/pets/ds-whale-girl/expressions/claw-recolor.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/claw.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/cry.exp3.json +60 -0
- package/pets/ds-whale-girl/expressions/dark-tablecloth.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/dizzy.exp3.json +40 -0
- package/pets/ds-whale-girl/expressions/draw-brush.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/draw-eraser.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/draw-undo.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/drool.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/excited.exp3.json +65 -0
- package/pets/ds-whale-girl/expressions/exclaim.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/facial-red.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/glasses-oval.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/glasses-round.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/glasses-square.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/gloomy.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/hands-peace.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/headband.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/heart-pop.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/heartbeat.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/ketchup-squeeze.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/love-eyes.exp3.json +60 -0
- package/pets/ds-whale-girl/expressions/menu-press.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/naughty.exp3.json +20 -0
- package/pets/ds-whale-girl/expressions/omurice.exp3.json +16 -0
- package/pets/ds-whale-girl/expressions/parfait.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/phone-recolor.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/ponytail.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/question.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sad.exp3.json +35 -0
- package/pets/ds-whale-girl/expressions/soul-out.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sparkle.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/star-eyes.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sticker-bow.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sticker-cat.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sticker-rabbit.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sunglasses.exp3.json +20 -0
- package/pets/ds-whale-girl/expressions/sweat.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/tongue-out.exp3.json +26 -0
- package/pets/ds-whale-girl/expressions/whale-on-desk.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/whale.exp3.json +11 -0
- package/pets/ds-whale-girl/model/c_0120.cdi3.json +1802 -0
- package/pets/ds-whale-girl/model/c_0120.moc3 +0 -0
- package/pets/ds-whale-girl/model/c_0120.physics3.json +2396 -0
- package/pets/ds-whale-girl/motions/bubble-gum.motion3.json +369 -0
- package/pets/ds-whale-girl/motions/hammer.motion3.json +1120 -0
- package/pets/ds-whale-girl/motions/idle.motion3.json +4746 -0
- package/pets/ds-whale-girl/motions/ketchup.motion3.json +768 -0
- package/pets/ds-whale-girl/motions/open-case.motion3.json +116 -0
- package/pets/ds-whale-girl/motions/selfie-quick.motion3.json +47 -0
- package/pets/ds-whale-girl/motions/selfie.motion3.json +671 -0
- package/pets/ds-whale-girl/motions/spray-water.motion3.json +31 -0
- package/pets/ds-whale-girl/pet.json +599 -0
- package/pets/ds-whale-girl/previews/idle.png +0 -0
- package/pets/ds-whale-girl/textures/texture_00.png +0 -0
- package/pets/ds-whale-girl/textures/texture_01.png +0 -0
- package/pets/ds-whale-girl/voice.json +46 -0
- package/pets/ds-whale-girl//345/256/211/350/243/205/350/257/264/346/230/216.md +30 -0
- package/src/vendor-entry.ts +28 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-live2d-pet — host half.
|
|
3
|
+
*
|
|
4
|
+
* A self-contained Live2D desk-pet plugin for the DSH Web GUI. It is
|
|
5
|
+
* deliberately independent of any other pet plugin: it discovers Live2D pet
|
|
6
|
+
* directories itself, serves their model reference closure, serves the Live2D
|
|
7
|
+
* runtime files, and answers one catalog endpoint the browser half renders.
|
|
8
|
+
*
|
|
9
|
+
* Discovery follows the DSH pet convention: every directory under
|
|
10
|
+
* '$DSH_HOME/pets/<id>/' holding a pet.json whose 'renderer' is 'live2d'.
|
|
11
|
+
* The model's declared reference closure (moc3, textures, motions, physics,
|
|
12
|
+
* expressions) is the allow-list the asset route serves — a crafted '..'
|
|
13
|
+
* segment can never match, and realpath containment is the second layer.
|
|
14
|
+
*
|
|
15
|
+
* The proprietary Cubism Core runtime is NEVER bundled or downloaded by this
|
|
16
|
+
* plugin: it is read from the user-supplied
|
|
17
|
+
* '$DSH_HOME/pets/.runtime/live2dcubismcore.min.js'.
|
|
18
|
+
*
|
|
19
|
+
* buildRoutes() is exported so the same route table can be mounted by the
|
|
20
|
+
* DSH web server (apply) and by tests.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { readFile } from 'node:fs/promises'
|
|
25
|
+
import { homedir } from 'node:os'
|
|
26
|
+
import { dirname, extname, join, sep } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
export const name = 'live2d-pet'
|
|
30
|
+
|
|
31
|
+
export const inject = ['webServer']
|
|
32
|
+
|
|
33
|
+
/** Browser-facing API base. */
|
|
34
|
+
export const API = '/api/live2d-pet'
|
|
35
|
+
|
|
36
|
+
/** Size ceilings per served file class, in bytes. */
|
|
37
|
+
const CAP_JSON = 64 * 1024
|
|
38
|
+
const CAP_IMAGE = 24 * 1024 * 1024
|
|
39
|
+
const CAP_MODEL = 48 * 1024 * 1024
|
|
40
|
+
const CAP_RUNTIME = 24 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
const MIME = {
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.webp': 'image/webp',
|
|
45
|
+
'.jpg': 'image/jpeg',
|
|
46
|
+
'.jpeg': 'image/jpeg',
|
|
47
|
+
'.gif': 'image/gif',
|
|
48
|
+
'.json': 'application/json; charset=utf-8',
|
|
49
|
+
'.moc3': 'application/octet-stream',
|
|
50
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** $DSH_HOME (defaults to ~/.dsh). */
|
|
54
|
+
export function dshHome() {
|
|
55
|
+
const raw = process.env.DSH_HOME
|
|
56
|
+
return raw !== undefined && raw.trim() !== '' ? raw.trim() : join(homedir(), '.dsh')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Directory holding every installed pet. */
|
|
60
|
+
export function petsRoot() {
|
|
61
|
+
return join(dshHome(), 'pets')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Directory holding the user-supplied Live2D runtime files. */
|
|
65
|
+
export function runtimeDir() {
|
|
66
|
+
return join(petsRoot(), '.runtime')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Package root of this plugin (lib/ -> package root), resolved once. */
|
|
70
|
+
let packageRoot
|
|
71
|
+
export function pluginRoot() {
|
|
72
|
+
packageRoot ??= fileURLToPath(new URL('..', import.meta.url))
|
|
73
|
+
return packageRoot
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Plain JSON read that never throws. */
|
|
77
|
+
function readJson(file) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Safe relative path: no absolute paths, no backslashes, no traversal, plain segments. */
|
|
86
|
+
export function safeRel(raw) {
|
|
87
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
88
|
+
const value = raw.trim()
|
|
89
|
+
if (value.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(value) || /^[\\/]/.test(value)) return undefined
|
|
90
|
+
const segments = value.split('/').filter((segment) => segment !== '')
|
|
91
|
+
if (segments.length === 0) return undefined
|
|
92
|
+
if (segments.some((segment) => segment === '.' || segment === '..' || !/^[A-Za-z0-9._-]+$/.test(segment))) return undefined
|
|
93
|
+
return segments.join('/')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The reference closure of one Cubism model3.json — exactly the files the
|
|
98
|
+
* asset route may serve for the pet that declares it.
|
|
99
|
+
*/
|
|
100
|
+
export function modelClosure(model3) {
|
|
101
|
+
const out = new Set()
|
|
102
|
+
if (typeof model3 !== 'object' || model3 === null) return out
|
|
103
|
+
const refs = model3.FileReferences
|
|
104
|
+
if (typeof refs !== 'object' || refs === null) return out
|
|
105
|
+
const push = (raw) => {
|
|
106
|
+
const safe = safeRel(raw)
|
|
107
|
+
if (safe !== undefined) out.add(safe)
|
|
108
|
+
}
|
|
109
|
+
if (refs.Moc !== undefined) push(refs.Moc)
|
|
110
|
+
if (Array.isArray(refs.Textures)) refs.Textures.forEach(push)
|
|
111
|
+
for (const key of ['Physics', 'Pose', 'DisplayInfo', 'UserData']) if (refs[key] !== undefined) push(refs[key])
|
|
112
|
+
if (Array.isArray(refs.Expressions)) {
|
|
113
|
+
for (const expression of refs.Expressions) {
|
|
114
|
+
if (typeof expression === 'object' && expression !== null) push(expression.File)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (typeof refs.Motions === 'object' && refs.Motions !== null) {
|
|
118
|
+
for (const motions of Object.values(refs.Motions)) {
|
|
119
|
+
if (!Array.isArray(motions)) continue
|
|
120
|
+
for (const motion of motions) {
|
|
121
|
+
if (typeof motion === 'object' && motion !== null) push(motion.File)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate a pet's declared dress-up slots.
|
|
130
|
+
*
|
|
131
|
+
* Anything malformed is dropped rather than passed on: the browser half renders
|
|
132
|
+
* these straight into buttons, so a slot without an id, or an option without an
|
|
133
|
+
* expression, would produce a dead control. An empty result simply means the pet
|
|
134
|
+
* has no dress-up panel, which is the right outcome for a model that needs none.
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* Validate a procedural sweep.
|
|
138
|
+
*
|
|
139
|
+
* A sweep drives model parameters from a generated curve instead of from a
|
|
140
|
+
* baked motion. Only the channels the pet actually names are kept, so a typo in
|
|
141
|
+
* one axis cannot silently produce a stationary hand.
|
|
142
|
+
*/
|
|
143
|
+
function normaliseSweep(raw) {
|
|
144
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
145
|
+
const axes = {}
|
|
146
|
+
for (const key of ['x', 'y', 'z', 'rz']) {
|
|
147
|
+
if (typeof raw[key] === 'string' && raw[key].trim() !== '') axes[key] = raw[key].trim()
|
|
148
|
+
}
|
|
149
|
+
if (Object.keys(axes).length === 0) return null
|
|
150
|
+
const num = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
|
|
151
|
+
return {
|
|
152
|
+
...axes,
|
|
153
|
+
ampX: num(raw.ampX, 16),
|
|
154
|
+
ampY: num(raw.ampY, 8),
|
|
155
|
+
ampZ: num(raw.ampZ, 6),
|
|
156
|
+
// One full trace of the loop, in milliseconds. The pen never lifts: the
|
|
157
|
+
// path closes on itself, which is what makes it read as flowing rather
|
|
158
|
+
// than as a series of strokes being snapped back to the margin.
|
|
159
|
+
loopMs: num(raw.loopMs, 2600),
|
|
160
|
+
// Optional slow drift, so a long-running loop does not look pinned to one
|
|
161
|
+
// spot. 0 keeps it a pure closed curve.
|
|
162
|
+
driftMs: num(raw.driftMs, 0),
|
|
163
|
+
driftY: num(raw.driftY, 0),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normaliseSlots(raw) {
|
|
168
|
+
if (!Array.isArray(raw)) return []
|
|
169
|
+
const out = []
|
|
170
|
+
for (const slot of raw) {
|
|
171
|
+
if (typeof slot !== 'object' || slot === null) continue
|
|
172
|
+
const id = typeof slot.id === 'string' ? slot.id.trim() : ''
|
|
173
|
+
if (id === '') continue
|
|
174
|
+
const options = []
|
|
175
|
+
for (const option of Array.isArray(slot.options) ? slot.options : []) {
|
|
176
|
+
if (typeof option !== 'object' || option === null) continue
|
|
177
|
+
// An option may need SEVERAL expressions: this model's 白魔爪 is
|
|
178
|
+
// 魔爪换色 layered on 桌面粉魔爪, and the recolour alone renders nothing
|
|
179
|
+
// because there is no claw to recolour. Accept either shape so a pet can
|
|
180
|
+
// write the common single case without an array.
|
|
181
|
+
const raw = Array.isArray(option.expressions)
|
|
182
|
+
? option.expressions
|
|
183
|
+
: (typeof option.expression === 'string' ? [option.expression] : [])
|
|
184
|
+
const expressions = []
|
|
185
|
+
for (const name of raw) {
|
|
186
|
+
if (typeof name !== 'string') continue
|
|
187
|
+
const trimmed = name.trim()
|
|
188
|
+
if (trimmed !== '' && !expressions.includes(trimmed)) expressions.push(trimmed)
|
|
189
|
+
}
|
|
190
|
+
// An option may also drive a MOTION, because some of this model's actions
|
|
191
|
+
// sit at the same level as an expression: 吹泡泡糖 belongs with the mouth,
|
|
192
|
+
// 掏出手机 and 挤番茄酱 with the hand. `requires` lists expressions that must
|
|
193
|
+
// be on for the action to make sense (挤番茄酱 needs 蛋包饭 first).
|
|
194
|
+
const motion = typeof option.motion === 'string' && option.motion.trim() !== ''
|
|
195
|
+
? option.motion.trim()
|
|
196
|
+
: null
|
|
197
|
+
const requires = []
|
|
198
|
+
for (const name of Array.isArray(option.requires) ? option.requires : []) {
|
|
199
|
+
if (typeof name === 'string' && name.trim() !== '' && !requires.includes(name.trim())) {
|
|
200
|
+
requires.push(name.trim())
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Slots this option must RESET. 写本本 is a right-hand action that needs the
|
|
204
|
+
// left hand empty first, and "empty" is another slot's choice, so the
|
|
205
|
+
// option has to be able to clear it.
|
|
206
|
+
const clears = []
|
|
207
|
+
for (const id of Array.isArray(option.clears) ? option.clears : []) {
|
|
208
|
+
if (typeof id === 'string' && id.trim() !== '' && !clears.includes(id.trim())) clears.push(id.trim())
|
|
209
|
+
}
|
|
210
|
+
// A SWEEP is an animation this plugin generates, for parameters the model
|
|
211
|
+
// left unanimated. This model ships 点菜手X/Y/Z (pointX/pointY/pointY2) with
|
|
212
|
+
// a ±30 range and NOTHING driving them — the author meant the hand to
|
|
213
|
+
// follow the mouse and never wired it up. Writing a curve per frame turns
|
|
214
|
+
// them into a hand that actually writes on the tablet.
|
|
215
|
+
// Options that cannot be worn together, named by their LABEL. 吐魂 and
|
|
216
|
+
// 吹泡泡糖 write different parameters, so nothing in the engine stops them
|
|
217
|
+
// overlapping — but the soul leaving the mouth on top of a bubble-gum
|
|
218
|
+
// pucker is one mouth doing two things.
|
|
219
|
+
const conflicts = []
|
|
220
|
+
for (const label of Array.isArray(option.conflicts) ? option.conflicts : []) {
|
|
221
|
+
if (typeof label === 'string' && label.trim() !== '' && !conflicts.includes(label.trim())) {
|
|
222
|
+
conflicts.push(label.trim())
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Pairs and breaks, for options that travel with (or rule out) another
|
|
226
|
+
// slot's choice: 喵喵手 only makes sense with the cat sticker on, and any
|
|
227
|
+
// other hand pose means the sticker should come off.
|
|
228
|
+
const pairs = {}
|
|
229
|
+
if (typeof option.pairs === 'object' && option.pairs !== null) {
|
|
230
|
+
for (const [slotId, label] of Object.entries(option.pairs)) {
|
|
231
|
+
if (typeof slotId === 'string' && typeof label === 'string' && label !== '') pairs[slotId] = label
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const breaks = []
|
|
235
|
+
for (const label of Array.isArray(option.breaks) ? option.breaks : []) {
|
|
236
|
+
if (typeof label === 'string' && label.trim() !== '' && !breaks.includes(label.trim())) breaks.push(label.trim())
|
|
237
|
+
}
|
|
238
|
+
const sweep = normaliseSweep(option.sweep)
|
|
239
|
+
// Fidget weighting. Most of the time the mouth should simply be normal,
|
|
240
|
+
// and a couple of options should never come up on their own at all, so the
|
|
241
|
+
// idle fidget needs more than a uniform draw.
|
|
242
|
+
const fidgetOff = option.fidget === false
|
|
243
|
+
const fidgetWeight = typeof option.fidgetWeight === 'number' && option.fidgetWeight > 0
|
|
244
|
+
? option.fidgetWeight
|
|
245
|
+
: 1
|
|
246
|
+
if (expressions.length === 0 && motion === null && sweep === null) continue
|
|
247
|
+
options.push({
|
|
248
|
+
label: typeof option.label === 'string' && option.label !== ''
|
|
249
|
+
? option.label
|
|
250
|
+
: (expressions[0] ?? motion),
|
|
251
|
+
expressions,
|
|
252
|
+
...(motion === null ? {} : { motion }),
|
|
253
|
+
...(requires.length === 0 ? {} : { requires }),
|
|
254
|
+
...(clears.length === 0 ? {} : { clears }),
|
|
255
|
+
...(sweep === null ? {} : { sweep }),
|
|
256
|
+
...(conflicts.length === 0 ? {} : { conflicts }),
|
|
257
|
+
...(Object.keys(pairs).length === 0 ? {} : { pairs }),
|
|
258
|
+
...(breaks.length === 0 ? {} : { breaks }),
|
|
259
|
+
...(fidgetOff ? { fidget: false } : {}),
|
|
260
|
+
...(fidgetWeight === 1 ? {} : { fidgetWeight }),
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
if (options.length === 0) continue
|
|
264
|
+
out.push({
|
|
265
|
+
id,
|
|
266
|
+
label: typeof slot.label === 'string' && slot.label !== '' ? slot.label : id,
|
|
267
|
+
none: typeof slot.none === 'string' && slot.none !== '' ? slot.none : '无',
|
|
268
|
+
// How strongly the idle fidget should prefer leaving this slot alone.
|
|
269
|
+
// The mouth sets it high so the pet mostly looks normal.
|
|
270
|
+
...(typeof slot.fidgetNone === 'number' && slot.fidgetNone > 0 ? { fidgetNone: slot.fidgetNone } : {}),
|
|
271
|
+
options,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Scan one pet directory into a catalog entry, or undefined when unusable. */
|
|
278
|
+
export function scanPet(dir, id) {
|
|
279
|
+
const manifestFile = join(dir, 'pet.json')
|
|
280
|
+
if (!existsSync(manifestFile)) return undefined
|
|
281
|
+
const manifest = readJson(manifestFile)
|
|
282
|
+
if (manifest === undefined || typeof manifest !== 'object' || manifest === null) return undefined
|
|
283
|
+
if (manifest.renderer !== 'live2d') return undefined
|
|
284
|
+
const block = manifest.live2d
|
|
285
|
+
if (typeof block !== 'object' || block === null) return undefined
|
|
286
|
+
const modelPath = safeRel(block.model)
|
|
287
|
+
if (modelPath === undefined || !modelPath.endsWith('.model3.json')) return undefined
|
|
288
|
+
const modelFile = join(dir, modelPath)
|
|
289
|
+
if (!existsSync(modelFile)) return undefined
|
|
290
|
+
const model3 = readJson(modelFile)
|
|
291
|
+
if (model3 === undefined) return undefined
|
|
292
|
+
const closure = modelClosure(model3)
|
|
293
|
+
if (closure.size === 0) return undefined
|
|
294
|
+
// The model descriptor itself rides the same route: the browser fetches it
|
|
295
|
+
// first, then every file it names, so it belongs in the servable set.
|
|
296
|
+
closure.add(modelPath)
|
|
297
|
+
|
|
298
|
+
// Labels/categories are optional host-only metadata shipped beside the
|
|
299
|
+
// manifest ('catalog.json'); the authoritative motion/expression lists
|
|
300
|
+
// always come from the model itself, so a pet without one still lists
|
|
301
|
+
// everything, just with the model's own names.
|
|
302
|
+
const labels = readJson(join(dir, 'catalog.json')) ?? {}
|
|
303
|
+
const labelFor = (kind, key) => {
|
|
304
|
+
const list = labels[kind]
|
|
305
|
+
if (!Array.isArray(list)) return undefined
|
|
306
|
+
const hit = list.find((entry) => entry !== null && typeof entry === 'object' && entry.key === key)
|
|
307
|
+
return hit === undefined ? undefined : hit
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Every motion entry carries its own duration and loop flag, read from the
|
|
311
|
+
// motion3.json the model references. The browser half needs both: the
|
|
312
|
+
// engine refuses to restart a still-active group+index, and a motion
|
|
313
|
+
// flagged Loop never emits motionFinish — so playback has to be driven by
|
|
314
|
+
// the model's own timing instead of by that event alone.
|
|
315
|
+
const motions = []
|
|
316
|
+
const motionGroups = model3.FileReferences?.Motions
|
|
317
|
+
if (typeof motionGroups === 'object' && motionGroups !== null) {
|
|
318
|
+
for (const [group, list] of Object.entries(motionGroups)) {
|
|
319
|
+
if (!Array.isArray(list) || list.length === 0) continue
|
|
320
|
+
const meta = labelFor('motions', group)
|
|
321
|
+
const items = list.map((entry, index) => {
|
|
322
|
+
const file = typeof entry === 'object' && entry !== null ? safeRel(entry.File) : undefined
|
|
323
|
+
const motionMeta = file === undefined ? undefined : readJson(join(dir, file))
|
|
324
|
+
const rawDuration = motionMeta?.Meta?.Duration
|
|
325
|
+
// Every parameter the motion writes. The browser half needs this to
|
|
326
|
+
// clean up after a one-shot action: a motion such as 吹泡泡糖 drives
|
|
327
|
+
// its own mouth/pose parameters that the idle loop does NOT drive, so
|
|
328
|
+
// once the motion stops its last written value stays on the model
|
|
329
|
+
// forever unless something puts it back ("泡泡吹完嘴没还原").
|
|
330
|
+
const params = []
|
|
331
|
+
const curves = motionMeta?.Curves
|
|
332
|
+
if (Array.isArray(curves)) {
|
|
333
|
+
for (const curve of curves) {
|
|
334
|
+
if (curve?.Target === 'Parameter' && typeof curve.Id === 'string' && curve.Id !== '') {
|
|
335
|
+
params.push(curve.Id)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
index,
|
|
341
|
+
duration: typeof rawDuration === 'number' && rawDuration > 0 ? Math.round(rawDuration * 1000) : 0,
|
|
342
|
+
loop: motionMeta?.Meta?.Loop === true,
|
|
343
|
+
params,
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
motions.push({
|
|
347
|
+
group,
|
|
348
|
+
count: items.length,
|
|
349
|
+
label: meta?.label ?? group,
|
|
350
|
+
category: meta?.category ?? 'action',
|
|
351
|
+
items,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const expressions = []
|
|
357
|
+
const expressionRefs = model3.FileReferences?.Expressions
|
|
358
|
+
if (Array.isArray(expressionRefs)) {
|
|
359
|
+
for (const reference of expressionRefs) {
|
|
360
|
+
if (typeof reference !== 'object' || reference === null) continue
|
|
361
|
+
const expressionName = typeof reference.Name === 'string' && reference.Name !== '' ? reference.Name : reference.File
|
|
362
|
+
if (typeof expressionName !== 'string') continue
|
|
363
|
+
const meta = labelFor('expressions', expressionName)
|
|
364
|
+
// Ship each expression's own parameter writes.
|
|
365
|
+
//
|
|
366
|
+
// The engine's expression pipeline loads the file correctly (44
|
|
367
|
+
// definitions, the right parameter count, weight 1) but never actually
|
|
368
|
+
// moves a parameter, so nothing it plays is visible. The browser half
|
|
369
|
+
// therefore applies these values itself through the same parameter
|
|
370
|
+
// machinery it already uses for motions — which also gives the UI the
|
|
371
|
+
// per-expression data it needs to work out which effects conflict.
|
|
372
|
+
const file = safeRel(reference.File)
|
|
373
|
+
const params = []
|
|
374
|
+
if (file !== undefined) {
|
|
375
|
+
let parsed
|
|
376
|
+
try {
|
|
377
|
+
parsed = readJson(join(dir, file))
|
|
378
|
+
} catch {
|
|
379
|
+
parsed = undefined
|
|
380
|
+
}
|
|
381
|
+
if (Array.isArray(parsed?.Parameters)) {
|
|
382
|
+
for (const parameter of parsed.Parameters) {
|
|
383
|
+
if (typeof parameter?.Id !== 'string' || parameter.Id === '') continue
|
|
384
|
+
const value = Number(parameter.Value)
|
|
385
|
+
if (!Number.isFinite(value)) continue
|
|
386
|
+
const blend = parameter.Blend === 'Multiply' ? 'Multiply'
|
|
387
|
+
: parameter.Blend === 'Overwrite' ? 'Overwrite' : 'Add'
|
|
388
|
+
params.push({ id: parameter.Id, value, blend })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
expressions.push({
|
|
393
|
+
name: expressionName,
|
|
394
|
+
label: meta?.label ?? expressionName,
|
|
395
|
+
category: meta?.category ?? 'other',
|
|
396
|
+
file: file ?? '',
|
|
397
|
+
params,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
id,
|
|
404
|
+
displayName: typeof manifest.displayName === 'string' && manifest.displayName !== '' ? manifest.displayName : id,
|
|
405
|
+
description: typeof manifest.description === 'string' ? manifest.description : '',
|
|
406
|
+
scale: typeof block.scale === 'number' && block.scale > 0 && block.scale <= 10 ? block.scale : 1,
|
|
407
|
+
// The manifest's phase -> motion-group / expression maps (same keys the
|
|
408
|
+
// catalog uses), so the browser half can retarget session phases per pet.
|
|
409
|
+
motionsByPhase: typeof block.motions === 'object' && block.motions !== null ? block.motions : {},
|
|
410
|
+
// Per-motion playback policy (hold / reset / prepend) declared by the pet.
|
|
411
|
+
// See the browser half's motion controller: a model whose motion3.json all
|
|
412
|
+
// say "Loop": true cannot express "play once and hold the pose" or "clean
|
|
413
|
+
// up the mouth afterwards" on its own, so the pet says it here.
|
|
414
|
+
motionOptions: typeof block.motionOptions === 'object' && block.motionOptions !== null ? block.motionOptions : {},
|
|
415
|
+
expressionsByPhase: typeof block.expressions === 'object' && block.expressions !== null ? block.expressions : {},
|
|
416
|
+
// Mutually exclusive dress-up slots. Several of the model's expressions are
|
|
417
|
+
// switches for the same thing (three pairs of glasses, three stickers), so
|
|
418
|
+
// the pet declares which may not be worn together; the browser half turns
|
|
419
|
+
// that into a one-choice-per-slot panel.
|
|
420
|
+
expressionSlots: normaliseSlots(block.expressionSlots),
|
|
421
|
+
// Session phase -> a whole LOOK expressed in slot vocabulary
|
|
422
|
+
// ({ whale: "头顶鲸", lhand: "画笔" }), so a phase drives several slots at
|
|
423
|
+
// once instead of a single expression.
|
|
424
|
+
looksByPhase: typeof block.looksByPhase === 'object' && block.looksByPhase !== null ? block.looksByPhase : {},
|
|
425
|
+
// motion group -> { slotId: [acceptable option labels] }. A motion whose
|
|
426
|
+
// premise is missing (a selfie with no phone out, a spray with no whale)
|
|
427
|
+
// must not play at all, from ANY path: the panel, a fidget or a phase.
|
|
428
|
+
motionGuards: typeof block.motionGuards === 'object' && block.motionGuards !== null ? block.motionGuards : {},
|
|
429
|
+
// Motion groups that belong to the SLOT menu rather than the 动作 tab.
|
|
430
|
+
// 掏出手机 and 吹泡泡糖 are slot choices now; listing them twice made the
|
|
431
|
+
// same thing reachable two ways, and the tab copy could not hold a pose.
|
|
432
|
+
hiddenMotions: Array.isArray(block.hiddenMotions) ? block.hiddenMotions.filter((m) => typeof m === 'string') : [],
|
|
433
|
+
translate: {
|
|
434
|
+
x: typeof block.translate?.x === 'number' ? block.translate.x : 0,
|
|
435
|
+
y: typeof block.translate?.y === 'number' ? block.translate.y : 0,
|
|
436
|
+
},
|
|
437
|
+
dir,
|
|
438
|
+
modelPath,
|
|
439
|
+
modelUrl: API + '/asset/' + encodeURIComponent(id) + '/' + modelPath.split('/').map(encodeURIComponent).join('/'),
|
|
440
|
+
closure,
|
|
441
|
+
motions,
|
|
442
|
+
expressions,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Build the live catalog from disk (fresh per request, so installs are picked up). */
|
|
447
|
+
/**
|
|
448
|
+
* 把**随包分发**的宠物装进用户的宠物目录。
|
|
449
|
+
*
|
|
450
|
+
* 以前宠物是仓库里单独一份,装插件的人还得自己再拷一次 —— 从插件市场装完
|
|
451
|
+
* 「看不见宠物」就是这么来的(插件包里有代码,没有模型)。
|
|
452
|
+
*
|
|
453
|
+
* 为什么模型能随包、Cubism Core 不能:模型是 CC BY-NC-SA 4.0,且 Live2D 作者
|
|
454
|
+
* 已明确授权本项目转载与开源(见 pets/ds-whale-girl/LICENSE);Core 是 Live2D Inc.
|
|
455
|
+
* 的专有软件,只允许从官方渠道取,所以它走 CDN 兜底那条路。
|
|
456
|
+
*
|
|
457
|
+
* 只在目标**不存在**时复制:用户自己改过的宠物永远优先,绝不覆盖。
|
|
458
|
+
*/
|
|
459
|
+
export function installBundledPets() {
|
|
460
|
+
const bundled = join(pluginRoot(), 'pets')
|
|
461
|
+
if (!existsSync(bundled)) return
|
|
462
|
+
let names = []
|
|
463
|
+
try {
|
|
464
|
+
names = readdirSync(bundled).filter((entry) => !entry.startsWith('.'))
|
|
465
|
+
} catch {
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
for (const name of names) {
|
|
469
|
+
const source = join(bundled, name)
|
|
470
|
+
const target = join(petsRoot(), name)
|
|
471
|
+
try {
|
|
472
|
+
if (!statSync(source).isDirectory()) continue
|
|
473
|
+
if (existsSync(target)) continue
|
|
474
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
475
|
+
cpSync(source, target, { recursive: true })
|
|
476
|
+
} catch {
|
|
477
|
+
/* 只读文件系统之类:让用户自己拷,别让整个目录扫描失败 */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function buildCatalog() {
|
|
483
|
+
installBundledPets()
|
|
484
|
+
const root = petsRoot()
|
|
485
|
+
if (!existsSync(root)) return []
|
|
486
|
+
let names = []
|
|
487
|
+
try {
|
|
488
|
+
names = readdirSync(root).filter((entry) => !entry.startsWith('.'))
|
|
489
|
+
} catch {
|
|
490
|
+
return []
|
|
491
|
+
}
|
|
492
|
+
names.sort()
|
|
493
|
+
const pets = []
|
|
494
|
+
for (const entry of names) {
|
|
495
|
+
const dir = join(root, entry)
|
|
496
|
+
try {
|
|
497
|
+
if (!statSync(dir).isDirectory()) continue
|
|
498
|
+
} catch {
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
const pet = scanPet(dir, entry)
|
|
502
|
+
if (pet !== undefined) pets.push(pet)
|
|
503
|
+
}
|
|
504
|
+
return pets
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** realpath containment; a symlink escaping its root is refused. */
|
|
508
|
+
function contained(base, candidate) {
|
|
509
|
+
try {
|
|
510
|
+
const realBase = realpathSync(base)
|
|
511
|
+
const realCandidate = realpathSync(candidate)
|
|
512
|
+
return realCandidate === realBase || realCandidate.startsWith(realBase + sep) ? realCandidate : undefined
|
|
513
|
+
} catch {
|
|
514
|
+
return undefined
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function sendJson(response, status, payload) {
|
|
519
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
|
520
|
+
response.writeHead(status, {
|
|
521
|
+
'cache-control': 'no-store',
|
|
522
|
+
'content-type': 'application/json; charset=utf-8',
|
|
523
|
+
'content-length': String(body.byteLength),
|
|
524
|
+
})
|
|
525
|
+
response.end(body)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Weak validator from size + mtime. */
|
|
529
|
+
function weakEtag(stat) {
|
|
530
|
+
return '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Serve one file with containment, size ceiling and revalidation. */
|
|
534
|
+
function serveFile(request, response, base, file, cap) {
|
|
535
|
+
const resolved = contained(base, file)
|
|
536
|
+
if (resolved === undefined) {
|
|
537
|
+
response.writeHead(403)
|
|
538
|
+
response.end()
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
let stat
|
|
542
|
+
try {
|
|
543
|
+
stat = statSync(resolved)
|
|
544
|
+
if (!stat.isFile()) throw new Error('not a file')
|
|
545
|
+
if (stat.size > cap) {
|
|
546
|
+
response.writeHead(413)
|
|
547
|
+
response.end()
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
response.writeHead(404)
|
|
552
|
+
response.end()
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
const etag = weakEtag(stat)
|
|
556
|
+
if (request.headers['if-none-match'] === etag) {
|
|
557
|
+
response.writeHead(304, { etag, 'cache-control': 'no-cache' })
|
|
558
|
+
response.end()
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
readFile(resolved).then((body) => {
|
|
562
|
+
response.writeHead(200, {
|
|
563
|
+
'content-type': MIME[extname(resolved).toLowerCase()] ?? 'application/octet-stream',
|
|
564
|
+
'content-length': String(body.byteLength),
|
|
565
|
+
'cache-control': 'no-cache',
|
|
566
|
+
etag,
|
|
567
|
+
})
|
|
568
|
+
if (request.method === 'HEAD') {
|
|
569
|
+
response.end()
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
response.end(body)
|
|
573
|
+
}, () => {
|
|
574
|
+
response.writeHead(404)
|
|
575
|
+
response.end()
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Loopback-only fence: the pet API never answers a non-local peer. */
|
|
580
|
+
function loopbackOnly(request) {
|
|
581
|
+
const address = request.socket?.remoteAddress ?? ''
|
|
582
|
+
return address === '' || address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Split a request path into the segments after one known prefix. */
|
|
586
|
+
function segmentsAfter(pathname, prefix) {
|
|
587
|
+
if (!pathname.startsWith(prefix + '/')) return undefined
|
|
588
|
+
return pathname.slice(prefix.length + 1).split('/')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The catalog route (GET /api/live2d-pet/catalog). */
|
|
592
|
+
function catalogRoute() {
|
|
593
|
+
return {
|
|
594
|
+
kind: 'exact',
|
|
595
|
+
path: API + '/catalog',
|
|
596
|
+
handler: (request, response) => {
|
|
597
|
+
if (!loopbackOnly(request)) {
|
|
598
|
+
response.writeHead(403)
|
|
599
|
+
response.end()
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
603
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
604
|
+
response.end()
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
sendJson(response, 200, {
|
|
608
|
+
ok: true,
|
|
609
|
+
coreUrl: API + '/runtime/live2dcubismcore.min.js',
|
|
610
|
+
vendorUrl: API + '/runtime/live2d-vendor.js',
|
|
611
|
+
pets: buildCatalog().map((pet) => ({
|
|
612
|
+
id: pet.id,
|
|
613
|
+
displayName: pet.displayName,
|
|
614
|
+
description: pet.description,
|
|
615
|
+
modelUrl: pet.modelUrl,
|
|
616
|
+
scale: pet.scale,
|
|
617
|
+
translate: pet.translate,
|
|
618
|
+
motionsByPhase: pet.motionsByPhase,
|
|
619
|
+
expressionsByPhase: pet.expressionsByPhase,
|
|
620
|
+
expressionSlots: pet.expressionSlots,
|
|
621
|
+
looksByPhase: pet.looksByPhase,
|
|
622
|
+
motionGuards: pet.motionGuards,
|
|
623
|
+
hiddenMotions: pet.hiddenMotions,
|
|
624
|
+
motionOptions: pet.motionOptions,
|
|
625
|
+
motions: pet.motions,
|
|
626
|
+
expressions: pet.expressions,
|
|
627
|
+
})),
|
|
628
|
+
})
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** The model reference-closure route (GET /api/live2d-pet/asset/<id>/<path>). */
|
|
634
|
+
function assetRoute() {
|
|
635
|
+
return {
|
|
636
|
+
kind: 'prefix',
|
|
637
|
+
path: API + '/asset',
|
|
638
|
+
handler: (request, response) => {
|
|
639
|
+
if (!loopbackOnly(request)) {
|
|
640
|
+
response.writeHead(403)
|
|
641
|
+
response.end()
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
645
|
+
response.writeHead(405)
|
|
646
|
+
response.end()
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
let pathname
|
|
650
|
+
try {
|
|
651
|
+
pathname = new URL(request.url ?? '/', 'http://pet.local').pathname
|
|
652
|
+
} catch {
|
|
653
|
+
response.writeHead(400)
|
|
654
|
+
response.end()
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
const segments = segmentsAfter(pathname, API + '/asset')
|
|
658
|
+
if (segments === undefined || segments.length < 2) {
|
|
659
|
+
response.writeHead(404)
|
|
660
|
+
response.end()
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
let id
|
|
664
|
+
let rel
|
|
665
|
+
try {
|
|
666
|
+
id = decodeURIComponent(segments[0])
|
|
667
|
+
rel = segments.slice(1).map(decodeURIComponent).join('/')
|
|
668
|
+
} catch {
|
|
669
|
+
response.writeHead(400)
|
|
670
|
+
response.end()
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
const pet = buildCatalog().find((candidate) => candidate.id === id)
|
|
674
|
+
// The closure probe is a Set lookup on scan-time normalized paths, so a
|
|
675
|
+
// crafted '..' / '.' segment can never match a real file.
|
|
676
|
+
if (pet === undefined || !pet.closure.has(rel)) {
|
|
677
|
+
response.writeHead(404)
|
|
678
|
+
response.end()
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
const ext = extname(rel).toLowerCase()
|
|
682
|
+
const cap = ext === '.json' ? CAP_JSON : (ext === '.moc3' ? CAP_MODEL : CAP_IMAGE)
|
|
683
|
+
serveFile(request, response, pet.dir, join(pet.dir, rel), cap)
|
|
684
|
+
},
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Live2D 官方托管的 Cubism Core。
|
|
690
|
+
*
|
|
691
|
+
* 这份运行时是 Live2D 的专有软件,插件**不内置也不代下**(授权要求),但
|
|
692
|
+
* **用户的机器可以从 Live2D 自己的 CDN 取** —— 官方 SDK 文档就是让使用者在页面里
|
|
693
|
+
* 引这一行。取到之后缓存进 runtimeDir(),之后离线也能用。
|
|
694
|
+
*
|
|
695
|
+
* 有了这条兜底,从插件市场装完就能直接用,不必先去找 Cubism Core 放哪。
|
|
696
|
+
*/
|
|
697
|
+
const CORE_CDN = 'https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js'
|
|
698
|
+
|
|
699
|
+
/** 本地没有 Core 时:去 Live2D 官方 CDN 拿一份,顺手缓存到本地。 */
|
|
700
|
+
async function fetchCoreFromCdn(response, cachePath) {
|
|
701
|
+
try {
|
|
702
|
+
const upstream = await fetch(CORE_CDN, { redirect: 'follow' })
|
|
703
|
+
if (!upstream.ok) throw new Error('HTTP ' + upstream.status)
|
|
704
|
+
const bytes = Buffer.from(await upstream.arrayBuffer())
|
|
705
|
+
// 拿回来的必须真是一份 JS:否则多半是被网关/登录页替换了。
|
|
706
|
+
if (bytes.length < 10000 || !bytes.includes('Live2DCubismCore')) {
|
|
707
|
+
throw new Error('unexpected payload: ' + bytes.length + ' bytes')
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
mkdirSync(dirname(cachePath), { recursive: true })
|
|
711
|
+
writeFileSync(cachePath, bytes)
|
|
712
|
+
} catch {
|
|
713
|
+
/* 缓存写不进去不影响这一次:直接把它发出去 */
|
|
714
|
+
}
|
|
715
|
+
response.writeHead(200, {
|
|
716
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
717
|
+
'content-length': String(bytes.length),
|
|
718
|
+
'cache-control': 'public, max-age=86400',
|
|
719
|
+
'x-live2d-core-source': 'cdn',
|
|
720
|
+
})
|
|
721
|
+
response.end(bytes)
|
|
722
|
+
} catch (error) {
|
|
723
|
+
sendJson(response, 502, {
|
|
724
|
+
ok: false,
|
|
725
|
+
error: 'cubism-core-unavailable',
|
|
726
|
+
detail: String(error?.message ?? error),
|
|
727
|
+
hint: 'Cubism Core 是 Live2D 的专有运行时,本插件不内置。可以让这台机器能访问 '
|
|
728
|
+
+ CORE_CDN + ',或自行下载后放到 ' + join(runtimeDir(), 'live2dcubismcore.min.js'),
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** The runtime route (user-supplied Cubism Core + plugin vendor bundle). */
|
|
734
|
+
function runtimeRoute() {
|
|
735
|
+
const vendorBase = join(pluginRoot(), 'lib')
|
|
736
|
+
const coreBase = runtimeDir()
|
|
737
|
+
return {
|
|
738
|
+
kind: 'prefix',
|
|
739
|
+
path: API + '/runtime',
|
|
740
|
+
handler: (request, response) => {
|
|
741
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
742
|
+
response.writeHead(405)
|
|
743
|
+
response.end()
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
let pathname
|
|
747
|
+
try {
|
|
748
|
+
pathname = new URL(request.url ?? '/', 'http://pet.local').pathname
|
|
749
|
+
} catch {
|
|
750
|
+
response.writeHead(400)
|
|
751
|
+
response.end()
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
const segments = segmentsAfter(pathname, API + '/runtime')
|
|
755
|
+
if (segments === undefined || segments.length !== 1) {
|
|
756
|
+
response.writeHead(404)
|
|
757
|
+
response.end()
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
// An exact-name allow-list: a segment carrying a separator never matches.
|
|
761
|
+
const runtimeName = segments[0]
|
|
762
|
+
let base
|
|
763
|
+
if (runtimeName === 'live2dcubismcore.min.js') base = coreBase
|
|
764
|
+
else if (runtimeName === 'live2d-vendor.js') base = vendorBase
|
|
765
|
+
else {
|
|
766
|
+
response.writeHead(404)
|
|
767
|
+
response.end()
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
const file = join(base, runtimeName)
|
|
771
|
+
if (!existsSync(file)) {
|
|
772
|
+
// Cubism Core 走官方 CDN 兜底:用户不用再自己找文件。
|
|
773
|
+
if (runtimeName === 'live2dcubismcore.min.js') {
|
|
774
|
+
void fetchCoreFromCdn(response, file)
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
sendJson(response, 404, { ok: false, error: 'runtime-file-missing', file: runtimeName })
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
serveFile(request, response, base, file, CAP_RUNTIME)
|
|
781
|
+
},
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
// ---------------------------------------------------------------- activity
|
|
787
|
+
//
|
|
788
|
+
// Session-activity mirror (requirement #4): the pet follows the DSH agent's
|
|
789
|
+
// real lifecycle instead of only reacting to clicks.
|
|
790
|
+
//
|
|
791
|
+
// The event vocabulary is the official one (verified against dsh-agent's
|
|
792
|
+
// runtime-types): 'agent/status' carries idle <-> running, 'agent/turn-stopping'
|
|
793
|
+
// fires when a turn finishes, 'agent/error' on failure, and 'approval/request'
|
|
794
|
+
// (a waterfall event, so it must be resumed) while the user is being asked.
|
|
795
|
+
//
|
|
796
|
+
// Delivery is a same-origin SSE stream rather than polling: transitions are
|
|
797
|
+
// pushed the moment they happen, and an idle page costs only a keep-alive
|
|
798
|
+
// comment frame.
|
|
799
|
+
|
|
800
|
+
/** The activity phases the browser half understands. */
|
|
801
|
+
export const ACTIVITY_PHASES = ['idle', 'thinking', 'waiting', 'tool', 'done', 'failed']
|
|
802
|
+
|
|
803
|
+
/** How long the 'done' celebration is held before falling back to idle. */
|
|
804
|
+
const DONE_HOLD_MS = 3500
|
|
805
|
+
|
|
806
|
+
/** SSE keep-alive interval. */
|
|
807
|
+
const SSE_PING_MS = 30000
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The activity hub: folds DSH session events into one coarse phase, and fans
|
|
811
|
+
* that phase out to every subscribed SSE response.
|
|
812
|
+
*/
|
|
813
|
+
export class ActivityHub {
|
|
814
|
+
constructor() {
|
|
815
|
+
this.phase = 'idle'
|
|
816
|
+
this.detail = ''
|
|
817
|
+
this.listeners = new Set()
|
|
818
|
+
this.doneTimer = undefined
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Current snapshot (sent as the first frame of every stream). */
|
|
822
|
+
snapshot() {
|
|
823
|
+
return { phase: this.phase, detail: this.detail, at: Date.now() }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Subscribe one SSE response; returns the unsubscribe function. */
|
|
827
|
+
subscribe(listener) {
|
|
828
|
+
this.listeners.add(listener)
|
|
829
|
+
return () => { this.listeners.delete(listener) }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
set(phase, detail = '') {
|
|
833
|
+
if (this.phase === phase && this.detail === detail) return
|
|
834
|
+
this.phase = phase
|
|
835
|
+
this.detail = detail
|
|
836
|
+
this.emit()
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Enter the 'done' celebration and fall back to idle after a hold, so the
|
|
841
|
+
* pet visibly finishes a turn instead of snapping straight back.
|
|
842
|
+
*/
|
|
843
|
+
celebrate() {
|
|
844
|
+
if (this.doneTimer !== undefined) clearTimeout(this.doneTimer)
|
|
845
|
+
this.set('done', '')
|
|
846
|
+
this.doneTimer = setTimeout(() => {
|
|
847
|
+
this.doneTimer = undefined
|
|
848
|
+
this.set('idle', '')
|
|
849
|
+
}, DONE_HOLD_MS)
|
|
850
|
+
// Keep the process free to exit; the timer is purely cosmetic.
|
|
851
|
+
this.doneTimer?.unref?.()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
emit() {
|
|
855
|
+
const payload = this.snapshot()
|
|
856
|
+
for (const listener of this.listeners) {
|
|
857
|
+
try {
|
|
858
|
+
listener(payload)
|
|
859
|
+
} catch {
|
|
860
|
+
/* a dead stream must not break the others */
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
dispose() {
|
|
866
|
+
if (this.doneTimer !== undefined) {
|
|
867
|
+
clearTimeout(this.doneTimer)
|
|
868
|
+
this.doneTimer = undefined
|
|
869
|
+
}
|
|
870
|
+
this.listeners.clear()
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* How long the pet stays in the 'tool' phase after the last tool returns.
|
|
876
|
+
*
|
|
877
|
+
* Long enough to bridge the gap between consecutive tool calls in one turn
|
|
878
|
+
* (otherwise the phase flaps and the animation restarts constantly), short
|
|
879
|
+
* enough that the pet visibly settles once the work really stops.
|
|
880
|
+
*/
|
|
881
|
+
const TOOL_IDLE_MS = 1200
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Fold the official DSH events into hub phases. Every subscription is optional
|
|
885
|
+
* at runtime: an older host that lacks one simply keeps mirroring the others.
|
|
886
|
+
*/
|
|
887
|
+
export function attachActivityEvents(ctx, hub) {
|
|
888
|
+
const on = (event, handler) => {
|
|
889
|
+
try {
|
|
890
|
+
ctx.on(event, handler)
|
|
891
|
+
} catch {
|
|
892
|
+
/* host without this event */
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Waterfall events only continue if the handler resumes the chain, so a
|
|
896
|
+
// throw here would silently break tool execution for the whole session.
|
|
897
|
+
const onWaterfall = (event, handler) => {
|
|
898
|
+
try {
|
|
899
|
+
ctx.on(event, (...args) => {
|
|
900
|
+
try {
|
|
901
|
+
return handler(...args)
|
|
902
|
+
} catch {
|
|
903
|
+
// Never swallow the chain: hand control straight on.
|
|
904
|
+
const next = args[args.length - 1]
|
|
905
|
+
return typeof next === 'function' ? next() : undefined
|
|
906
|
+
}
|
|
907
|
+
})
|
|
908
|
+
} catch {
|
|
909
|
+
/* host without this event */
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
on('agent/status', (payload) => {
|
|
913
|
+
const status = payload?.status
|
|
914
|
+
if (status === 'running') hub.set('thinking', '')
|
|
915
|
+
else if (status === 'idle' && hub.phase !== 'done') hub.set('idle', '')
|
|
916
|
+
})
|
|
917
|
+
on('agent/turn-stopping', () => hub.celebrate())
|
|
918
|
+
on('agent/error', () => hub.set('failed', ''))
|
|
919
|
+
// approval/request is a waterfall event: it MUST resume the chain.
|
|
920
|
+
on('approval/request', (_request, next) => {
|
|
921
|
+
hub.set('waiting', '')
|
|
922
|
+
return typeof next === 'function' ? next() : undefined
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// Tool activity refines the generic 'thinking' phase while a turn runs.
|
|
926
|
+
//
|
|
927
|
+
// The obvious-looking event name for this is 'tool/call', but that is a
|
|
928
|
+
// SESSION LOG event (a record appended to the transcript), not a cordis
|
|
929
|
+
// lifecycle event — subscribing to it never fires, which is why the pet
|
|
930
|
+
// appeared to ignore tool activity entirely. The live hooks are the
|
|
931
|
+
// 'tools/*' waterfall events, which carry the ToolExecution itself.
|
|
932
|
+
//
|
|
933
|
+
// A turn usually runs MANY tools back to back, so reverting the instant one
|
|
934
|
+
// returns would flap tool -> thinking -> tool several times a second, and each
|
|
935
|
+
// transition restarts the pet's animation. The revert is therefore debounced:
|
|
936
|
+
// it only happens once the tools actually stop arriving.
|
|
937
|
+
let toolIdleTimer
|
|
938
|
+
onWaterfall('tools/pre-execute', (exec, next) => {
|
|
939
|
+
if (toolIdleTimer !== undefined) {
|
|
940
|
+
clearTimeout(toolIdleTimer)
|
|
941
|
+
toolIdleTimer = undefined
|
|
942
|
+
}
|
|
943
|
+
const name = exec?.name
|
|
944
|
+
hub.set('tool', typeof name === 'string' ? name : '')
|
|
945
|
+
return next()
|
|
946
|
+
})
|
|
947
|
+
onWaterfall('tools/post-execute', (_exec, _result, next) => {
|
|
948
|
+
if (toolIdleTimer !== undefined) clearTimeout(toolIdleTimer)
|
|
949
|
+
toolIdleTimer = setTimeout(() => {
|
|
950
|
+
toolIdleTimer = undefined
|
|
951
|
+
// Only step down if nothing else has taken over in the meantime.
|
|
952
|
+
if (hub.phase === 'tool') hub.set('thinking', '')
|
|
953
|
+
}, TOOL_IDLE_MS)
|
|
954
|
+
return next()
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** The activity stream + snapshot route. */
|
|
959
|
+
function eventsRoute(hub) {
|
|
960
|
+
return {
|
|
961
|
+
kind: 'exact',
|
|
962
|
+
path: API + '/events',
|
|
963
|
+
handler: (request, response) => {
|
|
964
|
+
if (!loopbackOnly(request)) {
|
|
965
|
+
response.writeHead(403)
|
|
966
|
+
response.end()
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
if (request.method !== 'GET' || hub === undefined) {
|
|
970
|
+
response.writeHead(405, { allow: 'GET' })
|
|
971
|
+
response.end()
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
response.writeHead(200, {
|
|
975
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
976
|
+
'cache-control': 'no-store',
|
|
977
|
+
connection: 'keep-alive',
|
|
978
|
+
'x-accel-buffering': 'no',
|
|
979
|
+
})
|
|
980
|
+
const frame = (payload) => {
|
|
981
|
+
// Guard every write: the peer may have gone away mid-push.
|
|
982
|
+
try {
|
|
983
|
+
response.write('data: ' + JSON.stringify(payload) + '\n\n')
|
|
984
|
+
} catch {
|
|
985
|
+
/* stream already closed */
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
frame(hub.snapshot())
|
|
989
|
+
const unsubscribe = hub.subscribe(frame)
|
|
990
|
+
const ping = setInterval(() => {
|
|
991
|
+
try {
|
|
992
|
+
response.write(': ping\n\n')
|
|
993
|
+
} catch {
|
|
994
|
+
/* stream already closed */
|
|
995
|
+
}
|
|
996
|
+
}, SSE_PING_MS)
|
|
997
|
+
ping.unref?.()
|
|
998
|
+
const close = () => {
|
|
999
|
+
clearInterval(ping)
|
|
1000
|
+
unsubscribe()
|
|
1001
|
+
}
|
|
1002
|
+
request.on('close', close)
|
|
1003
|
+
response.on('close', close)
|
|
1004
|
+
response.on('error', close)
|
|
1005
|
+
},
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** The complete route table this plugin owns. */
|
|
1010
|
+
export function buildRoutes(hub) {
|
|
1011
|
+
return [catalogRoute(), assetRoute(), runtimeRoute(), eventsRoute(hub)]
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export function apply(ctx) {
|
|
1015
|
+
const hub = new ActivityHub()
|
|
1016
|
+
attachActivityEvents(ctx, hub)
|
|
1017
|
+
ctx.effect(() => () => hub.dispose(), 'live2d-pet: activity hub')
|
|
1018
|
+
ctx.inject(['webServer'], (host) => {
|
|
1019
|
+
for (const route of buildRoutes(hub)) {
|
|
1020
|
+
try {
|
|
1021
|
+
host.effect(() => host.webServer.register(route), 'live2d-pet: route ' + route.path)
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
host.logger?.warn('live2d-pet: route ' + route.path + ' failed: ' + String(error))
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
})
|
|
1027
|
+
}
|