@linxin666/dsh-pet 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +110 -28
- package/lib/client.js.map +1 -1
- package/lib/index.js +164 -34
- package/lib/types/access.d.ts +16 -0
- package/lib/types/access.d.ts.map +1 -0
- package/lib/types/access.js +20 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +8 -2
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +10 -2
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +120 -24
- package/lib/types/image-dimensions.d.ts +26 -0
- package/lib/types/image-dimensions.d.ts.map +1 -0
- package/lib/types/image-dimensions.js +77 -0
- package/lib/types/index.js +1 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +43 -1
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +36 -33
- package/package.json +1 -1
- package/src/access.ts +40 -0
- package/src/client/PetSettingsCard.tsx +8 -2
- package/src/client/PetSprite.test.tsx +71 -0
- package/src/client/PetSprite.tsx +10 -2
- package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
- package/src/client/renderers/live2d/runtime.ts +8 -2
- package/src/client/renderers/live2d.test.ts +211 -8
- package/src/client/renderers/live2d.ts +129 -29
- package/src/client/settings-card.module.css +2 -0
- package/src/image-dimensions.test.ts +85 -0
- package/src/image-dimensions.ts +76 -0
- package/src/index.ts +1 -1
- package/src/registry.test.ts +58 -5
- package/src/registry.ts +40 -1
- package/src/routes.ts +38 -34
|
@@ -68,6 +68,43 @@ const TAP_GROUP = 'TapBody'
|
|
|
68
68
|
* downsampled atlas only when the effective on-screen scale warrants it.
|
|
69
69
|
*/
|
|
70
70
|
const TEXTURE_OPTIONS = { lod: 'single-auto' } as const
|
|
71
|
+
/** Recursively release the activation without invalidating shared texture caches. */
|
|
72
|
+
const DESTROY_OPTIONS = { children: true } as const
|
|
73
|
+
/** Remove only this activation's canvas; `true` would release Pixi globals. */
|
|
74
|
+
const RENDERER_DESTROY_OPTIONS = { removeView: true } as const
|
|
75
|
+
|
|
76
|
+
interface Live2dModelSize {
|
|
77
|
+
width: number
|
|
78
|
+
height: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ignore hidden/zero boxes and keep Pixi dimensions stable and integral. */
|
|
82
|
+
function normalizeRendererSize(width: number, height: number): Live2dModelSize | undefined {
|
|
83
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined
|
|
84
|
+
return {
|
|
85
|
+
width: Math.max(1, Math.round(width)),
|
|
86
|
+
height: Math.max(1, Math.round(height)),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fit the model from its unscaled dimensions into the current Pixi screen. */
|
|
91
|
+
function layoutModel(
|
|
92
|
+
app: Live2dVendorApp,
|
|
93
|
+
model: Live2dVendorModel,
|
|
94
|
+
sourceSize: Live2dModelSize,
|
|
95
|
+
config: PetLive2dConfig,
|
|
96
|
+
): void {
|
|
97
|
+
const fit = Math.min(
|
|
98
|
+
app.renderer.width / sourceSize.width,
|
|
99
|
+
app.renderer.height / sourceSize.height,
|
|
100
|
+
) * 0.92
|
|
101
|
+
model.scale.set(fit * (config.scale ?? 1))
|
|
102
|
+
model.anchor.set(0.5)
|
|
103
|
+
model.position.set(
|
|
104
|
+
app.renderer.width / 2 + (config.translate?.x ?? 0),
|
|
105
|
+
app.renderer.height / 2 + (config.translate?.y ?? 0),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
71
108
|
|
|
72
109
|
let vendorConfigured = false
|
|
73
110
|
|
|
@@ -105,12 +142,64 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
105
142
|
let disposed = false
|
|
106
143
|
let app: Live2dVendorApp | undefined
|
|
107
144
|
let model: Live2dVendorModel | undefined
|
|
145
|
+
let modelAttached = false
|
|
146
|
+
let modelSourceSize: Live2dModelSize | undefined
|
|
147
|
+
let resizeObserver: ResizeObserver | undefined
|
|
148
|
+
let resizeTracking = false
|
|
108
149
|
let errorListener: ((code: Live2dErrorCode) => void) | undefined
|
|
109
150
|
let unsubscribe: (() => void) | undefined
|
|
110
151
|
/** The motion group the current phase maps to (resume target after taps). */
|
|
111
152
|
let phaseGroup: string = config.motions.idle
|
|
112
153
|
let tapPlaying = false
|
|
113
154
|
|
|
155
|
+
const stopResizeTracking = (): void => {
|
|
156
|
+
resizeTracking = false
|
|
157
|
+
resizeObserver?.disconnect()
|
|
158
|
+
resizeObserver = undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resizeRenderer = (pixiApp: Live2dVendorApp, width: number, height: number): void => {
|
|
162
|
+
const next = normalizeRendererSize(width, height)
|
|
163
|
+
if (disposed || !resizeTracking || next === undefined) return
|
|
164
|
+
if (pixiApp.renderer.width === next.width && pixiApp.renderer.height === next.height) return
|
|
165
|
+
pixiApp.renderer.resize(next.width, next.height)
|
|
166
|
+
if (model !== undefined && modelSourceSize !== undefined) {
|
|
167
|
+
layoutModel(pixiApp, model, modelSourceSize, config)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const trackContainerSize = (pixiApp: Live2dVendorApp): void => {
|
|
172
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
173
|
+
resizeTracking = true
|
|
174
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
175
|
+
const entry = entries.find(candidate => candidate.target === ctx.container)
|
|
176
|
+
if (entry === undefined) return
|
|
177
|
+
resizeRenderer(pixiApp, entry.contentRect.width, entry.contentRect.height)
|
|
178
|
+
})
|
|
179
|
+
resizeObserver.observe(ctx.container)
|
|
180
|
+
// Catch a synchronous layout change between init() and observe().
|
|
181
|
+
resizeRenderer(pixiApp, ctx.container.clientWidth, ctx.container.clientHeight)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Release every resource currently owned by this activation exactly once. */
|
|
185
|
+
const destroyResources = (): void => {
|
|
186
|
+
stopResizeTracking()
|
|
187
|
+
unsubscribe?.()
|
|
188
|
+
unsubscribe = undefined
|
|
189
|
+
const currentApp = app
|
|
190
|
+
const currentModel = model
|
|
191
|
+
const modelOwnedByApp = currentApp !== undefined && modelAttached
|
|
192
|
+
app = undefined
|
|
193
|
+
model = undefined
|
|
194
|
+
modelSourceSize = undefined
|
|
195
|
+
modelAttached = false
|
|
196
|
+
try {
|
|
197
|
+
if (currentModel !== undefined && !modelOwnedByApp) currentModel.destroy(DESTROY_OPTIONS)
|
|
198
|
+
} finally {
|
|
199
|
+
currentApp?.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
114
203
|
const playGroup = (group: string): void => {
|
|
115
204
|
if (model === undefined) return
|
|
116
205
|
const groups = model.internalModel.settings.motions ?? {}
|
|
@@ -143,46 +232,58 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
143
232
|
}
|
|
144
233
|
configureOnce(vendor)
|
|
145
234
|
const pixiApp = new vendor.Application()
|
|
146
|
-
|
|
147
|
-
width:
|
|
148
|
-
height:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
235
|
+
const initialSize = normalizeRendererSize(ctx.container.clientWidth, ctx.container.clientHeight) ?? {
|
|
236
|
+
width: 160,
|
|
237
|
+
height: 174,
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
await pixiApp.init({
|
|
241
|
+
width: initialSize.width,
|
|
242
|
+
height: initialSize.height,
|
|
243
|
+
backgroundAlpha: 0,
|
|
244
|
+
antialias: true,
|
|
245
|
+
autoDensity: true,
|
|
246
|
+
preference: 'webgl',
|
|
247
|
+
})
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// init() can fail after allocating a partial renderer; cleanup is
|
|
250
|
+
// best-effort because Pixi may not consider that partial app ready.
|
|
251
|
+
try { pixiApp.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS) } catch {}
|
|
252
|
+
throw error
|
|
253
|
+
}
|
|
154
254
|
if (disposed) {
|
|
155
|
-
pixiApp.destroy(
|
|
255
|
+
pixiApp.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS)
|
|
156
256
|
return
|
|
157
257
|
}
|
|
258
|
+
app = pixiApp
|
|
158
259
|
pixiApp.canvas.style.display = 'block'
|
|
159
260
|
pixiApp.canvas.style.width = '100%'
|
|
160
261
|
pixiApp.canvas.style.height = '100%'
|
|
161
262
|
ctx.container.appendChild(pixiApp.canvas)
|
|
263
|
+
// Keep a model that rejects during setup off Ticker.shared; from()
|
|
264
|
+
// does not expose that partial instance to callers for disposal.
|
|
265
|
+
trackContainerSize(pixiApp)
|
|
162
266
|
const loaded = await vendor.Live2DModel.from(config.modelUrl, {
|
|
267
|
+
autoUpdate: false,
|
|
163
268
|
autoHitTest: false,
|
|
164
269
|
autoFocus: false,
|
|
165
270
|
textureOptions: TEXTURE_OPTIONS,
|
|
166
271
|
})
|
|
272
|
+
model = loaded
|
|
167
273
|
if (disposed) {
|
|
168
|
-
|
|
274
|
+
destroyResources()
|
|
169
275
|
return
|
|
170
276
|
}
|
|
171
|
-
|
|
172
|
-
|
|
277
|
+
modelSourceSize = {
|
|
278
|
+
width: Math.max(1, loaded.width),
|
|
279
|
+
height: Math.max(1, loaded.height),
|
|
280
|
+
}
|
|
173
281
|
// Auto-fit the model into the container; the manifest scale multiplies
|
|
174
282
|
// the fit and translate offsets from the center anchor.
|
|
175
|
-
|
|
176
|
-
pixiApp.renderer.width / Math.max(1, loaded.width),
|
|
177
|
-
pixiApp.renderer.height / Math.max(1, loaded.height),
|
|
178
|
-
) * 0.92
|
|
179
|
-
loaded.scale.set(fit * (config.scale ?? 1))
|
|
180
|
-
loaded.anchor.set(0.5)
|
|
181
|
-
loaded.position.set(
|
|
182
|
-
pixiApp.renderer.width / 2 + (config.translate?.x ?? 0),
|
|
183
|
-
pixiApp.renderer.height / 2 + (config.translate?.y ?? 0),
|
|
184
|
-
)
|
|
283
|
+
layoutModel(pixiApp, loaded, modelSourceSize, config)
|
|
185
284
|
pixiApp.stage.addChild(loaded)
|
|
285
|
+
modelAttached = true
|
|
286
|
+
loaded.automator.autoUpdate = true
|
|
186
287
|
// Resume the phase group once a tap motion finishes playing.
|
|
187
288
|
loaded.on('motionFinish', () => {
|
|
188
289
|
if (tapPlaying) {
|
|
@@ -195,19 +296,18 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
195
296
|
}
|
|
196
297
|
|
|
197
298
|
void boot().catch(() => {
|
|
198
|
-
|
|
299
|
+
try {
|
|
300
|
+
destroyResources()
|
|
301
|
+
} finally {
|
|
302
|
+
if (!disposed) errorListener?.('load-failed')
|
|
303
|
+
}
|
|
199
304
|
})
|
|
200
305
|
|
|
201
306
|
return {
|
|
202
307
|
dispose() {
|
|
203
308
|
if (disposed) return
|
|
204
309
|
disposed = true
|
|
205
|
-
|
|
206
|
-
model = undefined
|
|
207
|
-
if (app !== undefined) {
|
|
208
|
-
app.destroy(true, { children: true })
|
|
209
|
-
app = undefined
|
|
210
|
-
}
|
|
310
|
+
destroyResources()
|
|
211
311
|
},
|
|
212
312
|
tap(x: number, y: number) {
|
|
213
313
|
const current = model
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { imageDimensions } from './image-dimensions.ts'
|
|
3
|
+
|
|
4
|
+
/** Minimal PNG header (signature + IHDR) for the given size. */
|
|
5
|
+
function pngHeader(width: number, height: number): Buffer {
|
|
6
|
+
const buf = Buffer.alloc(26)
|
|
7
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0)
|
|
8
|
+
buf.writeUInt32BE(13, 8)
|
|
9
|
+
buf.write('IHDR', 12)
|
|
10
|
+
buf.writeUInt32BE(width, 16)
|
|
11
|
+
buf.writeUInt32BE(height, 20)
|
|
12
|
+
return buf
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Minimal extended WebP header (RIFF + VP8X) for the given size. */
|
|
16
|
+
function webpExtendedHeader(width: number, height: number): Buffer {
|
|
17
|
+
const buf = Buffer.alloc(30)
|
|
18
|
+
buf.write('RIFF', 0)
|
|
19
|
+
buf.writeUInt32LE(30 - 8, 4)
|
|
20
|
+
buf.write('WEBP', 8)
|
|
21
|
+
buf.write('VP8X', 12)
|
|
22
|
+
buf[20] = 0 // flags: none
|
|
23
|
+
buf.writeUIntLE(width - 1, 24, 3)
|
|
24
|
+
buf.writeUIntLE(height - 1, 27, 3)
|
|
25
|
+
return buf
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal lossless WebP header (RIFF + VP8L). */
|
|
29
|
+
function webpLosslessHeader(width: number, height: number): Buffer {
|
|
30
|
+
const buf = Buffer.alloc(25)
|
|
31
|
+
buf.write('RIFF', 0)
|
|
32
|
+
buf.writeUInt32LE(25 - 8, 4)
|
|
33
|
+
buf.write('WEBP', 8)
|
|
34
|
+
buf.write('VP8L', 12)
|
|
35
|
+
buf[20] = 0x2f
|
|
36
|
+
const bits = (width - 1) | ((height - 1) << 14)
|
|
37
|
+
buf.writeUInt32LE(bits, 21)
|
|
38
|
+
return buf
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal lossy WebP header (RIFF + VP8 ) — low 14 bits carry the size. */
|
|
42
|
+
function webpLossyHeader(width: number, height: number): Buffer {
|
|
43
|
+
const buf = Buffer.alloc(30)
|
|
44
|
+
buf.write('RIFF', 0)
|
|
45
|
+
buf.writeUInt32LE(30 - 8, 4)
|
|
46
|
+
buf.write('WEBP', 8)
|
|
47
|
+
buf.write('VP8 ', 12)
|
|
48
|
+
buf.writeUInt16LE(width & 0x3fff, 26)
|
|
49
|
+
buf.writeUInt16LE(height & 0x3fff, 28)
|
|
50
|
+
return buf
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('imageDimensions (decoration strip geometry)', () => {
|
|
54
|
+
it('reads a PNG header', () => {
|
|
55
|
+
expect(imageDimensions(pngHeader(256, 48))).toEqual({ width: 256, height: 48 })
|
|
56
|
+
expect(imageDimensions(pngHeader(64, 48))).toEqual({ width: 64, height: 48 })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reads an extended WebP header (VP8X)', () => {
|
|
60
|
+
expect(imageDimensions(webpExtendedHeader(128, 64))).toEqual({ width: 128, height: 64 })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reads a lossless WebP header (VP8L)', () => {
|
|
64
|
+
expect(imageDimensions(webpLosslessHeader(300, 200))).toEqual({ width: 300, height: 200 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reads a lossy WebP header (VP8 )', () => {
|
|
68
|
+
expect(imageDimensions(webpLossyHeader(80, 40))).toEqual({ width: 80, height: 40 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns undefined for non-image bytes and truncated headers', () => {
|
|
72
|
+
expect(imageDimensions(Buffer.from('not-an-image'))).toBeUndefined()
|
|
73
|
+
expect(imageDimensions(Buffer.alloc(0))).toBeUndefined()
|
|
74
|
+
expect(imageDimensions(Buffer.from([0x89, 0x50]))).toBeUndefined()
|
|
75
|
+
// PNG magic but truncated before IHDR width/height.
|
|
76
|
+
expect(imageDimensions(pngHeader(10, 10).subarray(0, 10))).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns undefined for a RIFF buffer that is not WebP', () => {
|
|
80
|
+
const riff = Buffer.alloc(16)
|
|
81
|
+
riff.write('RIFF', 0)
|
|
82
|
+
riff.write('AVI ', 8)
|
|
83
|
+
expect(imageDimensions(riff)).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal PNG/WebP dimension reader — header-only, no decoding, no
|
|
3
|
+
* dependencies. Used by the decoration registry to verify a strip's actual
|
|
4
|
+
* pixel geometry matches its descriptor (single-row sprite strip; the client
|
|
5
|
+
* renders by frame-column offsets, so a mismatched strip silently shows the
|
|
6
|
+
* wrong frames). Parsing is best-effort: an unrecognized or truncated header
|
|
7
|
+
* returns undefined (the caller decides whether to warn).
|
|
8
|
+
*
|
|
9
|
+
* PNG: signature (8) + IHDR chunk — length (4) + 'IHDR' (4) + width (4) +
|
|
10
|
+
* height (4), both big-endian uint32 at fixed offsets 16/20.
|
|
11
|
+
* WebP: RIFF header (12) + chunk — 'VP8X' extended (width-1/height-1 as
|
|
12
|
+
* little-endian uint24 at 24/27), 'VP8L' lossless (packed 14-bit dims at
|
|
13
|
+
* 21), or 'VP8 ' lossy (frame header, low 14 bits of the uint16 at 26/28).
|
|
14
|
+
* @module @linxin666/dsh-pet/image-dimensions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
18
|
+
|
|
19
|
+
export interface ImageDimensions {
|
|
20
|
+
width: number
|
|
21
|
+
height: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read the pixel size of a PNG buffer, or undefined when unrecognized. */
|
|
25
|
+
function pngDimensions(buf: Buffer): ImageDimensions | undefined {
|
|
26
|
+
if (buf.length < 24) return undefined
|
|
27
|
+
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) return undefined
|
|
28
|
+
if (buf.toString('ascii', 12, 16) !== 'IHDR') return undefined
|
|
29
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the pixel size of a WebP buffer, or undefined when unrecognized. */
|
|
33
|
+
function webpDimensions(buf: Buffer): ImageDimensions | undefined {
|
|
34
|
+
if (buf.length < 21) return undefined
|
|
35
|
+
if (buf.toString('ascii', 0, 4) !== 'RIFF') return undefined
|
|
36
|
+
if (buf.toString('ascii', 8, 12) !== 'WEBP') return undefined
|
|
37
|
+
const fourcc = buf.toString('ascii', 12, 16)
|
|
38
|
+
if (fourcc === 'VP8X') {
|
|
39
|
+
// Extended header: 1-byte flags at 20, then width-1/height-1 uint24 LE.
|
|
40
|
+
if (buf.length < 30) return undefined
|
|
41
|
+
return {
|
|
42
|
+
width: 1 + buf.readUIntLE(24, 3),
|
|
43
|
+
height: 1 + buf.readUIntLE(27, 3),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (fourcc === 'VP8L') {
|
|
47
|
+
// Lossless header: 0x2f marker at 20, then 14-bit width / 14-bit height
|
|
48
|
+
// packed into the little-endian uint32 at 21.
|
|
49
|
+
if (buf.length < 25) return undefined
|
|
50
|
+
const bits = buf.readUInt32LE(21)
|
|
51
|
+
return {
|
|
52
|
+
width: 1 + (bits & 0x3fff),
|
|
53
|
+
height: 1 + ((bits >>> 14) & 0x3fff),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (fourcc === 'VP8 ') {
|
|
57
|
+
// Lossy frame header: 3-byte tag + 3-byte start code, then width/height
|
|
58
|
+
// as uint16 LE whose low 14 bits carry the dimension.
|
|
59
|
+
if (buf.length < 30) return undefined
|
|
60
|
+
return {
|
|
61
|
+
width: buf.readUInt16LE(26) & 0x3fff,
|
|
62
|
+
height: buf.readUInt16LE(28) & 0x3fff,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read image pixel dimensions from a PNG or WebP buffer. Returns undefined
|
|
70
|
+
* for formats this reader does not recognize (never throws). Callers treat
|
|
71
|
+
* undefined as "cannot verify", not as an error.
|
|
72
|
+
*/
|
|
73
|
+
export function imageDimensions(buf: Buffer): ImageDimensions | undefined {
|
|
74
|
+
if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF') return webpDimensions(buf)
|
|
75
|
+
return pngDimensions(buf)
|
|
76
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -172,7 +172,7 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
|
|
|
172
172
|
// pattern as dsh-remote-web-ui's /api/pair family). The routes are
|
|
173
173
|
// registered while the plugin is enabled; toggling the setting off makes
|
|
174
174
|
// the pet API disappear until it is re-enabled.
|
|
175
|
-
const routes = makePetRoutes({ service })
|
|
175
|
+
const routes = makePetRoutes({ service, ctx })
|
|
176
176
|
let disposeRoutes: (() => void) | undefined
|
|
177
177
|
const syncRoutes = (): void => {
|
|
178
178
|
const enabled = current().enabled ?? true
|
package/src/registry.test.ts
CHANGED
|
@@ -597,10 +597,26 @@ describe('voice packs (pet-center M4, issue #677)', () => {
|
|
|
597
597
|
})
|
|
598
598
|
})
|
|
599
599
|
describe('status decorations (pet-center M5, #567)', () => {
|
|
600
|
-
|
|
600
|
+
/** A minimal valid PNG header (signature + IHDR) with the given size. */
|
|
601
|
+
function pngHeader(width: number, height: number): Buffer {
|
|
602
|
+
const buf = Buffer.alloc(26)
|
|
603
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0)
|
|
604
|
+
buf.writeUInt32BE(13, 8) // IHDR chunk length
|
|
605
|
+
buf.write('IHDR', 12) // chunk type
|
|
606
|
+
buf.writeUInt32BE(width, 16) // width
|
|
607
|
+
buf.writeUInt32BE(height, 20) // height
|
|
608
|
+
return buf
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** A strip whose pixel geometry matches baseManifest() (64x48 cell, 4 cols). */
|
|
612
|
+
function matchingStrip(): Buffer {
|
|
613
|
+
return pngHeader(64 * 4, 48)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function writeDecoration(dir: string, name: string, manifest: Record<string, unknown>, strip: Buffer | string = 'whale-frames.png'): void {
|
|
601
617
|
mkdirSync(join(dir, name), { recursive: true })
|
|
602
618
|
writeFileSync(join(dir, name, 'decoration.json'), JSON.stringify(manifest), 'utf8')
|
|
603
|
-
writeFileSync(join(dir, name,
|
|
619
|
+
writeFileSync(join(dir, name, manifest.entry as string), strip)
|
|
604
620
|
}
|
|
605
621
|
|
|
606
622
|
const baseManifest = () => ({
|
|
@@ -618,7 +634,7 @@ describe('status decorations (pet-center M5, #567)', () => {
|
|
|
618
634
|
const root = tempDir()
|
|
619
635
|
try {
|
|
620
636
|
const assets = join(root, 'assets')
|
|
621
|
-
writeDecoration(join(assets, 'decorations'), 'whale', baseManifest())
|
|
637
|
+
writeDecoration(join(assets, 'decorations'), 'whale', baseManifest(), matchingStrip())
|
|
622
638
|
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
623
639
|
const entry = registry.decorationById?.('whale')
|
|
624
640
|
expect(entry).toBeDefined()
|
|
@@ -635,9 +651,9 @@ describe('status decorations (pet-center M5, #567)', () => {
|
|
|
635
651
|
it('lets a user decoration override the built-in by id', () => {
|
|
636
652
|
const root = tempDir()
|
|
637
653
|
try {
|
|
638
|
-
writeDecoration(join(root, 'assets', 'decorations'), 'whale', baseManifest())
|
|
654
|
+
writeDecoration(join(root, 'assets', 'decorations'), 'whale', baseManifest(), matchingStrip())
|
|
639
655
|
const dsh = join(root, 'dsh')
|
|
640
|
-
writeDecoration(join(dsh, 'decorations'), 'whale', { ...baseManifest(), displayName: '家用鲸鱼' })
|
|
656
|
+
writeDecoration(join(dsh, 'decorations'), 'whale', { ...baseManifest(), displayName: '家用鲸鱼' }, matchingStrip())
|
|
641
657
|
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: dsh })
|
|
642
658
|
expect(registry.decorationById?.('whale')?.id).toBe('whale')
|
|
643
659
|
expect(registry.warnings.some(w => w.includes('user decoration whale overrides'))).toBe(true)
|
|
@@ -687,4 +703,41 @@ describe('status decorations (pet-center M5, #567)', () => {
|
|
|
687
703
|
rmSync(root, { recursive: true, force: true })
|
|
688
704
|
}
|
|
689
705
|
})
|
|
706
|
+
|
|
707
|
+
it('warns and keeps a decoration whose strip geometry mismatches the descriptor', () => {
|
|
708
|
+
const root = tempDir()
|
|
709
|
+
try {
|
|
710
|
+
// Declared 64x48 cell x 4 columns = 256x48; the file is only 128x48
|
|
711
|
+
// (2 frames worth) — the client would silently render half the frames.
|
|
712
|
+
const dir = join(root, 'assets', 'decorations', 'short')
|
|
713
|
+
mkdirSync(dir, { recursive: true })
|
|
714
|
+
writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
|
|
715
|
+
writeFileSync(join(dir, 'whale-frames.png'), pngHeader(128, 48))
|
|
716
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
717
|
+
// Warn-and-keep: the entry still lists (mirroring the missing-strip
|
|
718
|
+
// discipline) and the warning names the mismatch.
|
|
719
|
+
expect(registry.decorationById?.('whale')).toBeDefined()
|
|
720
|
+
expect(registry.warnings.some(w => w.includes('does not match cell'))).toBe(true)
|
|
721
|
+
} finally {
|
|
722
|
+
rmSync(root, { recursive: true, force: true })
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('does not warn when a matching strip is undecodable (non-image bytes)', () => {
|
|
727
|
+
const root = tempDir()
|
|
728
|
+
try {
|
|
729
|
+
const dir = join(root, 'assets', 'decorations', 'opaque')
|
|
730
|
+
mkdirSync(dir, { recursive: true })
|
|
731
|
+
writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
|
|
732
|
+
// Unrecognized bytes: the header reader returns undefined, so the scan
|
|
733
|
+
// stays silent (cannot verify != mismatch). Only the missing-file check
|
|
734
|
+
// applies.
|
|
735
|
+
writeFileSync(join(dir, 'whale-frames.png'), Buffer.from('not-an-image'))
|
|
736
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
737
|
+
expect(registry.decorationById?.('whale')).toBeDefined()
|
|
738
|
+
expect(registry.warnings.some(w => w.includes('does not match cell'))).toBe(false)
|
|
739
|
+
} finally {
|
|
740
|
+
rmSync(root, { recursive: true, force: true })
|
|
741
|
+
}
|
|
742
|
+
})
|
|
690
743
|
})
|
package/src/registry.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* @module @linxin666/dsh-pet/registry
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { existsSync,
|
|
30
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs'
|
|
31
31
|
import { homedir } from 'node:os'
|
|
32
32
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
@@ -35,6 +35,7 @@ import type { ActivityPhase, PetAnimation } from './state.ts'
|
|
|
35
35
|
import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
|
|
36
36
|
import { mergeVoicePacks, normalizeVoicePack, type PetPanelView, type VoicePack } from './voice-pack.ts'
|
|
37
37
|
import { parseDecorationManifest } from './decoration.ts'
|
|
38
|
+
import { imageDimensions } from './image-dimensions.ts'
|
|
38
39
|
import { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
|
|
39
40
|
import { dshHome } from './dsh-home.ts'
|
|
40
41
|
import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
|
|
@@ -687,6 +688,27 @@ function loadVoicePackFile(
|
|
|
687
688
|
/** Decoration asset URL prefix (served by the decoration route, M5). */
|
|
688
689
|
export const DECORATION_ASSET_PREFIX = '/api/pet/decoration'
|
|
689
690
|
|
|
691
|
+
/** Read the pixel dimensions of a decoration strip (PNG/WebP), if decodable. */
|
|
692
|
+
function readImageDimensions(file: string): { width: number; height: number } | undefined {
|
|
693
|
+
let header: Buffer
|
|
694
|
+
try {
|
|
695
|
+
// Only the header is needed for dimensions; cap the read so a huge or
|
|
696
|
+
// corrupt strip cannot balloon memory during the registry scan.
|
|
697
|
+
const fd = openSync(file, 'r')
|
|
698
|
+
try {
|
|
699
|
+
header = Buffer.alloc(64)
|
|
700
|
+
const read = readSync(fd, header, 0, header.length, 0)
|
|
701
|
+
if (read < 0) return undefined
|
|
702
|
+
header = header.subarray(0, read)
|
|
703
|
+
} finally {
|
|
704
|
+
closeSync(fd)
|
|
705
|
+
}
|
|
706
|
+
} catch {
|
|
707
|
+
return undefined
|
|
708
|
+
}
|
|
709
|
+
return imageDimensions(header)
|
|
710
|
+
}
|
|
711
|
+
|
|
690
712
|
/**
|
|
691
713
|
* Scan one directory of decoration folders ('decoration.json' + strip).
|
|
692
714
|
* Later scans override earlier ones on id collision; a bad descriptor warns
|
|
@@ -730,6 +752,23 @@ function scanDecorationDir(dir: string, options: { warnings?: string[]; diagnost
|
|
|
730
752
|
const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry
|
|
731
753
|
options.warnings?.push(message)
|
|
732
754
|
options.diagnostics?.push({ level: 'warning', source: entryDir, message })
|
|
755
|
+
} else {
|
|
756
|
+
// Geometry check: the client renders the strip as a single row of
|
|
757
|
+
// 'columns' frames (background-position advances by frame width only),
|
|
758
|
+
// so the strip must be exactly cell.width * columns wide and cell.height
|
|
759
|
+
// tall. A mismatched strip silently shows the wrong/partial frames —
|
|
760
|
+
// warn-and-keep, mirroring the missing-strip discipline (never throw).
|
|
761
|
+
const actual = readImageDimensions(join(entryDir, manifest.entry))
|
|
762
|
+
if (actual !== undefined) {
|
|
763
|
+
const expectedWidth = manifest.cell.width * manifest.columns
|
|
764
|
+
if (actual.width !== expectedWidth || actual.height !== manifest.cell.height) {
|
|
765
|
+
const message = 'decoration ' + manifest.id + ': strip ' + actual.width + 'x' + actual.height
|
|
766
|
+
+ ' does not match cell ' + manifest.cell.width + 'x' + manifest.cell.height + ' x ' + manifest.columns
|
|
767
|
+
+ ' columns (expected ' + expectedWidth + 'x' + manifest.cell.height + '); frames will render wrong'
|
|
768
|
+
options.warnings?.push(message)
|
|
769
|
+
options.diagnostics?.push({ level: 'warning', source: entryDir, message })
|
|
770
|
+
}
|
|
771
|
+
}
|
|
733
772
|
}
|
|
734
773
|
entries.push({
|
|
735
774
|
apiVersion: PET_DECORATION_API_VERSION,
|