@linxin666/dsh-pet 0.3.5 → 0.3.9

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 (88) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/contracts/voice-pack-v1.schema.json +30 -0
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +330 -103
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +145 -28
  9. package/lib/invariant.js +1 -1
  10. package/lib/live2d-vendor.js +106 -95
  11. package/lib/live2d-vendor.js.map +1 -1
  12. package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
  13. package/lib/types/access.d.ts +5 -4
  14. package/lib/types/access.d.ts.map +1 -1
  15. package/lib/types/access.js +2 -12
  16. package/lib/types/affinity.d.ts +2 -0
  17. package/lib/types/affinity.d.ts.map +1 -1
  18. package/lib/types/affinity.js +12 -0
  19. package/lib/types/announce.d.ts +53 -0
  20. package/lib/types/announce.d.ts.map +1 -0
  21. package/lib/types/announce.js +80 -0
  22. package/lib/types/client/PetDockEntry.d.ts +14 -5
  23. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  24. package/lib/types/client/PetDockEntry.js +6 -5
  25. package/lib/types/client/PetSettingsCard.d.ts +2 -1
  26. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PetSprite.d.ts +8 -0
  28. package/lib/types/client/PetSprite.d.ts.map +1 -1
  29. package/lib/types/client/PetSprite.js +36 -5
  30. package/lib/types/client/index.d.ts +8 -5
  31. package/lib/types/client/index.d.ts.map +1 -1
  32. package/lib/types/client/index.js +11 -5
  33. package/lib/types/client/locales.d.ts +20 -2
  34. package/lib/types/client/locales.d.ts.map +1 -1
  35. package/lib/types/client/locales.js +20 -2
  36. package/lib/types/client/pet-store.d.ts +1 -1
  37. package/lib/types/client/pet-store.d.ts.map +1 -1
  38. package/lib/types/client/pet-store.js +1 -1
  39. package/lib/types/client/renderers/frames2d.d.ts +12 -0
  40. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  41. package/lib/types/client/renderers/frames2d.js +143 -18
  42. package/lib/types/client/settings-form.d.ts +35 -43
  43. package/lib/types/client/settings-form.d.ts.map +1 -1
  44. package/lib/types/client/settings-form.js +60 -53
  45. package/lib/types/index.d.ts.map +1 -1
  46. package/lib/types/index.js +10 -9
  47. package/lib/types/ledger.d.ts +4 -2
  48. package/lib/types/ledger.d.ts.map +1 -1
  49. package/lib/types/ledger.js +4 -4
  50. package/lib/types/pair-access.d.ts +35 -0
  51. package/lib/types/pair-access.d.ts.map +1 -0
  52. package/lib/types/pair-access.js +19 -0
  53. package/lib/types/remarks.d.ts +1 -1
  54. package/lib/types/remarks.d.ts.map +1 -1
  55. package/lib/types/remarks.js +11 -2
  56. package/lib/types/service.d.ts +18 -0
  57. package/lib/types/service.d.ts.map +1 -1
  58. package/lib/types/service.js +26 -1
  59. package/lib/types/voice-pack.d.ts +5 -0
  60. package/lib/types/voice-pack.d.ts.map +1 -1
  61. package/lib/types/voice-pack.js +36 -2
  62. package/package.json +17 -13
  63. package/src/access.ts +7 -27
  64. package/src/affinity.ts +13 -0
  65. package/src/announce.ts +103 -0
  66. package/src/client/PetDockEntry.test.tsx +1 -1
  67. package/src/client/PetDockEntry.tsx +15 -4
  68. package/src/client/PetSettingsCard.tsx +2 -1
  69. package/src/client/PetSprite.test.tsx +36 -0
  70. package/src/client/PetSprite.tsx +75 -4
  71. package/src/client/gameplay-hud.test.tsx +1 -1
  72. package/src/client/index.test.tsx +2 -2
  73. package/src/client/index.ts +17 -6
  74. package/src/client/locales.ts +20 -2
  75. package/src/client/pet-store.ts +2 -2
  76. package/src/client/pet.module.css +104 -0
  77. package/src/client/renderers/frames2d.test.ts +101 -0
  78. package/src/client/renderers/frames2d.ts +138 -18
  79. package/src/client/settings-form.ts +78 -92
  80. package/src/dsh-home.ts +1 -1
  81. package/src/index.ts +17 -15
  82. package/src/ledger.ts +6 -4
  83. package/src/pair-access.ts +49 -0
  84. package/src/remarks.test.ts +10 -0
  85. package/src/remarks.ts +9 -2
  86. package/src/service.ts +32 -2
  87. package/src/voice-pack.test.ts +39 -0
  88. package/src/voice-pack.ts +37 -2
