From 20b6978a9d81a7e3c7379bc27993c27c640e4cd5 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 8 Jan 2026 00:31:07 +0800 Subject: [PATCH] refactor(stage-ui,stage-ui-live2d,stage-ui-three): split out stage-ui-live2d component --- packages/stage-ui-live2d/package.json | 69 ++++++ .../stage-ui-live2d/src/components/index.ts | 1 + .../src/components/scenes/Live2D.vue | 19 ++ .../src/components/scenes/index.ts | 2 + .../src/components/scenes/live2d/Canvas.vue | 0 .../src/components/scenes/live2d/Model.vue | 27 ++- .../src/components/scenes/live2d/index.ts | 0 .../src/composables/live2d/animation.ts | 8 +- .../src/composables/live2d/beat-sync.ts | 0 .../src/composables/live2d/index.ts | 0 .../src/composables/live2d/motion-manager.ts | 0 .../stage-ui-live2d/src/constants/emotions.ts | 56 +++++ packages/stage-ui-live2d/src/index.ts | 11 + packages/stage-ui-live2d/src/stores/index.ts | 1 + packages/stage-ui-live2d/src/stores/live2d.ts | 92 ++++++++ .../src/utils/eye-motions.ts | 0 packages/stage-ui-live2d/src/utils/index.ts | 7 + .../src/utils/live2d-opfs-registration.ts | 0 .../src/utils/live2d-preview.ts | 90 ++++++++ .../src/utils/live2d-uri-encode-filenames.ts | 0 .../src/utils/live2d-zip-loader.ts | 0 .../src/utils/opfs-loader.ts | 0 packages/stage-ui-live2d/tsconfig.json | 36 ++++ packages/stage-ui-three/package.json | 1 + packages/stage-ui-three/src/index.ts | 1 + .../stage-ui-three/src/utils/vrm-preview.ts | 97 +++++++++ packages/stage-ui/package.json | 24 +-- .../settings/model-settings/index.vue | 22 +- .../settings/model-settings/live2d.vue | 2 +- .../stage-ui/src/components/scenes/Stage.vue | 24 ++- .../scenes/StageSubtitlesWithStrip.vue | 2 +- .../stage-ui/src/components/scenes/index.ts | 8 +- .../src/composables/use-data-maintenance.ts | 2 +- .../stage-ui/src/stores/display-models.ts | 196 +----------------- packages/stage-ui/src/stores/live2d.ts | 95 +-------- packages/stage-ui/src/utils/index.ts | 2 +- pnpm-lock.yaml | 172 ++++++++------- pnpm-workspace.yaml | 36 ++-- 38 files changed, 681 insertions(+), 422 deletions(-) create mode 100644 packages/stage-ui-live2d/package.json create mode 100644 packages/stage-ui-live2d/src/components/index.ts rename packages/{stage-ui => stage-ui-live2d}/src/components/scenes/Live2D.vue (73%) create mode 100644 packages/stage-ui-live2d/src/components/scenes/index.ts rename packages/{stage-ui => stage-ui-live2d}/src/components/scenes/live2d/Canvas.vue (100%) rename packages/{stage-ui => stage-ui-live2d}/src/components/scenes/live2d/Model.vue (96%) rename packages/{stage-ui => stage-ui-live2d}/src/components/scenes/live2d/index.ts (100%) rename packages/{stage-ui => stage-ui-live2d}/src/composables/live2d/animation.ts (73%) rename packages/{stage-ui => stage-ui-live2d}/src/composables/live2d/beat-sync.ts (100%) rename packages/{stage-ui => stage-ui-live2d}/src/composables/live2d/index.ts (100%) rename packages/{stage-ui => stage-ui-live2d}/src/composables/live2d/motion-manager.ts (100%) create mode 100644 packages/stage-ui-live2d/src/constants/emotions.ts create mode 100644 packages/stage-ui-live2d/src/index.ts create mode 100644 packages/stage-ui-live2d/src/stores/index.ts create mode 100644 packages/stage-ui-live2d/src/stores/live2d.ts rename packages/{stage-ui => stage-ui-live2d}/src/utils/eye-motions.ts (100%) create mode 100644 packages/stage-ui-live2d/src/utils/index.ts rename packages/{stage-ui => stage-ui-live2d}/src/utils/live2d-opfs-registration.ts (100%) create mode 100644 packages/stage-ui-live2d/src/utils/live2d-preview.ts rename packages/{stage-ui => stage-ui-live2d}/src/utils/live2d-uri-encode-filenames.ts (100%) rename packages/{stage-ui => stage-ui-live2d}/src/utils/live2d-zip-loader.ts (100%) rename packages/{stage-ui => stage-ui-live2d}/src/utils/opfs-loader.ts (100%) create mode 100644 packages/stage-ui-live2d/tsconfig.json create mode 100644 packages/stage-ui-three/src/utils/vrm-preview.ts diff --git a/packages/stage-ui-live2d/package.json b/packages/stage-ui-live2d/package.json new file mode 100644 index 000000000..c8e03a752 --- /dev/null +++ b/packages/stage-ui-live2d/package.json @@ -0,0 +1,69 @@ +{ + "name": "@proj-airi/stage-ui-live2d", + "type": "module", + "private": true, + "description": "Live2D scene components and stores for Project AIRI", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "packages/stage-ui-live2d" + }, + "exports": { + ".": "./src/index.ts", + "./components": "./src/components/index.ts", + "./components/scenes": "./src/components/scenes/index.ts", + "./components/scenes/Live2D.vue": "./src/components/scenes/Live2D.vue", + "./components/scenes/live2d": "./src/components/scenes/live2d/index.ts", + "./composables/live2d": "./src/composables/live2d/index.ts", + "./constants/emotions": "./src/constants/emotions.ts", + "./stores": "./src/stores/index.ts", + "./stores/live2d": "./src/stores/live2d.ts", + "./utils/eye-motions": "./src/utils/eye-motions.ts", + "./utils/live2d-preview": "./src/utils/live2d-preview.ts", + "./utils/live2d-zip-loader": "./src/utils/live2d-zip-loader.ts", + "./utils/live2d-opfs-registration": "./src/utils/live2d-opfs-registration.ts", + "./utils/live2d-uri-encode-filenames": "./src/utils/live2d-uri-encode-filenames.ts", + "./utils/opfs-loader": "./src/utils/opfs-loader.ts" + }, + "scripts": { + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@lemonneko/crop-empty-pixels": "0.1.1", + "@pixi/app": "^6.5.10", + "@pixi/constants": "^6.5.10", + "@pixi/core": "^6.5.10", + "@pixi/display": "^6.5.10", + "@pixi/extensions": "^6.5.10", + "@pixi/interaction": "^6.5.10", + "@pixi/loaders": "^6.5.10", + "@pixi/math": "^6.5.10", + "@pixi/runner": "^6.5.10", + "@pixi/settings": "^6.5.10", + "@pixi/sprite": "^6.5.10", + "@pixi/ticker": "^6.5.10", + "@pixi/utils": "^6.5.10", + "@proj-airi/stage-shared": "workspace:^", + "@proj-airi/ui": "workspace:^", + "@vueuse/core": "^14.1.0", + "culori": "^4.0.2", + "es-toolkit": "catalog:", + "jszip": "^3.10.1", + "pinia": "^3.0.4", + "pixi-filters": "4", + "pixi-live2d-display": "^0.4.0", + "three": "^0.182.0", + "vue": "^3.5.25" + }, + "devDependencies": { + "@types/culori": "^4.0.1", + "@types/three": "^0.182.0", + "vue-tsc": "^3.1.8" + } +} diff --git a/packages/stage-ui-live2d/src/components/index.ts b/packages/stage-ui-live2d/src/components/index.ts new file mode 100644 index 000000000..ab6b53306 --- /dev/null +++ b/packages/stage-ui-live2d/src/components/index.ts @@ -0,0 +1 @@ +export * from './scenes' diff --git a/packages/stage-ui/src/components/scenes/Live2D.vue b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue similarity index 73% rename from packages/stage-ui/src/components/scenes/Live2D.vue rename to packages/stage-ui-live2d/src/components/scenes/Live2D.vue index f48b8708f..3b9076489 100644 --- a/packages/stage-ui/src/components/scenes/Live2D.vue +++ b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue @@ -19,11 +19,24 @@ withDefaults(defineProps<{ mouthOpenSize?: number focusAt?: { x: number, y: number } disableFocusAt?: boolean + scale?: number + themeColorsHue?: number + themeColorsHueDynamic?: boolean + live2dIdleAnimationEnabled?: boolean + live2dAutoBlinkEnabled?: boolean + live2dForceAutoBlinkEnabled?: boolean + live2dShadowEnabled?: boolean }>(), { paused: false, focusAt: () => ({ x: 0, y: 0 }), mouthOpenSize: 0, scale: 1, + themeColorsHue: 220.44, + themeColorsHueDynamic: false, + live2dIdleAnimationEnabled: true, + live2dAutoBlinkEnabled: true, + live2dForceAutoBlinkEnabled: false, + live2dShadowEnabled: true, }) const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' }) @@ -77,6 +90,12 @@ defineExpose({ :y-offset="position.y" :scale="scale" :disable-focus-at="disableFocusAt" + :theme-colors-hue="themeColorsHue" + :theme-colors-hue-dynamic="themeColorsHueDynamic" + :live2d-idle-animation-enabled="live2dIdleAnimationEnabled" + :live2d-auto-blink-enabled="live2dAutoBlinkEnabled" + :live2d-force-auto-blink-enabled="live2dForceAutoBlinkEnabled" + :live2d-shadow-enabled="live2dShadowEnabled" /> diff --git a/packages/stage-ui-live2d/src/components/scenes/index.ts b/packages/stage-ui-live2d/src/components/scenes/index.ts new file mode 100644 index 000000000..14f589987 --- /dev/null +++ b/packages/stage-ui-live2d/src/components/scenes/index.ts @@ -0,0 +1,2 @@ +export { default as Live2DScene } from './Live2D.vue' +export { Live2DCanvas, Live2DModel } from './live2d' diff --git a/packages/stage-ui/src/components/scenes/live2d/Canvas.vue b/packages/stage-ui-live2d/src/components/scenes/live2d/Canvas.vue similarity index 100% rename from packages/stage-ui/src/components/scenes/live2d/Canvas.vue rename to packages/stage-ui-live2d/src/components/scenes/live2d/Canvas.vue diff --git a/packages/stage-ui/src/components/scenes/live2d/Model.vue b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue similarity index 96% rename from packages/stage-ui/src/components/scenes/live2d/Model.vue rename to packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue index 869657757..7f8f63e92 100644 --- a/packages/stage-ui/src/components/scenes/live2d/Model.vue +++ b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue @@ -24,7 +24,6 @@ import { } from '../../../composables/live2d' import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions' import { useLive2d } from '../../../stores/live2d' -import { useSettings } from '../../../stores/settings' const props = withDefaults(defineProps<{ modelSrc?: string @@ -40,12 +39,24 @@ const props = withDefaults(defineProps<{ xOffset?: number | string yOffset?: number | string scale?: number + themeColorsHue?: number + themeColorsHueDynamic?: boolean + live2dIdleAnimationEnabled?: boolean + live2dAutoBlinkEnabled?: boolean + live2dForceAutoBlinkEnabled?: boolean + live2dShadowEnabled?: boolean }>(), { mouthOpenSize: 0, paused: false, focusAt: () => ({ x: 0, y: 0 }), disableFocusAt: false, scale: 1, + themeColorsHue: 220.44, + themeColorsHueDynamic: false, + live2dIdleAnimationEnabled: true, + live2dAutoBlinkEnabled: true, + live2dForceAutoBlinkEnabled: false, + live2dShadowEnabled: true, }) const emits = defineEmits<{ @@ -136,14 +147,12 @@ const { modelParameters, } = storeToRefs(live2dStore) -const { - themeColorsHue, - themeColorsHueDynamic, - live2dIdleAnimationEnabled, - live2dAutoBlinkEnabled, - live2dForceAutoBlinkEnabled, - live2dShadowEnabled, -} = storeToRefs(useSettings()) +const themeColorsHue = toRef(() => props.themeColorsHue) +const themeColorsHueDynamic = toRef(() => props.themeColorsHueDynamic) +const live2dIdleAnimationEnabled = toRef(() => props.live2dIdleAnimationEnabled) +const live2dAutoBlinkEnabled = toRef(() => props.live2dAutoBlinkEnabled) +const live2dForceAutoBlinkEnabled = toRef(() => props.live2dForceAutoBlinkEnabled) +const live2dShadowEnabled = toRef(() => props.live2dShadowEnabled) const localCurrentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 }) const beatSync = createBeatSyncController({ diff --git a/packages/stage-ui/src/components/scenes/live2d/index.ts b/packages/stage-ui-live2d/src/components/scenes/live2d/index.ts similarity index 100% rename from packages/stage-ui/src/components/scenes/live2d/index.ts rename to packages/stage-ui-live2d/src/components/scenes/live2d/index.ts diff --git a/packages/stage-ui/src/composables/live2d/animation.ts b/packages/stage-ui-live2d/src/composables/live2d/animation.ts similarity index 73% rename from packages/stage-ui/src/composables/live2d/animation.ts rename to packages/stage-ui-live2d/src/composables/live2d/animation.ts index 1483139f6..8adb00bc3 100644 --- a/packages/stage-ui/src/composables/live2d/animation.ts +++ b/packages/stage-ui-live2d/src/composables/live2d/animation.ts @@ -1,6 +1,6 @@ import type { InternalModel } from 'pixi-live2d-display/cubism4' -import { lerp, randFloat } from 'three/src/math/MathUtils.js' +import { MathUtils } from 'three' import { randomSaccadeInterval } from '../../utils' @@ -17,7 +17,7 @@ export function useLive2DIdleEyeFocus() { // Function to handle idle eye saccades and focus (head) movements function update(model: InternalModel, now: number) { if (now >= nextSaccadeAfter || now < lastSaccadeAt) { - focusTarget = [randFloat(-1, 1), randFloat(-1, 0.7)] + focusTarget = [MathUtils.randFloat(-1, 1), MathUtils.randFloat(-1, 0.7)] lastSaccadeAt = now nextSaccadeAfter = now + (randomSaccadeInterval() / 1000) model.focusController.focus(focusTarget![0] * 0.5, focusTarget![1] * 0.5, false) @@ -26,8 +26,8 @@ export function useLive2DIdleEyeFocus() { model.focusController.update(now - lastSaccadeAt) const coreModel = model.coreModel as any // TODO: After emotion mapper, stage editor, eye related parameters should be take cared to be dynamical instead of hardcoding - coreModel.setParameterValueById('ParamEyeBallX', lerp(coreModel.getParameterValueById('ParamEyeBallX'), focusTarget![0], 0.3)) - coreModel.setParameterValueById('ParamEyeBallY', lerp(coreModel.getParameterValueById('ParamEyeBallY'), focusTarget![1], 0.3)) + coreModel.setParameterValueById('ParamEyeBallX', MathUtils.lerp(coreModel.getParameterValueById('ParamEyeBallX'), focusTarget![0], 0.3)) + coreModel.setParameterValueById('ParamEyeBallY', MathUtils.lerp(coreModel.getParameterValueById('ParamEyeBallY'), focusTarget![1], 0.3)) } return { update } diff --git a/packages/stage-ui/src/composables/live2d/beat-sync.ts b/packages/stage-ui-live2d/src/composables/live2d/beat-sync.ts similarity index 100% rename from packages/stage-ui/src/composables/live2d/beat-sync.ts rename to packages/stage-ui-live2d/src/composables/live2d/beat-sync.ts diff --git a/packages/stage-ui/src/composables/live2d/index.ts b/packages/stage-ui-live2d/src/composables/live2d/index.ts similarity index 100% rename from packages/stage-ui/src/composables/live2d/index.ts rename to packages/stage-ui-live2d/src/composables/live2d/index.ts diff --git a/packages/stage-ui/src/composables/live2d/motion-manager.ts b/packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts similarity index 100% rename from packages/stage-ui/src/composables/live2d/motion-manager.ts rename to packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts diff --git a/packages/stage-ui-live2d/src/constants/emotions.ts b/packages/stage-ui-live2d/src/constants/emotions.ts new file mode 100644 index 000000000..3cdae1c25 --- /dev/null +++ b/packages/stage-ui-live2d/src/constants/emotions.ts @@ -0,0 +1,56 @@ +export const EMOTION_HAPPY = '<|EMOTE_HAPPY|>' +export const EMOTION_SAD = '<|EMOTE_SAD|>' +export const EMOTION_ANGRY = '<|EMOTE_ANGRY|>' +export const EMOTION_THINK = '<|EMOTE_THINK|>' +export const EMOTION_SURPRISE = '<|EMOTE_SURPRISED|>' +export const EMOTION_AWKWARD = '<|EMOTE_AWKWARD|>' +export const EMOTION_QUESTION = '<|EMOTE_QUESTION|>' +export const EMOTION_CURIOUS = '<|EMOTE_CURIOUS|>' + +export enum Emotion { + Idle = '<|EMOTE_NEUTRAL|>', + Happy = '<|EMOTE_HAPPY|>', + Sad = '<|EMOTE_SAD|>', + Angry = '<|EMOTE_ANGRY|>', + Think = '<|EMOTE_THINK|>', + Surprise = '<|EMOTE_SURPRISED|>', + Awkward = '<|EMOTE_AWKWARD|>', + Question = '<|EMOTE_QUESTION|>', + Curious = '<|EMOTE_CURIOUS|>', +} + +export const EMOTION_VALUES = Object.values(Emotion) + +export const EmotionHappyMotionName = 'Happy' +export const EmotionSadMotionName = 'Sad' +export const EmotionAngryMotionName = 'Angry' +export const EmotionAwkwardMotionName = 'Awkward' +export const EmotionThinkMotionName = 'Think' +export const EmotionSurpriseMotionName = 'Surprise' +export const EmotionQuestionMotionName = 'Question' +export const EmotionNeutralMotionName = 'Idle' +export const EmotionCuriousMotionName = 'Curious' + +export const EMOTION_EmotionMotionName_value = { + [Emotion.Happy]: EmotionHappyMotionName, + [Emotion.Sad]: EmotionSadMotionName, + [Emotion.Angry]: EmotionAngryMotionName, + [Emotion.Think]: EmotionThinkMotionName, + [Emotion.Surprise]: EmotionSurpriseMotionName, + [Emotion.Awkward]: EmotionAwkwardMotionName, + [Emotion.Question]: EmotionQuestionMotionName, + [Emotion.Idle]: EmotionNeutralMotionName, + [Emotion.Curious]: EmotionCuriousMotionName, +} + +export const EMOTION_VRMExpressionName_value = { + [Emotion.Happy]: 'happy', + [Emotion.Sad]: 'sad', + [Emotion.Angry]: 'angry', + [Emotion.Think]: undefined, + [Emotion.Surprise]: 'surprised', + [Emotion.Awkward]: undefined, + [Emotion.Question]: undefined, + [Emotion.Idle]: undefined, + [Emotion.Curious]: 'surprised', +} satisfies Record diff --git a/packages/stage-ui-live2d/src/index.ts b/packages/stage-ui-live2d/src/index.ts new file mode 100644 index 000000000..0dc3d6ffd --- /dev/null +++ b/packages/stage-ui-live2d/src/index.ts @@ -0,0 +1,11 @@ +export { Live2DCanvas, Live2DModel } from './components/scenes/live2d' +export { default as Live2DScene } from './components/scenes/Live2D.vue' +export * from './composables/live2d' +export * from './constants/emotions' +export * from './stores' +export { randomSaccadeInterval } from './utils/eye-motions' +export * from './utils/live2d-opfs-registration' +export * from './utils/live2d-preview' +export * from './utils/live2d-uri-encode-filenames' +export * from './utils/live2d-zip-loader' +export * from './utils/opfs-loader' diff --git a/packages/stage-ui-live2d/src/stores/index.ts b/packages/stage-ui-live2d/src/stores/index.ts new file mode 100644 index 000000000..66e76b8f6 --- /dev/null +++ b/packages/stage-ui-live2d/src/stores/index.ts @@ -0,0 +1 @@ +export * from './live2d' diff --git a/packages/stage-ui-live2d/src/stores/live2d.ts b/packages/stage-ui-live2d/src/stores/live2d.ts new file mode 100644 index 000000000..20e283fe1 --- /dev/null +++ b/packages/stage-ui-live2d/src/stores/live2d.ts @@ -0,0 +1,92 @@ +import { refManualReset, useBroadcastChannel, useLocalStorage } from '@vueuse/core' +import { defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +type BroadcastChannelEvents + = | BroadcastChannelEventShouldUpdateView + +interface BroadcastChannelEventShouldUpdateView { + type: 'should-update-view' +} + +export const defaultModelParameters = { + angleX: 0, + angleY: 0, + angleZ: 0, + leftEyeOpen: 1, + rightEyeOpen: 1, + leftEyeSmile: 0, + rightEyeSmile: 0, + leftEyebrowLR: 0, + rightEyebrowLR: 0, + leftEyebrowY: 0, + rightEyebrowY: 0, + leftEyebrowAngle: 0, + rightEyebrowAngle: 0, + leftEyebrowForm: 0, + rightEyebrowForm: 0, + mouthOpen: 0, + mouthForm: 0, + cheek: 0, + bodyAngleX: 0, + bodyAngleY: 0, + bodyAngleZ: 0, + breath: 0, +} + +export const useLive2d = defineStore('live2d', () => { + const { post, data } = useBroadcastChannel({ name: 'airi-stores-live2d' }) + const shouldUpdateViewHooks = ref void>>([]) + + const onShouldUpdateView = (hook: () => void) => { + shouldUpdateViewHooks.value.push(hook) + } + + function shouldUpdateView() { + post({ type: 'should-update-view' }) + shouldUpdateViewHooks.value.forEach(hook => hook()) + } + + watch(data, (event) => { + if (event.type === 'should-update-view') { + shouldUpdateViewHooks.value.forEach(hook => hook()) + } + }) + + const position = refManualReset<{ x: number, y: number }>(useLocalStorage('settings/live2d/position', { x: 0, y: 0 })) // position is relative to the center of the screen, units are % + const positionInPercentageString = computed(() => ({ + x: `${position.value.x}%`, + y: `${position.value.y}%`, + })) + const currentMotion = refManualReset<{ group: string, index?: number }>(() => ({ group: 'Idle', index: 0 })) + const availableMotions = refManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>(() => []) + const motionMap = refManualReset>(useLocalStorage('settings/live2d/motion-map', {})) + const scale = refManualReset(useLocalStorage('settings/live2d/scale', 1)) + + // Live2D model parameters + const modelParameters = refManualReset>(useLocalStorage('settings/live2d/parameters', defaultModelParameters)) + + function resetState() { + position.reset() + currentMotion.reset() + availableMotions.reset() + motionMap.reset() + scale.reset() + modelParameters.reset() + shouldUpdateView() + } + + return { + position, + positionInPercentageString, + currentMotion, + availableMotions, + motionMap, + scale, + modelParameters, + + onShouldUpdateView, + shouldUpdateView, + resetState, + } +}) diff --git a/packages/stage-ui/src/utils/eye-motions.ts b/packages/stage-ui-live2d/src/utils/eye-motions.ts similarity index 100% rename from packages/stage-ui/src/utils/eye-motions.ts rename to packages/stage-ui-live2d/src/utils/eye-motions.ts diff --git a/packages/stage-ui-live2d/src/utils/index.ts b/packages/stage-ui-live2d/src/utils/index.ts new file mode 100644 index 000000000..dfb6a7b0b --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/index.ts @@ -0,0 +1,7 @@ +export { randomSaccadeInterval } from './eye-motions' + +export * from './live2d-opfs-registration' +export * from './live2d-preview' +export * from './live2d-uri-encode-filenames' +export * from './live2d-zip-loader' +export * from './opfs-loader' diff --git a/packages/stage-ui/src/utils/live2d-opfs-registration.ts b/packages/stage-ui-live2d/src/utils/live2d-opfs-registration.ts similarity index 100% rename from packages/stage-ui/src/utils/live2d-opfs-registration.ts rename to packages/stage-ui-live2d/src/utils/live2d-opfs-registration.ts diff --git a/packages/stage-ui-live2d/src/utils/live2d-preview.ts b/packages/stage-ui-live2d/src/utils/live2d-preview.ts new file mode 100644 index 000000000..d5cc3ea8f --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/live2d-preview.ts @@ -0,0 +1,90 @@ +import cropImg from '@lemonneko/crop-empty-pixels' + +import { Application } from '@pixi/app' +import { extensions } from '@pixi/extensions' +import { Ticker, TickerPlugin } from '@pixi/ticker' +import { Live2DFactory, Live2DModel } from 'pixi-live2d-display/cubism4' + +/** + * Render a Live2D zip/file to an offscreen canvas and return a padded preview data URL. + */ +export async function loadLive2DModelPreview(file: File) { + Live2DModel.registerTicker(Ticker) + extensions.add(TickerPlugin) + + const previewWidth = 1440 + const previewHeight = 2560 + const previewResolution = 2 + + const offscreenCanvas = document.createElement('canvas') + offscreenCanvas.width = previewWidth * previewResolution + offscreenCanvas.height = previewHeight * previewResolution + offscreenCanvas.style.position = 'absolute' + offscreenCanvas.style.top = '0' + offscreenCanvas.style.left = '0' + offscreenCanvas.style.objectFit = 'cover' + offscreenCanvas.style.display = 'block' + offscreenCanvas.style.zIndex = '10000000000' + offscreenCanvas.style.opacity = '0' + document.body.appendChild(offscreenCanvas) + + const app = new Application({ + view: offscreenCanvas, + width: offscreenCanvas.width, + height: offscreenCanvas.height, + // Ensure the drawing buffer persists so toDataURL() can read pixels + preserveDrawingBuffer: true, + backgroundAlpha: 0, + autoDensity: false, + resolution: 1, + autoStart: false, + }) + app.stage.scale.set(previewResolution) + app.ticker.stop() + + const modelInstance = new Live2DModel() + const objUrl = URL.createObjectURL(file) + const res = await fetch(objUrl) + const blob = await res.blob() + + const cleanup = () => { + app.destroy() + if (offscreenCanvas.isConnected) + document.body.removeChild(offscreenCanvas) + URL.revokeObjectURL(objUrl) + } + + try { + await Live2DFactory.setupLive2DModel(modelInstance, [new File([blob], file.name)], { autoInteract: false }) + app.stage.addChild(modelInstance) + + modelInstance.x = 275 + modelInstance.y = 450 + modelInstance.width = previewWidth + modelInstance.height = previewHeight + modelInstance.scale.set(0.1, 0.1) + modelInstance.anchor.set(0.5, 0.5) + + await new Promise(resolve => setTimeout(resolve, 500)) + app.renderer.render(app.stage) + + const croppedCanvas = cropImg(offscreenCanvas) + + // padding to 12:16 + const paddingCanvas = document.createElement('canvas') + paddingCanvas.width = croppedCanvas.width > croppedCanvas.height / 16 * 12 ? croppedCanvas.width : croppedCanvas.height / 16 * 12 + paddingCanvas.height = paddingCanvas.width / 12 * 16 + const paddingCanvasCtx = paddingCanvas.getContext('2d')! + + paddingCanvasCtx.drawImage(croppedCanvas, (paddingCanvas.width - croppedCanvas.width) / 2, (paddingCanvas.height - croppedCanvas.height) / 2, croppedCanvas.width, croppedCanvas.height) + const paddingDataUrl = paddingCanvas.toDataURL() + + cleanup() + + return paddingDataUrl + } + catch (error) { + console.error(error) + cleanup() + } +} diff --git a/packages/stage-ui/src/utils/live2d-uri-encode-filenames.ts b/packages/stage-ui-live2d/src/utils/live2d-uri-encode-filenames.ts similarity index 100% rename from packages/stage-ui/src/utils/live2d-uri-encode-filenames.ts rename to packages/stage-ui-live2d/src/utils/live2d-uri-encode-filenames.ts diff --git a/packages/stage-ui/src/utils/live2d-zip-loader.ts b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts similarity index 100% rename from packages/stage-ui/src/utils/live2d-zip-loader.ts rename to packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts diff --git a/packages/stage-ui/src/utils/opfs-loader.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.ts similarity index 100% rename from packages/stage-ui/src/utils/opfs-loader.ts rename to packages/stage-ui-live2d/src/utils/opfs-loader.ts diff --git a/packages/stage-ui-live2d/tsconfig.json b/packages/stage-ui-live2d/tsconfig.json new file mode 100644 index 000000000..c53f50ef3 --- /dev/null +++ b/packages/stage-ui-live2d/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "lib": [ + "DOM", + "ESNext", + "DOM.AsyncIterable" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": [ + "vite/client" + ], + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/stage-ui-three/package.json b/packages/stage-ui-three/package.json index fe65c3df9..67a9a567d 100644 --- a/packages/stage-ui-three/package.json +++ b/packages/stage-ui-three/package.json @@ -17,6 +17,7 @@ "exports": { "./assets/vrm": "./src/assets/vrm/index.ts", "./composables/vrm": "./src/composables/vrm/index.ts", + "./utils/vrm-preview": "./src/utils/vrm-preview.ts", ".": "./src/index.ts" }, "scripts": { diff --git a/packages/stage-ui-three/src/index.ts b/packages/stage-ui-three/src/index.ts index a863ab165..09e45f039 100644 --- a/packages/stage-ui-three/src/index.ts +++ b/packages/stage-ui-three/src/index.ts @@ -1,2 +1,3 @@ export { default as ThreeScene } from './components/ThreeScene.vue' export { useModelStore } from './stores/model-store' +export * from './utils/vrm-preview' diff --git a/packages/stage-ui-three/src/utils/vrm-preview.ts b/packages/stage-ui-three/src/utils/vrm-preview.ts new file mode 100644 index 000000000..1a98e4f62 --- /dev/null +++ b/packages/stage-ui-three/src/utils/vrm-preview.ts @@ -0,0 +1,97 @@ +import type { VRM } from '@pixiv/three-vrm' + +import { AmbientLight, AnimationMixer, DirectionalLight, PerspectiveCamera, Scene, WebGLRenderer } from 'three' + +import { animations } from '../assets/vrm' +import { clipFromVRMAnimation, loadVrm, loadVRMAnimation, reAnchorRootPositionTrack } from '../composables/vrm' + +/** + * Render a VRM file to an offscreen canvas and return a preview data URL. + */ +export async function loadVrmModelPreview(file: File) { + const offscreenCanvas = document.createElement('canvas') + offscreenCanvas.width = 1440 + offscreenCanvas.height = 2560 + offscreenCanvas.style.position = 'absolute' + offscreenCanvas.style.top = '0' + offscreenCanvas.style.left = '0' + offscreenCanvas.style.objectFit = 'cover' + offscreenCanvas.style.display = 'block' + offscreenCanvas.style.zIndex = '10000000000' + offscreenCanvas.style.opacity = '0' + document.body.appendChild(offscreenCanvas) + + const renderer = new WebGLRenderer({ + canvas: offscreenCanvas, + alpha: true, + antialias: true, + preserveDrawingBuffer: true, + }) + renderer.setSize(offscreenCanvas.width, offscreenCanvas.height, false) + renderer.setPixelRatio(1) + + const scene = new Scene() + const camera = new PerspectiveCamera(40, offscreenCanvas.width / offscreenCanvas.height, 0.01, 1000) + const ambientLight = new AmbientLight(0xFFFFFF, 0.8) + const directionalLight = new DirectionalLight(0xFFFFFF, 0.8) + directionalLight.position.set(1, 1, 1) + scene.add(ambientLight, directionalLight) + + const objUrl = URL.createObjectURL(file) + let vrmInstance: VRM | undefined + + try { + const vrmData = await loadVrm(objUrl, { scene, lookAt: true }) + if (!vrmData) + return + + vrmInstance = vrmData._vrm + const { modelCenter, initialCameraOffset } = vrmData + + camera.position.copy(modelCenter).add(initialCameraOffset) + camera.lookAt(modelCenter) + camera.updateProjectionMatrix() + + try { + const animation = await loadVRMAnimation(animations.idleLoop.toString()) + const clip = await clipFromVRMAnimation(vrmData._vrm, animation) + if (clip) { + reAnchorRootPositionTrack(clip, vrmData._vrm) + const mixer = new AnimationMixer(vrmData._vrm.scene) + const action = mixer.clipAction(clip) + action.play() + mixer.update(0.1) + } + } + catch (err) { + console.warn('Failed to load VRM animation for preview:', err) + } + + renderer.render(scene, camera) + + const dataUrl = offscreenCanvas.toDataURL() + return dataUrl + } + finally { + renderer.dispose() + if (vrmInstance) { + vrmInstance.scene.traverse((child) => { + const node = child as any + if (node.geometry?.dispose) + node.geometry.dispose() + + if (node.material) { + const materials = Array.isArray(node.material) ? node.material : [node.material] + for (const mat of materials) { + if (mat?.map?.dispose) + mat.map.dispose() + mat?.dispose?.() + } + } + }) + } + URL.revokeObjectURL(objUrl) + if (offscreenCanvas.isConnected) + document.body.removeChild(offscreenCanvas) + } +} diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index d9fe5e789..91791a6ec 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -51,24 +51,7 @@ "@date-fns/utc": "^2.1.1", "@formkit/auto-animate": "^0.9.0", "@huggingface/transformers": "^3.8.1", - "@lemonneko/crop-empty-pixels": "0.1.1", "@moeru/eventa": "^1.0.0-alpha.10", - "@pixi/app": "^6.5.10", - "@pixi/constants": "^6.5.10", - "@pixi/core": "^6.5.10", - "@pixi/display": "^6.5.10", - "@pixi/extensions": "^6.5.10", - "@pixi/interaction": "^6.5.10", - "@pixi/loaders": "^6.5.10", - "@pixi/math": "^6.5.10", - "@pixi/runner": "^6.5.10", - "@pixi/settings": "^6.5.10", - "@pixi/sprite": "^6.5.10", - "@pixi/ticker": "^6.5.10", - "@pixi/utils": "6", - "@pixiv/three-vrm": "^3.4.4", - "@pixiv/three-vrm-animation": "^3.4.4", - "@pixiv/three-vrm-core": "^3.4.4", "@proj-airi/audio": "workspace:^", "@proj-airi/ccc": "workspace:^", "@proj-airi/chromatic": "^1.0.2", @@ -79,6 +62,7 @@ "@proj-airi/model-driver-lipsync": "workspace:^", "@proj-airi/server-sdk": "workspace:^", "@proj-airi/stage-shared": "workspace:^", + "@proj-airi/stage-ui-live2d": "workspace:^", "@proj-airi/stage-ui-three": "workspace:^", "@proj-airi/tauri-plugin-mcp": "workspace:^", "@proj-airi/ui": "workspace:^", @@ -110,17 +94,13 @@ "gpuu": "^1.0.6", "hono": "catalog:", "html2canvas": "^1.4.1", - "jszip": "^3.10.1", "localforage": "^1.10.0", "mediabunny": "^1.27.2", "nanoid": "^5.1.6", "node-vibrant": "^4.0.3", "ofetch": "^1.5.1", "pinia": "^3.0.4", - "pixi-filters": "4", - "pixi-live2d-display": "^0.4.0", "posthog-js": "catalog:", - "postprocessing": "^6.38.2", "rehype-katex": "^7.0.1", "rehype-parse": "^9.0.1", "rehype-stringify": "^10.0.1", @@ -130,7 +110,6 @@ "remark-rehype": "^11.1.2", "shiki": "^3.20.0", "splitpanes": "catalog:", - "three": "^0.182.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-visit": "^5.0.0", @@ -174,7 +153,6 @@ "@types/culori": "^4.0.1", "@types/hast": "catalog:", "@types/splitpanes": "catalog:", - "@types/three": "^0.182.0", "@types/unist": "catalog:", "@unocss/reset": "^66.5.11", "@vitejs/plugin-vue": "^6.0.3", diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue index c11eebb8c..8cb602da9 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue @@ -1,18 +1,17 @@