fix(stage-*): model not loading when entering main stage

This commit is contained in:
Neko Ayaka
2025-08-25 01:32:04 +08:00
parent 8ea98037e5
commit bc8cb59e6b
7 changed files with 61 additions and 483 deletions
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { Screen } from '@proj-airi/stage-ui/components'
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const live2dModelRef = ref<InstanceType<typeof Live2DModel>>()
const settingsStore = useSettings()
const { stageModelSelectedUrl } = storeToRefs(settingsStore)
const motion = ref<string>('idle')
const motionGroupsList = ref<{
motionName: string
@@ -61,6 +65,7 @@ function handleShot() {
>
<Live2DModel
ref="live2dModelRef"
:model-src="stageModelSelectedUrl"
:app="app"
:width="width"
:height="height"
@@ -13,7 +13,10 @@ import ModelManagerDialog from '../../Dialogs/model-selector/model-selector-dial
import Live2D from './Live2D.vue'
import VRM from './VRM.vue'
import { DisplayModelFormat } from '../../../../stores/display-models'
import { useLive2d } from '../../../../stores/live2d'
import { useSettings } from '../../../../stores/settings'
import { useVRM } from '../../../../stores/vrm'
const props = defineProps<{
palette: string[]
@@ -33,9 +36,20 @@ const positionCursor = useMouse()
const settingsStore = useSettings()
const { live2dDisableFocus, stageModelSelectedUrl, stageModelSelected, stageModelRenderer } = storeToRefs(settingsStore)
watch(selectedModel, () => {
watch(selectedModel, async () => {
stageModelSelected.value = selectedModel.value?.id
settingsStore.updateStageModel()
await settingsStore.updateStageModel()
if (selectedModel.value) {
switch (selectedModel.value.format) {
case DisplayModelFormat.Live2dZip:
useLive2d().shouldUpdateView()
break
case DisplayModelFormat.VRM:
useVRM().shouldUpdateView()
break
}
}
}, { deep: true })
</script>
@@ -27,6 +27,7 @@ import { useLive2d } from '../../stores/live2d'
import { useSpeechStore } from '../../stores/modules/speech'
import { useProvidersStore } from '../../stores/providers'
import { useSettings } from '../../stores/settings'
import { useVRM } from '../../stores/vrm'
withDefaults(defineProps<{
paused?: boolean
@@ -41,12 +42,34 @@ const db = ref<DuckDBWasmDrizzleDatabase>()
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
const { stageModelRenderer, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl } = storeToRefs(useSettings())
const settingsStore = useSettings()
const { stageModelRenderer, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl } = storeToRefs(settingsStore)
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
const providersStore = useProvidersStore()
const live2dStore = useLive2d()
const vrmStore = useVRM()
const showStage = ref(true)
live2dStore.onShouldUpdateView(async () => {
showStage.value = false
await settingsStore.updateStageModel()
setTimeout(() => {
showStage.value = true
}, 100)
})
vrmStore.onShouldUpdateView(async () => {
showStage.value = false
await settingsStore.updateStageModel()
setTimeout(() => {
showStage.value = true
}, 100)
})
const audioAnalyser = ref<AnalyserNode>()
const nowSpeaking = ref(false)
const lipSyncStarted = ref(false)
@@ -229,7 +252,7 @@ onMounted(async () => {
<div relative>
<div h-full w-full>
<Live2DScene
v-if="stageModelRenderer === 'live2d'"
v-if="stageModelRenderer === 'live2d' && showStage"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:model-src="stageModelSelectedUrl"
:focus-at="focusAt"
@@ -241,7 +264,7 @@ onMounted(async () => {
:disable-focus-at="live2dDisableFocus"
/>
<VRMScene
v-else-if="stageModelRenderer === 'vrm'"
v-if="stageModelRenderer === 'vrm' && showStage"
ref="vrmViewerRef"
:model-src="stageModelSelectedUrl"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
@@ -1,252 +0,0 @@
<script setup lang="ts">
import { FieldRange } from '@proj-airi/ui'
import { ref } from 'vue'
import PoppinMarkdown from './PoppinMarkdown.web.vue'
import {
createCutePopupAnimator,
createFadeAnimator,
createFloatAnimator,
createPopupAnimator,
createStackAnimator,
} from './animators'
const duration = ref(750)
// Sample markdown content
const markdownText = ref(`# Hello World
This is **bold text** and *italic text*.
Here's a list:
- Item 1
- Item 2
- Item 3
\`\`\`javascript
console.log('Hello, world!');
\`\`\`
> This is a blockquote with some **bold** content.
And here's a [link](https://example.com) and some inline \`code\`.`)
const chatMarkdown = ref(`Hi! I can help you with **markdown**. Here are some examples:
- *Italics* and **bold** text
- \`inline code\` snippets
- Even [links](https://example.com)
\`\`\`typescript
function greet(name: string) {
console.log(\`Hello, \${name}!\`);
}
\`\`\`
What would you like to know?`)
function createMarkdownStream(text: string) {
return new ReadableStream<Uint8Array>({
start(controller) {
const bytes = new TextEncoder().encode(text)
let index = 0
const interval = setInterval(() => {
if (index < bytes.length) {
controller.enqueue(bytes.subarray(index, index + 1))
index++
}
else {
clearInterval(interval)
controller.close()
}
}, 30 + 50 * Math.random()) // Faster for better demo
},
})
}
const streamingMarkdown = createMarkdownStream(`# Streaming Markdown Demo
This text is being **streamed** character by character!
## Features
- Real-time *markdown* rendering
- **Character-by-character** animation
- Support for \`code\` and other elements
\`\`\`javascript
// Code blocks appear as complete units
const message = "Hello from streaming markdown!";
console.log(message);
\`\`\`
> Blockquotes work too with **mixed** formatting.
Pretty cool, right?`)
const chatStreamingMarkdown = createMarkdownStream(`🤖 **AI Assistant**: Hello! I can help you with various tasks.
Here's what I can do:
- Answer questions about **programming**
- Help with \`code debugging\`
- Explain *technical concepts*
\`\`\`python
# Example: Python function
def greet_user(name):
return f"Hello, {name}! How can I help?"
print(greet_user("Developer"))
\`\`\`
> 💡 **Tip**: I stream my responses in real-time for a better experience!
What would you like to work on today?`)
</script>
<template>
<Story
title="PoppinMarkdown"
group="widgets"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<div px-2>
<FieldRange
v-model.number="duration"
:min="50"
:max="3000"
:step="50"
label="Animation Duration"
/>
</div>
</template>
<Variant
id="static-markdown"
title="Static Markdown"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createFadeAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="streaming-markdown"
title="Streaming Markdown"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="streamingMarkdown"
:animator="createFadeAnimator({ duration: 100 })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="chat-style"
title="Chat Style"
>
<div h-auto max-w-2xl w-full p-4>
<div
class="border-2 border-primary-200/50 rounded-lg border-solid bg-primary-50/50 p-4 dark:border-primary-500/50 dark:bg-primary-950/70"
>
<PoppinMarkdown
:text="chatMarkdown"
:animator="createFadeAnimator({ duration: 150 })"
class="prose-sm prose prose-primary dark:prose-invert"
/>
</div>
</div>
</Variant>
<Variant
id="streaming-chat"
title="Streaming Chat Response"
>
<div h-auto max-w-2xl w-full p-4>
<div
class="border-2 border-primary-200/50 rounded-lg border-solid bg-primary-50/50 p-4 dark:border-primary-500/50 dark:bg-primary-950/70"
>
<PoppinMarkdown
:text="chatStreamingMarkdown"
:animator="createFadeAnimator({ duration: 80 })"
class="prose-sm prose prose-primary dark:prose-invert"
/>
</div>
</div>
</Variant>
<Variant
id="popup-animation"
title="Popup Animation"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createPopupAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="float-animation"
title="Float Animation"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createFloatAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="cute-popup"
title="Cute Popup"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="chatMarkdown"
:animator="createCutePopupAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="stack-animation"
title="Stack Animation"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createStackAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="no-markdown"
title="Plain Text Mode"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:markdown="false"
:animator="createFadeAnimator({ duration })"
class="text-sm font-mono"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,212 +0,0 @@
<!-- PoppinMarkdown - PoppinText with markdown support -->
<script setup lang="ts">
import type { Element, Root } from 'hast'
import type { Animator } from './animators'
import RemarkParse from 'remark-parse'
import RemarkRehype from 'remark-rehype'
import { readGraphemeClusters } from 'clustr'
import { unified } from 'unified'
import { computed, defineComponent, h, ref, shallowRef, watch } from 'vue'
const props = withDefaults(defineProps<{
/**
* A plain string or a ReadableStream of bytes from text in UTF-8 encoding.
* If a stream is provided, the stream **SHOULD NOT** be reused.
*/
text?: string | ReadableStream<Uint8Array>
textClass?: string | string[]
animator?: Animator
/**
* Whether to process the accumulated text as markdown
* @default true
*/
markdown?: boolean
}>(), {
markdown: true,
})
const emits = defineEmits<{
(e: 'textSplit', grapheme: string): void
(e: 'markdownUpdated', html: string): void
}>()
const targets = ref<string[]>([])
const abortController = shallowRef<AbortController>()
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
// Accumulate all text content
const accumulatedText = ref('')
const animatedCharCount = ref(0)
// Create markdown processor
const processor = unified()
.use(RemarkParse)
.use(RemarkRehype)
// Convert HAST to Vue render function
function hastToVNode(node: any, animator?: Animator): any {
if (node.type === 'text') {
// Text node - split into animated characters
const textContent = node.value.trim()
if (!textContent)
return null
const characters = [...segmenter.segment(textContent)].map(seg => seg.segment)
const currentAnimatedCount = animatedCharCount.value
return characters.map((char, index) => {
const globalIndex = currentAnimatedCount + index
return h('span', {
key: `char-${globalIndex}`,
class: 'inline-block poppin-char',
onVnodeMounted: (vnode: any) => {
if (animator && vnode.el) {
setTimeout(() => animator([vnode.el]), 0)
}
},
}, char)
})
}
if (node.type === 'element') {
const element = node as Element
const { tagName, properties = {}, children = [] } = element
// Check if this is a non-text element that should animate as a unit
const isNonTextElement = ['img', 'code', 'pre', 'hr', 'br'].includes(tagName)
|| (properties.className
&& (properties.className as string[]).includes('code-block'))
if (isNonTextElement) {
return h(tagName, {
...properties,
key: `element-${tagName}-${animatedCharCount.value}`,
class: [properties.className, 'poppin-element'],
onVnodeMounted: (vnode: any) => {
if (animator && vnode.el) {
setTimeout(() => animator([vnode.el]), 0)
}
},
})
}
// Regular element - recursively process children
const childVNodes = children
.map((child: any) => hastToVNode(child, animator))
.flat()
.filter(Boolean)
return h(tagName, properties, childVNodes)
}
return null
}
// Parse markdown to HAST and create Vue component
const MarkdownComponent = computed(() => {
if (!props.markdown || !accumulatedText.value) {
// Fallback to character animation
// eslint-disable-next-line vue/one-component-per-file
return defineComponent({
setup() {
return () => targets.value.map((char, index) =>
h('span', {
key: `fallback-${index}`,
class: 'inline-block color-primary-400 dark:color-primary-100',
onVnodeMounted: (vnode: any) => {
if (props.animator && vnode.el) {
setTimeout(() => props.animator?.([vnode.el]), 0)
}
},
}, char),
)
},
})
}
try {
const ast = processor.parse(accumulatedText.value)
const hast = processor.runSync(ast) as Root
// eslint-disable-next-line vue/one-component-per-file
return defineComponent({
setup() {
return () => h('div', {
class: ['markdown-content', props.textClass].flat().filter(Boolean),
}, hast.children.map((child: any) => hastToVNode(child, props.animator)))
},
})
}
catch (error) {
console.warn('Markdown parsing failed:', error)
return null
}
})
// Update character count when streaming
watch(accumulatedText, (newText, oldText) => {
if (newText.length > (oldText?.length || 0)) {
// Only update count when new characters are added
const addedChars = newText.slice(oldText?.length || 0)
const addedCharCount = [...segmenter.segment(addedChars)].length
animatedCharCount.value += addedCharCount
}
else if (newText.length < (oldText?.length || 0)) {
// Text was reset, reset count
animatedCharCount.value = 0
}
emits('markdownUpdated', newText)
})
// Reset animation count when text prop changes
watch(() => props.text, () => {
animatedCharCount.value = 0
}, { flush: 'pre' })
watch(() => props.text, async (text) => {
if (!text) {
accumulatedText.value = ''
targets.value = []
return
}
if (typeof text === 'string') {
accumulatedText.value = text
targets.value = [...segmenter.segment(text)].map(seg => seg.segment)
}
else {
abortController.value?.abort()
abortController.value = new AbortController()
try {
targets.value = []
accumulatedText.value = ''
for await (const cluster of readGraphemeClusters(text.getReader(), { signal: abortController.value.signal })) {
accumulatedText.value += cluster
targets.value.push(cluster)
emits('textSplit', cluster)
}
}
catch (error) {
if (error instanceof Error && error.message === 'Aborted') {
console.warn('Text reading aborted')
}
else {
console.error('Error reading text:', error)
}
}
}
}, { immediate: true })
</script>
<template>
<component
:is="MarkdownComponent"
:key="accumulatedText.length"
/>
</template>
@@ -39,8 +39,8 @@ export interface DisplayModelURL {
}
const displayModelsPresets: DisplayModel[] = [
{ id: 'preset-live2d-1', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_pro_zh.zip', name: 'Hiyori (Pro)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png', importedAt: 1733113886840 },
{ id: 'preset-live2d-2', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png', importedAt: 1733113886840 },
{ id: 'preset-live2d-1', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_pro_zh.zip', name: 'Hiyori (Pro)', previewImage: '/assets/live2d/models/hiyori_pro_zh/avatar.png', importedAt: 1733113886840 },
{ id: 'preset-live2d-2', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)', previewImage: '/assets/live2d/models/hiyori_pro_zh/avatar.png', importedAt: 1733113886840 },
]
export const useDisplayModelsStore = defineStore('display-models', () => {
+12 -12
View File
@@ -35,7 +35,7 @@ export const useSettings = defineStore('settings', () => {
const language = useLocalStorage('settings/language', '')
const stageModelSelected = useLocalStorage<string | undefined>('settings/stage/model', undefined)
const stageModelSelected = useLocalStorage<string | undefined>('settings/stage/model', 'preset-live2d-1')
const stageModelSelectedDisplayModel = ref<DisplayModel | undefined>()
const stageModelSelectedUrl = ref<string>()
const stageModelRenderer = ref<'live2d' | 'vrm' | 'disabled'>()
@@ -56,17 +56,6 @@ export const useSettings = defineStore('settings', () => {
return
}
if (model.type === 'file') {
if (stageModelSelectedUrl.value) {
URL.revokeObjectURL(stageModelSelectedUrl.value)
}
stageModelSelectedUrl.value = URL.createObjectURL(model.file)
}
else {
stageModelSelectedUrl.value = model.url
}
switch (model.format) {
case DisplayModelFormat.Live2dZip:
stageModelRenderer.value = 'live2d'
@@ -79,6 +68,17 @@ export const useSettings = defineStore('settings', () => {
break
}
if (model.type === 'file') {
if (stageModelSelectedUrl.value) {
URL.revokeObjectURL(stageModelSelectedUrl.value)
}
stageModelSelectedUrl.value = URL.createObjectURL(model.file)
}
else {
stageModelSelectedUrl.value = model.url
}
stageModelSelectedDisplayModel.value = model
}