@linxin666/dsh-pet 0.3.4 → 0.3.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 (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/contracts/voice-pack-v1.schema.json +53 -28
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +166 -30
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +400 -478
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
  11. package/lib/types/access.d.ts +5 -4
  12. package/lib/types/access.d.ts.map +1 -1
  13. package/lib/types/access.js +2 -12
  14. package/lib/types/affinity.d.ts +2 -0
  15. package/lib/types/affinity.d.ts.map +1 -1
  16. package/lib/types/affinity.js +12 -0
  17. package/lib/types/chatter.d.ts +68 -46
  18. package/lib/types/chatter.d.ts.map +1 -1
  19. package/lib/types/chatter.js +243 -301
  20. package/lib/types/client/PetSprite.d.ts.map +1 -1
  21. package/lib/types/client/PetSprite.js +23 -18
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +26 -9
  24. package/lib/types/client/locales.d.ts +18 -0
  25. package/lib/types/client/locales.d.ts.map +1 -1
  26. package/lib/types/client/locales.js +18 -0
  27. package/lib/types/client/renderers/frames2d.d.ts +12 -0
  28. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  29. package/lib/types/client/renderers/frames2d.js +143 -18
  30. package/lib/types/event-projection.d.ts +9 -4
  31. package/lib/types/event-projection.d.ts.map +1 -1
  32. package/lib/types/event-projection.js +46 -14
  33. package/lib/types/ledger.d.ts +4 -2
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +4 -4
  36. package/lib/types/pair-access.d.ts +35 -0
  37. package/lib/types/pair-access.d.ts.map +1 -0
  38. package/lib/types/pair-access.js +19 -0
  39. package/lib/types/remarks.d.ts +1 -1
  40. package/lib/types/remarks.d.ts.map +1 -1
  41. package/lib/types/remarks.js +11 -2
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +8 -3
  44. package/lib/types/service.d.ts +7 -11
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +31 -20
  47. package/lib/types/voice-pack.d.ts +11 -7
  48. package/lib/types/voice-pack.d.ts.map +1 -1
  49. package/lib/types/voice-pack.js +80 -49
  50. package/package.json +1 -1
  51. package/src/access.ts +7 -27
  52. package/src/affinity.ts +13 -0
  53. package/src/chatter.test.ts +68 -38
  54. package/src/chatter.ts +269 -316
  55. package/src/client/PetSprite.test.tsx +50 -15
  56. package/src/client/PetSprite.tsx +30 -25
  57. package/src/client/index.test.tsx +10 -1
  58. package/src/client/index.ts +29 -10
  59. package/src/client/locales.ts +18 -0
  60. package/src/client/renderers/frames2d.test.ts +101 -0
  61. package/src/client/renderers/frames2d.ts +138 -18
  62. package/src/event-projection.ts +57 -16
  63. package/src/ledger.ts +6 -4
  64. package/src/pair-access.ts +49 -0
  65. package/src/remarks.test.ts +10 -0
  66. package/src/remarks.ts +9 -2
  67. package/src/routes.ts +9 -4
  68. package/src/service.ts +36 -30
  69. package/src/voice-pack.test.ts +87 -25
  70. package/src/voice-pack.ts +87 -49
@@ -176,6 +176,24 @@ describe('PetSprite hide control (#1126)', () => {
176
176
  })
177
177
  })
178
178
 