@@ -15,6 +15,15 @@ export const zh = {
15
15
  'pet.namePlaceholder': '输入新名字',
16
16
  'pet.summon': '召唤{name}',
17
17
  'pet.rank': '亲密度 {rank}',
18
+ 'pet.rank.name.幼鲸': '幼鲸',
19
+ 'pet.rank.name.伙伴': '伙伴',
20
+ 'pet.rank.name.挚友': '挚友',
21
+ 'pet.rank.name.深海羁绊': '深海羁绊',
22
+ 'pet.rank.name.心有灵犀': '心有灵犀',
23
+ 'pet.rank.name.传说羁绊': '传说羁绊',
24
+ 'pet.rank.name.神话羁绊': '神话羁绊',
25
+ 'pet.rank.name.永恒之契': '永恒之契',
26
+ 'pet.rank.name.鲸生共渡': '鲸生共渡',
18
27
  'pet.points': '{points} 点',
19
28
  'pet.treats': '小鱼干 ×{n}',
20
29
  'pet.state.loading': '宠物正在赶来…',
@@ -67,7 +76,7 @@ export const zh = {
67
76
  'settings.off': '关',
68
77
  'settings.overridden': '已覆盖',
69
78
  'settings.reset': '恢复默认',
70
- 'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 ~/.dsh/settings.yaml 直接配置,或为 dsh-host-apiproxy 的 WEB_SETTINGS_NAMESPACES 白名单补充本命名空间后重启。',
79
+ 'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 $DSH_HOME/settings.yaml 直接配置,或确认提供该命名空间的插件已挂载其设置域并重启。',
71
80
  'settings.readOnly': '当前部署的设置只读。',
72
81
  'settings.expand': '展开设置',
73
82
  'settings.collapse': '收起设置',
@@ -88,6 +97,15 @@ export const en = {
88
97
  'pet.namePlaceholder': 'Enter a new name',
89
98
  'pet.summon': 'Summon {name}',
90
99
  'pet.rank': 'Affinity {rank}',
100
+ 'pet.rank.name.幼鲸': 'Baby Whale',
101
+ 'pet.rank.name.伙伴': 'Companion',
102
+ 'pet.rank.name.挚友': 'Close Friend',
103
+ 'pet.rank.name.深海羁绊': 'Deep Sea Bond',
104
+ 'pet.rank.name.心有灵犀': 'Kindred Spirit',
105
+ 'pet.rank.name.传说羁绊': 'Legendary Bond',
106
+ 'pet.rank.name.神话羁绊': 'Mythic Bond',
107
+ 'pet.rank.name.永恒之契': 'Eternal Covenant',
108
+ 'pet.rank.name.鲸生共渡': 'Lifelong Companion',
91
109
  'pet.points': '{points} pts',
92
110
  'pet.treats': 'Treats ×{n}',
93
111
  'pet.state.loading': 'The pet is on its way…',
@@ -140,7 +158,7 @@ export const en = {
140
158
  'settings.off': 'Off',
141
159
  'settings.overridden': 'Overridden',
142
160
  'settings.reset': 'Reset to default',
143
- 'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit ~/.dsh/settings.yaml directly, or add the namespace to dsh-host-apiproxy\'s WEB_SETTINGS_NAMESPACES allowlist and restart.',
161
+ 'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit $DSH_HOME/settings.yaml directly, or confirm that the plugin owning the namespace is mounted with its settings domain and restart.',
144
162
  'settings.readOnly': 'This deployment stores settings read-only.',
145
163
  'settings.expand': 'Show settings',
146
164
  'settings.collapse': 'Hide settings',
@@ -6,8 +6,8 @@
6
6
  * @module @linxin666/dsh-pet/client/pet-store
7
7
  */
8
8
 
9
- import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
10
- import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-runtime/client'
9
+ import { defineStore } from '@deepseek-ai/dsh-client-store'
10
+ import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-store'
11
11
  import type { PetGameplayStateView, PetStateView } from '../service.ts'
12
12
  import type { PetInteraction } from '../affinity.ts'
13
13
  import type { PetDefinition } from '../registry.ts'
@@ -604,3 +604,107 @@
604
604
  }
605
605
  }
606
606
 
607
+
608
+ /* 公告气泡(dsh-usage 联动):兄弟插件推送的结构化事实(余额 / 套餐用量)。
609
+ 与状态气泡同一片蓝黑玻璃家族,但自有版式:标题 + 数值两端对齐,
610
+ 套餐窗口带微型计量条,色调按 ok/warn/low 染描边与计量条。
611
+ 自带长驻动画(进场后保持),不走 2.6s 反馈气泡的淡出轨道。 */
612
+ .bubbleUsage {
613
+ display: flex;
614
+ flex-direction: column;
615
+ align-items: stretch;
616
+ gap: 3px;
617
+ min-width: 128px;
618
+ max-width: min(280px, calc(100vw - 24px));
619
+ padding: 6px 12px;
620
+ white-space: nowrap;
621
+ background: linear-gradient(160deg, rgba(19, 28, 54, 0.92), rgba(7, 11, 26, 0.95));
622
+ border: 1px solid rgba(126, 152, 255, 0.45);
623
+ box-shadow:
624
+ 0 4px 12px rgba(2, 6, 23, 0.35),
625
+ inset 0 1px 0 rgba(226, 232, 255, 0.1),
626
+ 0 0 12px rgba(77, 107, 254, 0.18);
627
+ backdrop-filter: blur(8px);
628
+ letter-spacing: 0.02em;
629
+ animation: pet-usage-in 280ms cubic-bezier(0.22, 1, 0.36, 1);
630
+ }
631
+
632
+ .bubbleUsageHead {
633
+ display: flex;
634
+ align-items: baseline;
635
+ justify-content: space-between;
636
+ gap: 10px;
637
+ }
638
+
639
+ .bubbleUsageTitle {
640
+ overflow: hidden;
641
+ text-overflow: ellipsis;
642
+ opacity: 0.85;
643
+ }
644
+
645
+ .bubbleUsageValue {
646
+ font-weight: 600;
647
+ font-variant-numeric: tabular-nums;
648
+ flex-shrink: 0;
649
+ }
650
+
651
+ .bubbleUsageNote {
652
+ overflow: hidden;
653
+ text-overflow: ellipsis;
654
+ font-size: 10px;
655
+ opacity: 0.55;
656
+ }
657
+
658
+ .bubbleUsageMeter {
659
+ display: block;
660
+ height: 4px;
661
+ border-radius: 999px;
662
+ background: rgba(226, 232, 255, 0.14);
663
+ overflow: hidden;
664
+ }
665
+
666
+ .bubbleUsageMeterFill {
667
+ display: block;
668
+ height: 100%;
669
+ border-radius: 999px;
670
+ background: #6ee7b7;
671
+ transition: width 300ms ease;
672
+ }
673
+
674
+ /* 色调:ok 沿用品牌蓝描边;warn 琥珀、low 珊瑚红,染描边与计量条。 */
675
+ .bubbleUsageOk {
676
+ border-color: rgba(126, 152, 255, 0.45);
677
+ }
678
+
679
+ .bubbleUsageWarn {
680
+ border-color: rgba(251, 191, 36, 0.55);
681
+ }
682
+
683
+ .bubbleUsageWarn .bubbleUsageMeterFill {
684
+ background: #fbbf24;
685
+ }
686
+
687
+ .bubbleUsageLow {
688
+ border-color: rgba(248, 113, 113, 0.6);
689
+ }
690
+
691
+ .bubbleUsageLow .bubbleUsageMeterFill {
692
+ background: #f87171;
693
+ }
694
+
695
+ @keyframes pet-usage-in {
696
+ from {
697
+ opacity: 0;
698
+ transform: translateY(6px) scale(0.96);
699
+ }
700
+ to {
701
+ opacity: 1;
702
+ transform: translateY(0) scale(1);
703
+ }
704
+ }
705
+
706
+ @media (prefers-reduced-motion: reduce) {
707
+ .bubbleUsage {
708
+ animation: none;
709
+ }
710
+ }
@@ -135,3 +135,104 @@ describe('frames2dRenderer', () => {
135
135
  expect(container.querySelector('img')).toBeNull()
136
136
  })
137
137
  })
