@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.
Files changed (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +110 -28
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +164 -34
  7. package/lib/types/access.d.ts +16 -0
  8. package/lib/types/access.d.ts.map +1 -0
  9. package/lib/types/access.js +20 -0
  10. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PetSettingsCard.js +8 -2
  12. package/lib/types/client/PetSprite.d.ts.map +1 -1
  13. package/lib/types/client/PetSprite.js +10 -2
  14. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  15. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  16. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  17. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  18. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  19. package/lib/types/client/renderers/live2d.js +120 -24
  20. package/lib/types/image-dimensions.d.ts +26 -0
  21. package/lib/types/image-dimensions.d.ts.map +1 -0
  22. package/lib/types/image-dimensions.js +77 -0
  23. package/lib/types/index.js +1 -1
  24. package/lib/types/registry.d.ts.map +1 -1
  25. package/lib/types/registry.js +43 -1
  26. package/lib/types/routes.d.ts +6 -1
  27. package/lib/types/routes.d.ts.map +1 -1
  28. package/lib/types/routes.js +36 -33
  29. package/package.json +1 -1
  30. package/src/access.ts +40 -0
  31. package/src/client/PetSettingsCard.tsx +8 -2
  32. package/src/client/PetSprite.test.tsx +71 -0
  33. package/src/client/PetSprite.tsx +10 -2
  34. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  35. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  36. package/src/client/renderers/live2d/runtime.ts +8 -2
  37. package/src/client/renderers/live2d.test.ts +211 -8
  38. package/src/client/renderers/live2d.ts +129 -29
  39. package/src/client/settings-card.module.css +2 -0
  40. package/src/image-dimensions.test.ts +85 -0
  41. package/src/image-dimensions.ts +76 -0
  42. package/src/index.ts +1 -1
  43. package/src/registry.test.ts +58 -5
  44. package/src/registry.ts +40 -1
  45. 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
- await pixiApp.init({
147
- width: Math.max(1, ctx.container.clientWidth || 160),
148
- height: Math.max(1, ctx.container.clientHeight || 174),
149
- backgroundAlpha: 0,
150
- antialias: true,
151
- autoDensity: true,
152
- preference: 'webgl',
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(true, { children: true })
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
- pixiApp.destroy(true, { children: true })
274
+ destroyResources()
169
275
  return
170
276
  }
171
- app = pixiApp
172
- model = loaded
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
- const fit = Math.min(
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
- if (!disposed) errorListener?.('load-failed')
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
- unsubscribe?.()
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
@@ -354,6 +354,8 @@
354
354
  }
355
355
 
356
356
  .selectOption {
357
+ /* 弹层是 max-height 受限的 flex 列:选项必须保持自然行高,超出交给弹层滚动 */
358
+ flex-shrink: 0;
357
359
  border-radius: 6px;
358
360
  padding: 6px 10px;
359
361
  font-size: 13px;
@@ -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
@@ -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
- function writeDecoration(dir: string, name: string, manifest: Record<string, unknown>, strip = 'whale-frames.png'): void {
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, strip), 'png', 'utf8')
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, readdirSync, readFileSync, statSync } from 'node:fs'
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,