179
+ describe('PetSprite affinity rank localization (#1226)', () => {
180
+ it('localizes the rank name in the hover panel when document language is en', () => {
181
+ document.documentElement.lang = 'en'
182
+ try {
183
+ renderPet({
184
+ snapshot: {
185
+ ...snapshot,
186
+ affinity: { ...snapshot.affinity, rank: '幼鲸' },
187
+ },
188
+ })
189
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
190
+ expect(screen.getByText('Affinity Baby Whale')).toBeTruthy()
191
+ } finally {
192
+ document.documentElement.lang = 'zh'
193
+ }
194
+ })
195
+ })
196
+
179
197
  describe('PetSprite rename input', () => {
180
198
  it('submits the draft on Enter outside composition', () => {
181
199
  const { onRename } = renderPet()
@@ -424,27 +442,18 @@ describe('PetSprite status bubble', () => {
424
442
  expect(screen.queryByText('正在使用 grep')).not.toBeNull()
425
443
  })
426
444
 
427
- it('lets the inner whisper take over the status bubble', () => {
428
- renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
429
- // The whisper speaks THROUGH the bubble: it replaces (not accompanies)
430
- // the status copy, so the pet never shows two bubbles at once.
431
- expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
432
- expect(screen.queryByText('正在思考')).toBeNull()
433
- })
434
-
435
- it('lets the whisper take over the display session bubble in the stack', () => {
445
+ it('lets a session whisper take over its own bubble while others stay collapsed', () => {
436
446
  const { onOpenSession } = renderPet({
437
447
  snapshot: {
438
448
  ...workingSnapshot,
439
- whisper: '我在这儿陪着你呢,别急别急',
440
449
  sessions: [
441
- { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
450
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '我在这儿陪着你呢,别急别急' },
442
451
  { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
443
452
  ],
444
453
  },
445
454
  })
446
- // The primary bubble (the display session) speaks the whisper instead
447
- // of its status copy; the collapsed extra session hides behind the badge.
455
+ // The lead session's bubble speaks ITS whisper instead of its status
456
+ // copy; the collapsed extra session hides behind the badge.
448
457
  expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
449
458
  expect(screen.queryByText('正在思考')).toBeNull()
450
459
  expect(screen.queryByText('正在使用 grep')).toBeNull()
@@ -456,9 +465,28 @@ describe('PetSprite status bubble', () => {
456
465
  expect(onOpenSession).toHaveBeenCalledWith('s-a')
457
466
  })
458
467
 
468
+ it('renders each session whisper on its own expanded bubble', () => {
469
+ renderPet({
470
+ snapshot: {
471
+ ...workingSnapshot,
472
+ sessions: [
473
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: 'A 的碎碎念' },
474
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep', whisper: 'B 的碎碎念' },
475
+ ],
476
+ },
477
+ })
478
+ fireEvent.pointerOver(screen.getByText('A 的碎碎念').closest('div')!)
479
+ expect(screen.queryByText('A 的碎碎念')).not.toBeNull()
480
+ expect(screen.queryByText('B 的碎碎念')).not.toBeNull()
481
+ expect(screen.queryByText('正在思考')).toBeNull()
482
+ })
483
+
459
484
  it('lets transient interaction feedback take over the whisper too', () => {
460
485
  renderPet({
461
- snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
486
+ snapshot: {
487
+ ...workingSnapshot,
488
+ sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '哼哧哼哧,大脑转得飞快~' }],
489
+ },
462
490
  feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
463
491
  })
464
492
  expect(screen.queryByText('摸摸成功')).not.toBeNull()
@@ -754,7 +782,14 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
754
782
  })
755
783
 
756
784
  it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
757
- renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
785
+ renderPet({
786
+ snapshot: {
787
+ ...snapshot,
788
+ phase: 'thinking',
789
+ decoration,
790
+ sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '冲了冲了' }],
791
+ },
792
+ })
758
793
  expect(ornament()).toBeNull()
759
794
  expect(document.body.textContent).toContain('冲了冲了')
760
795
  })
@@ -432,14 +432,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
432
432
  const statusBubble = feedback === null && sessionBubbles.length === 0
433
433
  ? snapshot?.bubble
434
434
  : undefined
435
- // The display session's inner whisper (碎碎念) — short inner-voice copy
436
- // woken by the model's output. Instead of a second bubble of its own, a
437
- // fresh whisper takes over the display session's bubble (the stack top, or
438
- // the single status bubble) and re-tints it, so the pet never wears two
439
- // voices at once. Interaction feedback takes over the whole bubble area
440
- // while it plays, so whispers yield to it like status copy.
441
- const whisper = feedback === null ? snapshot?.whisper : undefined
442
- const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
435
+ // Each session's inner whisper (碎碎念) rides its own bubble — short
436
+ // inner-voice copy woken by that session's activity, never the model's or
437
+ // another session's. Instead of a second bubble of its own, a fresh
438
+ // whisper takes over its session's bubble and re-tints it, so the pet
439
+ // never wears two voices at once. Interaction feedback takes over the
440
+ // whole bubble area while it plays, so whispers yield to it like status
441
+ // copy.
442
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
443
443
  const displayName = snapshot?.name ?? definition.displayName
444
444
  // The host-served status decoration (M5, #567); absent = text-only bubbles.
445
445
  const decoration = snapshot?.decoration
@@ -549,7 +549,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
549
549
  {feedback.text}
550
550
  </div>
551
551
  )}