138
+
139
+ describe('frames2dRenderer canvas bitmap path', () => {
140
+ // The canvas branch needs createImageBitmap + fetch + a real 2D context;
141
+ // jsdom has none of them, so each test installs fakes and restores after.
142
+ const flush = async (): Promise<void> => {
143
+ await Promise.resolve()
144
+ await Promise.resolve()
145
+ await Promise.resolve()
146
+ }
147
+
148
+ let draws = 0
149
+ const bitmaps: { width: number; height: number; close: ReturnType<typeof vi.fn> }[] = []
150
+
151
+ beforeEach(() => {
152
+ vi.useFakeTimers()
153
+ document.body.innerHTML = ''
154
+ draws = 0
155
+ bitmaps.length = 0
156
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, blob: async () => ({}) })))
157
+ vi.stubGlobal('createImageBitmap', vi.fn(async () => {
158
+ const bmp = { width: 32, height: 32, close: vi.fn() }
159
+ bitmaps.push(bmp)
160
+ return bmp
161
+ }))
162
+ ;(HTMLCanvasElement.prototype as unknown as { getContext: unknown }).getContext = () => ({
163
+ clearRect: () => {},
164
+ drawImage: () => {
165
+ draws += 1
166
+ },
167
+ })
168
+ })
169
+
170
+ afterEach(() => {
171
+ vi.unstubAllGlobals()
172
+ Reflect.deleteProperty(HTMLCanvasElement.prototype as unknown as Record<string, unknown>, 'getContext')
173
+ vi.useRealTimers()
174
+ })
175
+
176
+ function canvasSetup() {
177
+ const container = document.createElement('div')
178
+ document.body.appendChild(container)
179
+ const ctx: PetRendererContext = {
180
+ petId: 'miku',
181
+ assetBase: '/pet/miku',
182
+ container,
183
+ phase: createPhaseStream('idle'),
184
+ interact: () => {},
185
+ onCleanup: () => {},
186
+ }
187
+ return { ctx, container }
188
+ }
189
+
190
+ it('mounts a canvas instead of an img and paints advanced frames via drawImage', async () => {
191
+ const { ctx, container } = canvasSetup()
192
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
193
+ expect(container.querySelector('canvas')).not.toBeNull()
194
+ expect(container.querySelector('img')).toBeNull()
195
+ await flush()
196
+ // Warm pass decoded every configured frame exactly once; the first frame
197
+ // of the idle track painted during mount.
198
+ expect(bitmaps.length).toBe(6)
199
+ const paintsAtMount = draws
200
+ expect(paintsAtMount).toBeGreaterThanOrEqual(1)
201
+ vi.advanceTimersByTime(100)
202
+ await flush()
203
+ expect(draws).toBeGreaterThan(paintsAtMount)
204
+ handle.dispose()
205
+ })
206
+
207
+ it('sizes the backing store from decoded dimensions and releases bitmaps on dispose', async () => {
208
+ const { ctx, container } = canvasSetup()
209
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
210
+ await flush()
211
+ const canvas = container.querySelector('canvas')!
212
+ expect(canvas.width).toBe(32)
213
+ expect(canvas.height).toBe(32)
214
+ handle.dispose()
215
+ await flush()
216
+ for (const bmp of bitmaps) expect(bmp.close).toHaveBeenCalled()
217
+ })
218
+
219
+ it('keeps painting through a phase switch without touching the DOM tree', async () => {
220
+ const { container } = canvasSetup()
221
+ const stream = createPhaseStream('idle')
222
+ const ctx: PetRendererContext = {
223
+ petId: 'miku', assetBase: '/pet/miku', container, phase: stream, interact: () => {}, onCleanup: () => {},
224
+ }
225
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
226
+ await flush()
227
+ const nodesBefore = container.querySelectorAll('*').length
228
+ stream.push('thinking' as ActivityPhase)
229
+ expect(handle.currentTrack()).toBe('work')
230
+ await flush()
231
+ vi.advanceTimersByTime(100)
232
+ await flush()
233
+ // Steady-state playback is DOM-mutation-free: no element added or removed.
234
+ expect(container.querySelectorAll('*').length).toBe(nodesBefore)
235
+ expect(draws).toBeGreaterThanOrEqual(3)
236
+ handle.dispose()
237
+ })
238
+ })
@@ -8,6 +8,18 @@
8
8
  * phase-mapped track. Rendering never throws: a broken track list degrades
