@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
package/src/access.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet trust fence: loopback (the desktop) always passes; a live paired-device
|
|
3
|
+
* cookie is an additional allow path when remote-web-ui is loaded. The pet
|
|
4
|
+
* never depends on that plugin — without the service the fence stays
|
|
5
|
+
* loopback-only (same pattern as skill-explorer / aionui-panel).
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage } from 'node:http'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
10
|
+
|
|
11
|
+
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
|
12
|
+
interface PairingAccess {
|
|
13
|
+
isPairedDevice(request: IncomingMessage): boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** ctx.get is optional on the test harness; production Context always has it. */
|
|
17
|
+
type LookupCtx = Context & {
|
|
18
|
+
get?(name: string, strict?: boolean): unknown
|
|
19
|
+
remoteWebUiPairing?: PairingAccess
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether this request may enter any /api/pet or /pet asset route.
|
|
24
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
25
|
+
* @param request - the incoming HTTP request.
|
|
26
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
27
|
+
*/
|
|
28
|
+
export function isPetAllowed(ctx: Context, request: IncomingMessage): boolean {
|
|
29
|
+
if (isLoopbackRequest(request)) return true
|
|
30
|
+
const bag = ctx as LookupCtx
|
|
31
|
+
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
|
32
|
+
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
|
33
|
+
return pairing?.isPairedDevice(request) === true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPairingAccess(value: unknown): value is PairingAccess {
|
|
37
|
+
return value !== undefined
|
|
38
|
+
&& value !== null
|
|
39
|
+
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
|
40
|
+
}
|
|
@@ -116,8 +116,14 @@ export class PetSettingsCardController {
|
|
|
116
116
|
choiceField('petId', this.petChoices),
|
|
117
117
|
])
|
|
118
118
|
this.store = this.form.bind(() => this.projection())
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// Client plugins are applied synchronously during shell startup. Defer
|
|
120
|
+
// the first registry request until that pass completes so transport
|
|
121
|
+
// plugins (notably remote-web-ui on a paired non-loopback origin) can
|
|
122
|
+
// install their fetch channel before /api/pet/pets is issued.
|
|
123
|
+
window.setTimeout(() => {
|
|
124
|
+
void this.loadPets()
|
|
125
|
+
void this.loadDiagnostics()
|
|
126
|
+
}, 0)
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
/** Fetch registry diagnostics once (soft-fail: an empty list on error). */
|
|
@@ -755,6 +755,77 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
755
755
|
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
756
756
|
})
|
|
757
757
|
|
|
758
|
+
it('does not schedule a frame loop for a single-frame segment even when looping', () => {
|
|
759
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
760
|
+
matches: false,
|
|
761
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
762
|
+
onchange: null,
|
|
763
|
+
addEventListener: () => {},
|
|
764
|
+
removeEventListener: () => {},
|
|
765
|
+
addListener: () => {},
|
|
766
|
+
removeListener: () => {},
|
|
767
|
+
dispatchEvent: () => false,
|
|
768
|
+
})
|
|
769
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
770
|
+
const frames: FrameRequestCallback[] = []
|
|
771
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
772
|
+
frames.push(callback)
|
|
773
|
+
return frames.length
|
|
774
|
+
})
|
|
775
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
776
|
+
// failed binds the single frame 3 of a 24px-wide frame; loop stays true.
|
|
777
|
+
renderPet({
|
|
778
|
+
snapshot: {
|
|
779
|
+
...snapshot,
|
|
780
|
+
bubble: '失败',
|
|
781
|
+
phase: 'failed',
|
|
782
|
+
decoration: { ...decoration, phases: { ...decoration.phases, failed: { from: 3, to: 3 } } },
|
|
783
|
+
},
|
|
784
|
+
})
|
|
785
|
+
const el = ornament()!
|
|
786
|
+
// The ornament settles on its only frame, exactly like the reduced-motion
|
|
787
|
+
// hold — no rAF loop may start, so only the sprite's idle loop is pending.
|
|
788
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
789
|
+
expect(frames).toHaveLength(1)
|
|
790
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
791
|
+
act(() => { step(161) })
|
|
792
|
+
act(() => { step(322) })
|
|
793
|
+
// The frame never moves and the ornament never reschedules itself.
|
|
794
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
795
|
+
expect(frames).toHaveLength(1)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('does not advance the background while a frame is still in play', () => {
|
|
799
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
800
|
+
matches: false,
|
|
801
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
802
|
+
onchange: null,
|
|
803
|
+
addEventListener: () => {},
|
|
804
|
+
removeEventListener: () => {},
|
|
805
|
+
addListener: () => {},
|
|
806
|
+
removeListener: () => {},
|
|
807
|
+
dispatchEvent: () => false,
|
|
808
|
+
})
|
|
809
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
810
|
+
const frames: FrameRequestCallback[] = []
|
|
811
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
812
|
+
frames.push(callback)
|
|
813
|
+
return frames.length
|
|
814
|
+
})
|
|
815
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
816
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
817
|
+
const el = ornament()!
|
|
818
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
819
|
+
// thinking binds frames 0..3 at 160 ms/frame. The effect holds frame 0;
|
|
820
|
+
// a step at 80 ms — inside the first frame — must not move the ornament,
|
|
821
|
+
// and only crossing the 160 ms boundary advances to the next frame.
|
|
822
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
823
|
+
act(() => { step(80) })
|
|
824
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
825
|
+
act(() => { step(161) })
|
|
826
|
+
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
827
|
+
})
|
|
828
|
+
|
|
758
829
|
it('does not restart the frame loop when an equal-content decoration re-renders', () => {
|
|
759
830
|
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
760
831
|
matches: false,
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -93,7 +93,11 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
93
93
|
const position = (index: number): string => (-index * frameWidth) + 'px 0px'
|
|
94
94
|
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
95
95
|
el.style.backgroundPosition = position(segment.from)
|
|
96
|
-
|
|
96
|
+
// A single-frame segment (from === to) has nothing to animate: with
|
|
97
|
+
// loop=true the wrap branch would reset index to the same frame and the
|
|
98
|
+
// tick would keep rescheduling a no-op rAF forever. Settle on the one
|
|
99
|
+
// frame instead — same as the reduced-motion static hold.
|
|
100
|
+
if (reduceMotion || segment.from === segment.to) return
|
|
97
101
|
let raf = 0
|
|
98
102
|
let index = segment.from
|
|
99
103
|
let elapsed = 0
|
|
@@ -107,8 +111,12 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
107
111
|
elapsed = 0
|
|
108
112
|
if (index < segment.to) index += 1
|
|
109
113
|
else if (decoration.loop) index = segment.from
|
|
114
|
+
// Only advance the background when the frame actually changes:
|
|
115
|
+
// the segment's frame rate (duration ms, typically 90-160) is far
|
|
116
|
+
// below the rAF cadence, so writing the same position every frame
|
|
117
|
+
// would churn style recalculations for no visual change.
|
|
118
|
+
el.style.backgroundPosition = position(index)
|
|
110
119
|
}
|
|
111
|
-
el.style.backgroundPosition = position(index)
|
|
112
120
|
// A non-looping segment settles on its last frame; stop scheduling
|
|
113
121
|
// instead of repainting the same position every frame.
|
|
114
122
|
if (!decoration.loop && index === segment.to) return
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
5
|
+
import type { PetRenderer, PetRendererContext } from '../../../contracts/renderer.ts'
|
|
6
|
+
import type { PetDefinition } from '../../../registry.ts'
|
|
7
|
+
import { defaultPetRendererRegistry } from '../registry.ts'
|
|
8
|
+
import type { Live2dErrorCode } from '../live2d.ts'
|
|
9
|
+
import { Live2dVisualMount } from './Live2dVisualMount.tsx'
|
|
10
|
+
|
|
11
|
+
const t = ((key: string) => key) as PropsLocale<'pet'>['t']
|
|
12
|
+
const noop = (): void => {}
|
|
13
|
+
|
|
14
|
+
function definition(id: string): PetDefinition {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
displayName: id,
|
|
18
|
+
description: '',
|
|
19
|
+
renderer: 'live2d',
|
|
20
|
+
live2d: { modelPath: id + '.model3.json', modelUrl: '/pet/' + id + '/' + id + '.model3.json', motions: { idle: 'Idle' } },
|
|
21
|
+
} as unknown as PetDefinition
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('Live2dVisualMount', () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup()
|
|
27
|
+
defaultPetRendererRegistry.clear()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('clears a failed activation error when switching to another pet', () => {
|
|
31
|
+
const errorSinks: ((code: Live2dErrorCode) => void)[] = []
|
|
32
|
+
const disposes: ReturnType<typeof vi.fn>[] = []
|
|
33
|
+
defaultPetRendererRegistry.register({
|
|
34
|
+
id: 'live2d',
|
|
35
|
+
apiVersion: 'test',
|
|
36
|
+
validateConfig: (config) => config,
|
|
37
|
+
mount: (ctx: PetRendererContext) => {
|
|
38
|
+
const canvas = document.createElement('canvas')
|
|
39
|
+
ctx.container.appendChild(canvas)
|
|
40
|
+
const dispose = vi.fn(() => { canvas.remove() })
|
|
41
|
+
disposes.push(dispose)
|
|
42
|
+
return {
|
|
43
|
+
dispose,
|
|
44
|
+
tap: () => {},
|
|
45
|
+
onError: (listener: (code: Live2dErrorCode) => void) => { errorSinks.push(listener) },
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
} as PetRenderer)
|
|
49
|
+
|
|
50
|
+
const first = definition('broken')
|
|
51
|
+
const second = definition('healthy')
|
|
52
|
+
const { container, rerender, unmount } = render(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
|
|
53
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(1)
|
|
54
|
+
act(() => { errorSinks[0]?.('load-failed') })
|
|
55
|
+
expect(container.querySelector('[data-dsh-pet-live2d-error="load-failed"]')).toBeTruthy()
|
|
56
|
+
|
|
57
|
+
rerender(<Live2dVisualMount definition={second} phase="idle" onPet={noop} t={t} />)
|
|
58
|
+
expect(disposes[0]).toHaveBeenCalledTimes(1)
|
|
59
|
+
expect(container.querySelector('[data-dsh-pet-live2d="healthy"]')).toBeTruthy()
|
|
60
|
+
expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
|
|
61
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(1)
|
|
62
|
+
|
|
63
|
+
rerender(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
|
|
64
|
+
expect(disposes[1]).toHaveBeenCalledTimes(1)
|
|
65
|
+
expect(container.querySelector('[data-dsh-pet-live2d="broken"]')).toBeTruthy()
|
|
66
|
+
expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
|
|
67
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(1)
|
|
68
|
+
unmount()
|
|
69
|
+
expect(disposes[2]).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(0)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -33,6 +33,7 @@ export function Live2dVisualMount(props: {
|
|
|
33
33
|
|
|
34
34
|
// One activation per pet definition: build the contract context and mount.
|
|
35
35
|
useEffect(() => {
|
|
36
|
+
setError(null)
|
|
36
37
|
const container = containerRef.current
|
|
37
38
|
const live2d = props.definition.live2d
|
|
38
39
|
if (container === null || live2d === undefined) return undefined
|
|
@@ -21,13 +21,18 @@ const VENDOR_URL = '/api/pet/runtime/live2d-vendor.js'
|
|
|
21
21
|
export interface Live2dVendorApp {
|
|
22
22
|
canvas: HTMLCanvasElement
|
|
23
23
|
stage: { addChild(child: unknown): unknown }
|
|
24
|
-
renderer: {
|
|
24
|
+
renderer: {
|
|
25
|
+
readonly width: number
|
|
26
|
+
readonly height: number
|
|
27
|
+
resize(width: number, height: number): void
|
|
28
|
+
}
|
|
25
29
|
init(options: Record<string, unknown>): Promise<void>
|
|
26
|
-
destroy(removeView?: boolean, options?: Record<string, unknown>): void
|
|
30
|
+
destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** The Live2DModel slice the renderer uses. */
|
|
30
34
|
export interface Live2dVendorModel {
|
|
35
|
+
automator: { autoUpdate: boolean }
|
|
31
36
|
anchor: { set(x: number, y?: number): void }
|
|
32
37
|
position: { set(x: number, y: number): void }
|
|
33
38
|
scale: { set(x: number, y?: number): void }
|
|
@@ -43,6 +48,7 @@ export interface Live2dVendorModel {
|
|
|
43
48
|
expression(name?: string): unknown
|
|
44
49
|
hitTest(x: number, y: number): string[]
|
|
45
50
|
on(event: string, fn: () => void): unknown
|
|
51
|
+
destroy(options?: { children?: boolean; texture?: boolean; baseTexture?: boolean }): void
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/** The vendor bundle global (window.__dshPetLive2d). */
|
|
@@ -20,7 +20,10 @@ import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
|
|
|
20
20
|
import type { PetRendererContext } from '../../contracts/renderer.ts'
|
|
21
21
|
|
|
22
22
|
interface FakeModel {
|
|
23
|
+
automator: { autoUpdate: boolean }
|
|
23
24
|
calls: unknown[][]
|
|
25
|
+
destroyCalls: Record<string, unknown>[]
|
|
26
|
+
destroyed: number
|
|
24
27
|
listeners: Record<string, () => void>
|
|
25
28
|
width: number
|
|
26
29
|
height: number
|
|
@@ -32,21 +35,58 @@ interface FakeModel {
|
|
|
32
35
|
expression(name?: string): void
|
|
33
36
|
hitTest(x: number, y: number): string[]
|
|
34
37
|
on(event: string, fn: () => void): void
|
|
38
|
+
destroy(options?: Record<string, unknown>): void
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
interface FakeApp {
|
|
38
42
|
canvas: HTMLCanvasElement
|
|
39
43
|
stage: { addChild(child: unknown): void }
|
|
40
|
-
renderer: { width: number; height: number }
|
|
44
|
+
renderer: { width: number; height: number; resize(width: number, height: number): void }
|
|
41
45
|
init(options: Record<string, unknown>): Promise<void>
|
|
42
|
-
destroy(): void
|
|
46
|
+
destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
|
|
43
47
|
destroyed: number
|
|
48
|
+
destroyCalls: unknown[][]
|
|
44
49
|
added: unknown
|
|
50
|
+
resizeCalls: [number, number][]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class FakeResizeObserver {
|
|
54
|
+
static instances: FakeResizeObserver[] = []
|
|
55
|
+
|
|
56
|
+
readonly targets = new Set<Element>()
|
|
57
|
+
disconnected = false
|
|
58
|
+
|
|
59
|
+
constructor(private readonly callback: ResizeObserverCallback) {
|
|
60
|
+
FakeResizeObserver.instances.push(this)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
observe(target: Element): void {
|
|
64
|
+
this.targets.add(target)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
unobserve(target: Element): void {
|
|
68
|
+
this.targets.delete(target)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
disconnect(): void {
|
|
72
|
+
this.disconnected = true
|
|
73
|
+
this.targets.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
emit(target: Element, width: number, height: number): void {
|
|
77
|
+
this.callback([{
|
|
78
|
+
target,
|
|
79
|
+
contentRect: { width, height },
|
|
80
|
+
} as ResizeObserverEntry], this as unknown as ResizeObserver)
|
|
81
|
+
}
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBody: [{}] }): FakeModel {
|
|
48
85
|
const model: FakeModel = {
|
|
86
|
+
automator: { autoUpdate: false },
|
|
49
87
|
calls: [],
|
|
88
|
+
destroyCalls: [],
|
|
89
|
+
destroyed: 0,
|
|
50
90
|
listeners: {},
|
|
51
91
|
width: 1000,
|
|
52
92
|
height: 1200,
|
|
@@ -58,19 +98,50 @@ function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBod
|
|
|
58
98
|
expression: (name) => { model.calls.push(['expression', name]) },
|
|
59
99
|
hitTest: (x, y) => { model.calls.push(['hitTest', x, y]); return ['Body'] },
|
|
60
100
|
on: (event, fn) => { model.listeners[event] = fn },
|
|
101
|
+
destroy: (options = {}) => {
|
|
102
|
+
model.destroyed += 1
|
|
103
|
+
model.destroyCalls.push(options)
|
|
104
|
+
model.automator.autoUpdate = false
|
|
105
|
+
},
|
|
61
106
|
}
|
|
62
107
|
return model
|
|
63
108
|
}
|
|
64
109
|
|
|
65
110
|
function fakeApp(): FakeApp {
|
|
111
|
+
const canvas = document.createElement('canvas')
|
|
66
112
|
const app: FakeApp = {
|
|
67
|
-
canvas
|
|
113
|
+
canvas,
|
|
68
114
|
stage: { addChild: (child) => { app.added = child } },
|
|
69
|
-
renderer: {
|
|
70
|
-
|
|
71
|
-
|
|
115
|
+
renderer: {
|
|
116
|
+
width: 160,
|
|
117
|
+
height: 174,
|
|
118
|
+
resize: (width, height) => {
|
|
119
|
+
app.renderer.width = width
|
|
120
|
+
app.renderer.height = height
|
|
121
|
+
canvas.width = width
|
|
122
|
+
canvas.height = height
|
|
123
|
+
app.resizeCalls.push([width, height])
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
init: (options) => {
|
|
127
|
+
app.renderer.width = Number(options.width)
|
|
128
|
+
app.renderer.height = Number(options.height)
|
|
129
|
+
canvas.width = app.renderer.width
|
|
130
|
+
canvas.height = app.renderer.height
|
|
131
|
+
return Promise.resolve()
|
|
132
|
+
},
|
|
133
|
+
destroy: (rendererOptions, options) => {
|
|
134
|
+
app.destroyed += 1
|
|
135
|
+
app.destroyCalls.push([rendererOptions, options])
|
|
136
|
+
if (rendererOptions === true || (typeof rendererOptions === 'object' && rendererOptions.removeView === true)) app.canvas.remove()
|
|
137
|
+
if (options?.children === true && typeof (app.added as { destroy?: unknown } | undefined)?.destroy === 'function') {
|
|
138
|
+
;(app.added as { destroy(options?: Record<string, unknown>): void }).destroy(options)
|
|
139
|
+
}
|
|
140
|
+
},
|
|
72
141
|
destroyed: 0,
|
|
142
|
+
destroyCalls: [],
|
|
73
143
|
added: undefined,
|
|
144
|
+
resizeCalls: [],
|
|
74
145
|
}
|
|
75
146
|
return app
|
|
76
147
|
}
|
|
@@ -78,7 +149,10 @@ function fakeApp(): FakeApp {
|
|
|
78
149
|
function fakeVendor(
|
|
79
150
|
model: FakeModel,
|
|
80
151
|
app: FakeApp,
|
|
81
|
-
from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = () =>
|
|
152
|
+
from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = (_source, options) => {
|
|
153
|
+
model.calls.push(['from', options])
|
|
154
|
+
return Promise.resolve(model)
|
|
155
|
+
},
|
|
82
156
|
): unknown {
|
|
83
157
|
return {
|
|
84
158
|
Application: class { constructor() { return app } },
|
|
@@ -91,12 +165,27 @@ function fakeVendor(
|
|
|
91
165
|
|
|
92
166
|
const CONFIG = { modelUrl: '/pet/haru/haru.model3.json', motions: { idle: 'Idle', thinking: 'TapBody' } }
|
|
93
167
|
|
|
94
|
-
function makeCtx(
|
|
168
|
+
function makeCtx(width = 0, height = 0): {
|
|
169
|
+
ctx: PetRendererContext
|
|
170
|
+
stream: PhaseStream
|
|
171
|
+
container: HTMLDivElement
|
|
172
|
+
setSize(nextWidth: number, nextHeight: number): void
|
|
173
|
+
} {
|
|
95
174
|
const container = document.createElement('div')
|
|
175
|
+
let clientWidth = width
|
|
176
|
+
let clientHeight = height
|
|
177
|
+
Object.defineProperties(container, {
|
|
178
|
+
clientWidth: { configurable: true, get: () => clientWidth },
|
|
179
|
+
clientHeight: { configurable: true, get: () => clientHeight },
|
|
180
|
+
})
|
|
96
181
|
const stream = createPhaseStream('idle')
|
|
97
182
|
return {
|
|
98
183
|
container,
|
|
99
184
|
stream,
|
|
185
|
+
setSize(nextWidth, nextHeight) {
|
|
186
|
+
clientWidth = nextWidth
|
|
187
|
+
clientHeight = nextHeight
|
|
188
|
+
},
|
|
100
189
|
ctx: {
|
|
101
190
|
petId: 'haru',
|
|
102
191
|
assetBase: '/pet/haru',
|
|
@@ -115,9 +204,12 @@ async function flush(): Promise<void> {
|
|
|
115
204
|
describe('live2dRenderer', () => {
|
|
116
205
|
beforeEach(() => {
|
|
117
206
|
runtime.core = true
|
|
207
|
+
FakeResizeObserver.instances = []
|
|
208
|
+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
|
118
209
|
resetLive2dRenderer()
|
|
119
210
|
})
|
|
120
211
|
afterEach(() => {
|
|
212
|
+
vi.unstubAllGlobals()
|
|
121
213
|
vi.restoreAllMocks()
|
|
122
214
|
})
|
|
123
215
|
|
|
@@ -131,6 +223,8 @@ describe('live2dRenderer', () => {
|
|
|
131
223
|
await flush()
|
|
132
224
|
expect(container.querySelector('canvas')).toBeTruthy()
|
|
133
225
|
expect(app.added).toBe(model)
|
|
226
|
+
expect(model.calls).toContainEqual(['from', expect.objectContaining({ autoUpdate: false, autoHitTest: false, autoFocus: false })])
|
|
227
|
+
expect(model.automator.autoUpdate).toBe(true)
|
|
134
228
|
// idle plays on boot: group 'Idle', random index floor(0.99 * 2) = 1.
|
|
135
229
|
expect(model.calls).toContainEqual(['motion', 'Idle', 1])
|
|
136
230
|
// auto-fit: min(160/1000, 174/1200) * 0.92 = 0.1334
|
|
@@ -142,8 +236,14 @@ describe('live2dRenderer', () => {
|
|
|
142
236
|
expect(model.calls.filter(call => call[0] === 'motion').at(-1)).toEqual(['motion', 'Idle', 1])
|
|
143
237
|
handle.dispose()
|
|
144
238
|
expect(app.destroyed).toBe(1)
|
|
239
|
+
expect(app.destroyCalls[0]?.[0]).toEqual({ removeView: true })
|
|
240
|
+
expect(model.destroyed).toBe(1)
|
|
241
|
+
expect(model.destroyCalls).toContainEqual({ children: true })
|
|
242
|
+
expect(model.automator.autoUpdate).toBe(false)
|
|
145
243
|
handle.dispose() // idempotent
|
|
146
244
|
expect(app.destroyed).toBe(1)
|
|
245
|
+
expect(app.destroyCalls[0]?.[0]).toEqual({ removeView: true })
|
|
246
|
+
expect(model.destroyed).toBe(1)
|
|
147
247
|
})
|
|
148
248
|
|
|
149
249
|
it('uses one automatic texture LOD instead of the default full mip chain', async () => {
|
|
@@ -157,12 +257,79 @@ describe('live2dRenderer', () => {
|
|
|
157
257
|
await flush()
|
|
158
258
|
|
|
159
259
|
expect(from).toHaveBeenCalledWith(CONFIG.modelUrl, {
|
|
260
|
+
autoUpdate: false,
|
|
160
261
|
autoHitTest: false,
|
|
161
262
|
autoFocus: false,
|
|
162
263
|
textureOptions: { lod: 'single-auto' },
|
|
163
264
|
})
|
|
164
265
|
})
|
|
165
266
|
|
|
267
|
+
it('keeps the Pixi canvas and model layout in sync with display-size changes', async () => {
|
|
268
|
+
const model = fakeModel()
|
|
269
|
+
const app = fakeApp()
|
|
270
|
+
runtime.vendor = fakeVendor(model, app)
|
|
271
|
+
const { ctx, container, setSize } = makeCtx(148, 160)
|
|
272
|
+
|
|
273
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
|
|
274
|
+
await flush()
|
|
275
|
+
expect(app.canvas.width).toBe(148)
|
|
276
|
+
expect(app.canvas.height).toBe(160)
|
|
277
|
+
expect(model.calls).toContainEqual(['position', 74, 80])
|
|
278
|
+
|
|
279
|
+
model.calls.length = 0
|
|
280
|
+
setSize(295, 320)
|
|
281
|
+
FakeResizeObserver.instances[0]?.emit(container, 295, 320)
|
|
282
|
+
|
|
283
|
+
expect(app.resizeCalls).toEqual([[295, 320]])
|
|
284
|
+
expect(app.canvas.width).toBe(295)
|
|
285
|
+
expect(app.canvas.height).toBe(320)
|
|
286
|
+
expect(model.calls).toContainEqual(['scale', expect.closeTo(0.245333, 5)])
|
|
287
|
+
expect(model.calls).toContainEqual(['position', 147.5, 160])
|
|
288
|
+
handle.tap(147.5, 160)
|
|
289
|
+
expect(model.calls).toContainEqual(['hitTest', 147.5, 160])
|
|
290
|
+
|
|
291
|
+
handle.dispose()
|
|
292
|
+
expect(FakeResizeObserver.instances[0]?.disconnected).toBe(true)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('uses the latest renderer size when the model finishes loading', async () => {
|
|
296
|
+
const model = fakeModel()
|
|
297
|
+
const app = fakeApp()
|
|
298
|
+
let resolveModel!: (value: FakeModel) => void
|
|
299
|
+
const pendingModel = new Promise<FakeModel>((resolve) => { resolveModel = resolve })
|
|
300
|
+
runtime.vendor = fakeVendor(model, app, () => pendingModel)
|
|
301
|
+
const { ctx, container, setSize } = makeCtx(148, 160)
|
|
302
|
+
|
|
303
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
304
|
+
await flush()
|
|
305
|
+
setSize(295, 320)
|
|
306
|
+
FakeResizeObserver.instances[0]?.emit(container, 295, 320)
|
|
307
|
+
resolveModel(model)
|
|
308
|
+
await flush()
|
|
309
|
+
|
|
310
|
+
expect(app.resizeCalls).toEqual([[295, 320]])
|
|
311
|
+
expect(model.calls).toContainEqual(['position', 147.5, 160])
|
|
312
|
+
expect(model.calls).toContainEqual(['scale', expect.closeTo(0.245333, 5)])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('ignores zero-size observations and stops resizing after disposal', async () => {
|
|
316
|
+
const model = fakeModel()
|
|
317
|
+
const app = fakeApp()
|
|
318
|
+
runtime.vendor = fakeVendor(model, app)
|
|
319
|
+
const { ctx, container } = makeCtx(148, 160)
|
|
320
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
321
|
+
await flush()
|
|
322
|
+
const observer = FakeResizeObserver.instances[0]
|
|
323
|
+
|
|
324
|
+
observer?.emit(container, 0, 0)
|
|
325
|
+
observer?.emit(container, 148, 160)
|
|
326
|
+
expect(app.resizeCalls).toEqual([])
|
|
327
|
+
|
|
328
|
+
handle.dispose()
|
|
329
|
+
observer?.emit(container, 295, 320)
|
|
330
|
+
expect(app.resizeCalls).toEqual([])
|
|
331
|
+
})
|
|
332
|
+
|
|
166
333
|
it('falls back to the idle group when a mapped group is absent from the model', async () => {
|
|
167
334
|
const model = fakeModel({ Idle: [{}] })
|
|
168
335
|
runtime.vendor = fakeVendor(model, fakeApp())
|
|
@@ -239,7 +406,43 @@ describe('live2dRenderer', () => {
|
|
|
239
406
|
handle.onError((next) => { code = next })
|
|
240
407
|
await flush()
|
|
241
408
|
expect(code).toBe('load-failed')
|
|
409
|
+
expect(app.destroyed).toBe(1)
|
|
410
|
+
expect(ctx.container.querySelector('canvas')).toBeNull()
|
|
242
411
|
handle.dispose()
|
|
412
|
+
expect(app.destroyed).toBe(1)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('destroys both sides exactly once when disposed while model loading is pending', async () => {
|
|
416
|
+
const model = fakeModel()
|
|
417
|
+
const app = fakeApp()
|
|
418
|
+
let resolveModel!: (value: FakeModel) => void
|
|
419
|
+
const pendingModel = new Promise<FakeModel>((resolve) => { resolveModel = resolve })
|
|
420
|
+
const from = vi.fn(() => pendingModel)
|
|
421
|
+
runtime.vendor = {
|
|
422
|
+
Application: class { constructor() { return app } },
|
|
423
|
+
extensions: { add: () => {} },
|
|
424
|
+
Live2DPlugin: {},
|
|
425
|
+
configureCubismSDK: () => {},
|
|
426
|
+
Live2DModel: { from },
|
|
427
|
+
}
|
|
428
|
+
const { ctx, container } = makeCtx()
|
|
429
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
430
|
+
let error: string | undefined
|
|
431
|
+
;(handle as Live2dRendererHandle).onError((code) => { error = code })
|
|
432
|
+
await flush()
|
|
433
|
+
expect(from).toHaveBeenCalledTimes(1)
|
|
434
|
+
expect(container.querySelector('canvas')).toBeTruthy()
|
|
435
|
+
handle.dispose()
|
|
436
|
+
expect(app.destroyed).toBe(1)
|
|
437
|
+
expect(container.querySelector('canvas')).toBeNull()
|
|
438
|
+
resolveModel(model)
|
|
439
|
+
await flush()
|
|
440
|
+
expect(app.destroyed).toBe(1)
|
|
441
|
+
expect(model.destroyed).toBe(1)
|
|
442
|
+
expect(model.destroyCalls).toContainEqual({ children: true })
|
|
443
|
+
expect(model.automator.autoUpdate).toBe(false)
|
|
444
|
+
expect(app.added).toBeUndefined()
|
|
445
|
+
expect(error).toBeUndefined()
|
|
243
446
|
})
|
|
244
447
|
|
|
245
448
|
it('never appends a canvas when disposed mid-boot', async () => {
|