552
- {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
552
+ {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
553
553
  <div
554
554
  ref={bubbleRef}
555
555
  className={styles.bubbleStack}
@@ -557,15 +557,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
557
557
  onPointerLeave={() => setStackPeek(false)}
558
558
  >
559
559
  {visibleSessions.map((session, index) => {
560
- // The whisper rides the display session's bubble the stack's
561
- // primary entry (DOM-first, rendered bottom-most by the reversed
562
- // column so it stays glued to the sprite when extras open above).
563
- // The key swap restarts the entrance animation so the mood change
564
- // reads as the bubble re-speaking.
565
- const speaksWhisper = index === 0 && whisper !== undefined
560
+ // A session's whisper rides ITS OWN bubble (the stack lead when
561
+ // the current session has one, any bubble when the stack is
562
+ // expanded). The key swap restarts the entrance animation so the
563
+ // mood change reads as the bubble re-speaking.
564
+ const speaksWhisper = session.whisper !== undefined
566
565
  const bubble = (
567
566
  <button
568
- key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
567
+ key={speaksWhisper ? 'whisper:' + session.whisper : session.sessionId}
569
568
  type="button"
570
569
  className={clsx(
571
570
  styles.bubble,
@@ -579,7 +578,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
579
578
  {index === 0 && !speaksWhisper && decoration !== undefined && (
580
579
  <StatusOrnament decoration={decoration} phase={phase} />
581
580
  )}
582
- {speaksWhisper ? whisper : session.bubble}
581
+ {session.whisper ?? session.bubble}
583
582
  </button>
584
583
  )
585
584
  // The primary bubble carries the '+N' badge while other sessions
@@ -608,19 +607,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
608
607
  </span>
609
608
  )
610
609
  })}
611
- {sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
612
- // The key swap (status copy <-> whisper) restarts the entrance
613
- // animation on every mood change.
610
+ {sessionBubbles.length === 0 && statusBubble !== undefined && (
614
611
  <div
615
- key={whisper === undefined ? 'status' : 'whisper:' + whisper}
616
- className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
612
+ key="status"
613
+ className={clsx(styles.bubble, styles.bubbleStatus)}
617
614
  role="status"
618
615
  aria-live="polite"
619
616
  >
620
- {whisper === undefined && decoration !== undefined && (
617
+ {decoration !== undefined && (
621
618
  <StatusOrnament decoration={decoration} phase={phase} />
622
619
  )}
623
- {whisper ?? statusBubble}
620
+ {statusBubble}
624
621
  </div>
625
622
  )}
626
623
  </div>
@@ -688,7 +685,15 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
688
685
  <>
689
686
  <div className={styles.rankRow}>
690
687
  <span className={styles.nameCell}>{displayName}</span>
691
- <span className={styles.statRank}>{panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
688
+ <span className={styles.statRank}>
689
+ {(() => {
690
+ const rawRank = snapshot?.affinity.rank ?? '?'
691
+ const rankKey = `pet.rank.name.${rawRank}`
692
+ const localized = props.t(rankKey as any)
693
+ const rankName = localized !== rankKey ? localized : rawRank
694
+ return panelStat('rank', 'pet.rank', { rank: rankName })
695
+ })()}
696
+ </span>
692
697
  </div>
693
698
  <div className={styles.rankRow}>
694
699
  <span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
@@ -110,7 +110,16 @@ function fakeContext(): FakeClientLifecycle {
110
110
  },
111
111
  register: () => () => {},
112
112
  },
113
- sessions: undefined,
113
+ sessions: {
114
+ list: {
115
+ getSnapshot: () => ({ current: undefined, byId: {} }),
116
+ subscribe: (listener: () => void) => {
117
+ const set = new Set<() => void>([listener])
118
+ return () => { set.delete(listener) }
119
+ },
120
+ },
121
+ open: () => {},
122
+ },
114
123
  } as unknown as ClientContext
115
124
  let disposed = false
116
125
  const lifecycle: FakeClientLifecycle = {
@@ -36,7 +36,8 @@ import { reportDailyHeartbeat } from './telemetry.ts'
36
36
 
37
37
  /** The host pet API as the browser sees it (same-origin JSON endpoints). */
38
38
  interface PetHttpApi {
39
- state(): Promise<PetStateView>
39
+ /** Poll the host snapshot; the GUI's current session id rides the query. */
40
+ state(currentSessionId?: string): Promise<PetStateView>
40
41
  pets(): Promise<PetDefinition[]>
41
42
  interact(kind: PetInteraction): Promise<PetInteractResult>
42
43
  setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
@@ -66,7 +67,8 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
66
67
 
67
68
  /** The live host API instance (always defined; failures surface per call). */
68
69
  const petApi: PetHttpApi = {
69
- state: () => petFetch('/api/pet/state'),
70
+ state: (currentSessionId) => petFetch('/api/pet/state'
71
+ + (currentSessionId === undefined ? '' : '?current=' + encodeURIComponent(currentSessionId))),
70
72
  pets: () => petFetch('/api/pet/pets'),
71
73
  interact: (kind) => petFetch('/api/pet/interact', { kind }),
72
74
  setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
@@ -197,6 +199,20 @@ export function apply(ctx: ClientContext): void {
197
199
  const setState = petStore.actions.setState
198
200
  const setFeedback = petStore.actions.setFeedback
199
201
 
202
+ // Clicking a session bubble jumps the GUI to that session; the same
203
+ // sessions face reports which session the user is currently on, so the
204
+ // host can lead the bubble stack with it. A bubble can outlive its
205
+ // disposed session by one poll tick, and the sessions service fails
206
+ // loud on unknown ids, so consult the live list first. The pet's type
207
+ // program also loads the host-side dsh-session package through the
208
+ // service types, whose Context merge declares a different 'sessions'
209
+ // face; pin the browser runtime's outward face here.
210
+ const sessions = ctx.sessions as unknown as ISessions
211
+ const currentSessionId = (): string | undefined => {
212
+ const current = sessions.list.getSnapshot().current
213
+ return current === undefined ? undefined : String(current)
214
+ }
215
+
200
216
  // The registry list is fetched lazily with retries baked into the poll
201
217
  // cycle: until it lands, the dock entry renders nothing and every 2s
202
218
  // tick tries again. After it lands, one list feeds both the sprite and
@@ -218,7 +234,7 @@ export function apply(ctx: ClientContext): void {
218
234
  }
219
235
  const seq = stateSeq + 1
220
236
  stateSeq = seq
221
- petApi.state().then((snapshot) => {
237
+ petApi.state(currentSessionId()).then((snapshot) => {
222
238
  if (seq !== stateSeq) return
223
239
  setSnapshot(snapshot)
224
240
  }, () => {
@@ -261,13 +277,16 @@ export function apply(ctx: ClientContext): void {
261
277
  }
262
278
  }, 'pet: poll')
263
279
 
264
- // Clicking a session bubble jumps the GUI to that session. A bubble
265
- // can outlive its disposed session by one poll tick, and the sessions
266
- // service fails loud on unknown ids, so consult the live list first.
267
- // The pet's type program also loads the host-side dsh-session package
268
- // through the service types, whose Context merge declares a different
269
- // 'sessions' face; pin the browser runtime's outward face here.
270
- const sessions = ctx.sessions as unknown as ISessions
280
+ // A current-session switch should re-order the bubble stack right away,
281
+ // not on the next 2s tick. Poll only while the tab is visible, as the
282
+ // poll loop itself does.
283
+ const disposeSessionWatch = ctx.effect(() => {
284
+ const unsubscribe = sessions.list.subscribe(() => {
285
+ if (document.visibilityState === 'visible') pollNow()
286
+ })
287
+ return unsubscribe
288
+ }, 'pet: current-session watch')
289
+
271
290
  const openSession = (sessionId: string): void => {
272
291
  const list = sessions.list.getSnapshot()
273
292
  if (list.byId[sessionId as SessionId] === undefined) return
@@ -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': '宠物正在赶来…',
@@ -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…',
@@ -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