9
9
  * to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
10
10
  * playback chain after timer throttling.
11
+ *
12
+ * Frame presentation has two modes picked once at mount by capability
13
+ * probing:
14
+ * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
15
+ * exist): every frame is decoded exactly once into an ImageBitmap during
16
+ * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
+ * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
+ * <img>.src per frame drove image decode + invalidation every tick).
19
+ * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
+ * historical behavior - cache-warm Image elements plus guarded src swaps,
21
+ * so environments without modern decoding keep working unchanged.
22
+ *
11
23
  * @module @linxin666/dsh-pet/client/renderers/frames2d
12
24
  */
13
25
 
@@ -69,6 +81,12 @@ function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
69
81
  return { tracks, phases: phases as PetFrames2dConfig['phases'] }
70
82
  }
71
83
 
84
+ interface DecodedFrame {
85
+ source: ImageBitmap | HTMLImageElement
86
+ width: number
87
+ height: number
88
+ }
89
+
72
90
  export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
73
91
  id: 'frames2d',
74
92
  apiVersion: PET_RENDERER_API_VERSION,
@@ -79,23 +97,78 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
79
97
  && typeof window.matchMedia === 'function'
80
98
  && window.matchMedia('(prefers-reduced-motion: reduce)').matches
81
99
 
82
- const img = document.createElement('img')
83
- img.dataset.dshPetFrames2d = ctx.petId
84
- img.alt = ''
85
- img.draggable = false
86
- img.style.width = '100%'
87
- img.style.height = '100%'
88
- img.style.objectFit = 'contain'
89
- img.style.pointerEvents = 'none'
90
- ctx.container.appendChild(img)
91
-
92
- // Warm the browser cache for every frame up front (same-origin, small
93
- // webp files); playback itself swaps img.src without React state.
94
- for (const track of Object.values(config.tracks)) {
95
- for (const url of track.frames) {
96
- const pre = new Image()
97
- pre.src = url
100
+ // Capability probe: the canvas path needs decode-to-bitmap, same-origin
101
+ // fetch, and a real 2D context (jsdom returns null and falls back below).
102
+ let canvas: HTMLCanvasElement | null = null
103
+ let context2d: CanvasRenderingContext2D | null = null
104
+ let img: HTMLImageElement | null = null
105
+ try {
106
+ if (typeof createImageBitmap === 'function' && typeof fetch === 'function') {
107
+ const probe = document.createElement('canvas')
108
+ const c2d = probe.getContext('2d')
109
+ if (c2d !== null) {
110
+ canvas = probe
111
+ context2d = c2d
112
+ }
98
113
  }
114
+ } catch {
115
+ canvas = null
116
+ context2d = null
117
+ }
118
+
119
+ if (canvas !== null && context2d !== null) {
120
+ canvas.dataset.dshPetFrames2d = ctx.petId
121
+ canvas.draggable = false
122
+ canvas.style.width = '100%'
123
+ canvas.style.height = '100%'
124
+ canvas.style.objectFit = 'contain'
125
+ canvas.style.pointerEvents = 'none'
126
+ ctx.container.appendChild(canvas)
127
+ } else {
128
+ img = document.createElement('img')
129
+ img.dataset.dshPetFrames2d = ctx.petId
130
+ img.alt = ''
131
+ img.draggable = false
132
+ img.style.width = '100%'
133
+ img.style.height = '100%'
134
+ img.style.objectFit = 'contain'
135
+ img.style.pointerEvents = 'none'
136
+ ctx.container.appendChild(img)
137
+ }
138
+
139
+ // Decode cache: one concurrent decode per URL, memoized forever (frames
140
+ // are tiny same-origin webp files; failures resolve undefined and keep
141
+ // the last painted frame instead of breaking playback).
142
+ const decoding = new Map<string, Promise<DecodedFrame | undefined>>()
143
+ const decodedAll: Promise<void>[] = []
144
+ const loadFrame = (url: string): Promise<DecodedFrame | undefined> => {
145
+ const cached = decoding.get(url)
146
+ if (cached !== undefined) return cached
147
+ const job: Promise<DecodedFrame | undefined> = (async () => {
148
+ try {
149
+ const response = await fetch(url)
150
+ if (!response.ok) throw new Error('http ' + response.status)
151
+ const bitmap = await createImageBitmap(await response.blob())
152
+ return { source: bitmap, width: bitmap.width, height: bitmap.height }
153
+ } catch {
154
+ // Fail-open: classic Image decode keeps non-modern runtimes alive.
155
+ return await new Promise<DecodedFrame | undefined>((resolve) => {
156
+ try {
157
+ const pre = new Image()
158
+ pre.onload = (): void => {
159
+ resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
160
+ }
161
+ pre.onerror = (): void => resolve(undefined)
162
+ pre.src = url
163
+ } catch {
164
+ resolve(undefined)
165
+ }
166
+ })
167
+ }
168
+ })()
169
+ decoding.set(url, job)
170
+ decodedAll.push(job.then(() => undefined, () => undefined))
171
+ return job
99
172
  }
100
173
 
101
174
  let disposed = false
@@ -105,16 +178,42 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
105
178
  let frameIndex = 0
106
179
  let lastAdvance = Date.now()
107
180
  let override: string | undefined
181
+ let drawToken = 0
182
+ let lastDrawnUrl: string | undefined
108
183
 
109
184
  const trackForPhase = (phase: ActivityPhase): string => {
110
185
  const mapped = config.phases[phase]
111
186
  return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
112
187
  }
113
188
 
189
+ /** Canvas path: paints the newest requested frame; stale draws drop out. */
190
+ const paintCanvas = (url: string): void => {
191
+ if (context2d === null || canvas === null) return
192
+ const myToken = ++drawToken
193
+ void loadFrame(url).then((frame) => {
194
+ if (disposed || frame === undefined || myToken !== drawToken) return
195
+ if (lastDrawnUrl === url) return
196
+ lastDrawnUrl = url
197
+ // Resizing clears the canvas, so size only when it actually differs.
198
+ if (canvas.width !== frame.width || canvas.height !== frame.height) {
199
+ canvas.width = frame.width
200
+ canvas.height = frame.height
201
+ } else {
202
+ context2d.clearRect(0, 0, canvas.width, canvas.height)
203
+ }
204
+ context2d.drawImage(frame.source as CanvasImageSource, 0, 0)
205
+ }).catch(() => { /* keep last frame */ })
206
+ }
207
+
114
208
  const show = (trackId: string, index: number): void => {
115
209
  const def = config.tracks[trackId]
116
210
  const url = def?.frames[index]
117
- if (url !== undefined && img.getAttribute('src') !== url) img.src = url
211
+ if (url === undefined) return
212
+ if (img !== null) {
213
+ if (img.getAttribute('src') !== url) img.src = url
214
+ return
215
+ }
216
+ paintCanvas(url)
118
217
  }
119
218
 
120
219
  const schedule = (ms: number): void => {
@@ -180,6 +279,13 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
180
279
  }, WATCHDOG_MS)
181
280
  }
182
281
 
282
+ // Warm pass: decode every frame up front (tiny same-origin webp files)
283
+ // so loops and phase switches never wait on a first decode - same intent
284
+ // as the historical Image-cache warm loop, now feeding the decode cache.
285
+ for (const warmTrack of Object.values(config.tracks)) {
286
+ for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
287
+ }
288
+
183
289
  play(track)
184
290
 
185
291
  let disposedOnce = false
@@ -190,7 +296,21 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
190
296
  unsubscribe()
191
297
  if (timer !== undefined) clearTimeout(timer)
192
298
  if (watchdog !== undefined) clearInterval(watchdog)
193
- img.remove()
299
+ // Release decoded bitmaps after pending decodes settle; close() is
300
+ // browser-only, so guard it for exotic hosts.
301
+ void Promise.allSettled(decodedAll).then(() => {
302
+ for (const job of decoding.values()) {
303
+ void job.then((frame) => {
304
+ try {
305
+ const maybeClose = (frame?.source as { close?: () => void } | undefined)?.close
306
+ if (typeof maybeClose === 'function' && frame !== undefined) maybeClose.call(frame.source)
307
+ } catch { /* already released */ }
308
+ }).catch(() => { /* never rejects anyway */ })
309
+ }
310
+ decoding.clear()
311
+ })
312
+ canvas?.remove()
313
+ img?.remove()
194
314
  }
195
315
  ctx.onCleanup(dispose)
196
316