diff --git a/LICENSE b/LICENSE index 511f0931..f19e2338 100644 --- a/LICENSE +++ b/LICENSE @@ -19,8 +19,8 @@ copy, modify, and distribute copies of the Software for **non-commercial purpose implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. -*Note: This license applies only to non-commercial use. For commercial use, -please refer to the Commercial License below.* +_Note: This license applies only to non-commercial use. For commercial use, +please refer to the Commercial License below._ =============================================================================== Commercial License diff --git a/locales/en/translation.json b/locales/en/translation.json index de38d892..73d56dc5 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -344,6 +344,9 @@ "CustomAPIDescription": "Note: Messages are automatically included in the request body. In streaming mode, the server must return text/event-stream.", "EditSlideScripts": "Edit Dialogue", "PleaseSelectSlide": "Please select a slide", + "QueueCheckDelay": "Queue Check Delay", + "QueueCheckDelayInfo": "Time to wait before resetting to neutral expression after speech queue becomes empty (in seconds).", + "QueueCheckDelayLabel": "Queue Check Delay: {{delay}}s", "XAIAPIKeyLabel": "xAI API Key", "DynamicRetrievalDescription": "Sets the threshold for when the model performs a search. If 0, the search is always performed; if 1, the search is never performed.", "DynamicRetrieval": "Dynamic Search", diff --git a/locales/ja/translation.json b/locales/ja/translation.json index 107c2bfe..81fcc9bf 100644 --- a/locales/ja/translation.json +++ b/locales/ja/translation.json @@ -367,6 +367,9 @@ "XAIAPIKeyLabel": "xAI APIキー", "OpenRouterAPIKeyLabel": "OpenRouter APIキー", "OpenRouterModelNameInstruction": "OpenRouterからモデル識別子を入力してください(例: \"openai/gpt-4o\", \"mistralai/mistral-large-latest\")。モデル識別子はOpenRouterモデルページで確認できます。", + "QueueCheckDelay": "キューチェック遅延", + "QueueCheckDelayInfo": "音声キューが空になった後、ニュートラル表情にリセットするまでの待機時間(秒)。", + "QueueCheckDelayLabel": "キューチェック遅延: {{delay}}秒", "ImageDisplayPosition": "画像表示位置", "ImageDisplayPositionDescription": "アップロードした画像を表示する位置を選択してください", "InputArea": "入力エリア", diff --git a/public/idle_loop.vrma b/public/idle_loop.vrma index 26b28f4e..507cf56f 100644 Binary files a/public/idle_loop.vrma and b/public/idle_loop.vrma differ diff --git a/public/idle_loop_.vrma b/public/idle_loop_.vrma new file mode 100644 index 00000000..26b28f4e Binary files /dev/null and b/public/idle_loop_.vrma differ diff --git a/public/vrma/angry.vrma b/public/vrma/angry.vrma new file mode 100644 index 00000000..ee53a821 Binary files /dev/null and b/public/vrma/angry.vrma differ diff --git a/public/vrma/happy.vrma b/public/vrma/happy.vrma new file mode 100644 index 00000000..18351775 Binary files /dev/null and b/public/vrma/happy.vrma differ diff --git a/public/vrma/sad.vrma b/public/vrma/sad.vrma new file mode 100644 index 00000000..64662e2e Binary files /dev/null and b/public/vrma/sad.vrma differ diff --git a/src/components/settings/character.tsx b/src/components/settings/character.tsx index b7b93877..1b1c466c 100644 --- a/src/components/settings/character.tsx +++ b/src/components/settings/character.tsx @@ -787,6 +787,31 @@ const Character = () => { ) })} + +
+
{t('QueueCheckDelay')}
+
+ {t('QueueCheckDelayInfo')} +
+
+ {t('QueueCheckDelayLabel', { + delay: settingsStore((s) => s.queueCheckDelay), + })} +
+ s.queueCheckDelay)} + onChange={(e) => + settingsStore.setState({ + queueCheckDelay: parseFloat(e.target.value), + }) + } + className="mt-2 mb-4 input-range" + /> +
) diff --git a/src/components/vrmViewer.tsx b/src/components/vrmViewer.tsx index 876c0940..29ea9714 100644 --- a/src/components/vrmViewer.tsx +++ b/src/components/vrmViewer.tsx @@ -40,6 +40,10 @@ export default function VrmViewer() { const image = reader.result as string image !== '' && homeStore.setState({ modalImage: image }) } + } else if (file_type === 'vrma') { + const blob = new Blob([file], { type: 'application/octet-stream' }) + const url = window.URL.createObjectURL(blob) + viewer.loadVrma(url) } }) } diff --git a/src/features/messages/speakCharacter.ts b/src/features/messages/speakCharacter.ts index 8905388a..7c4c42e4 100644 --- a/src/features/messages/speakCharacter.ts +++ b/src/features/messages/speakCharacter.ts @@ -206,6 +206,7 @@ const createSpeakCharacter = () => { ) => { let called = false const ss = settingsStore.getState() + const hs = homeStore.getState() onStart?.() const initialToken = SpeakQueue.currentStopToken @@ -327,6 +328,15 @@ const createSpeakCharacter = () => { isNeedDecode: result.isNeedDecode, onComplete: guardedOnComplete, // Pass the guarded function }) + + // VRMの場合、ここで感情アニメーションを再生 + if (ss.modelType === 'vrm') { + if (hs.viewer && talk.emotion && talk.emotion !== 'neutral') { + // neutral は常時アイドルアニメーションが流れているため、個別に再生しない + hs.viewer.playEmotionAnimation(talk.emotion, 0.3) + console.log(`VRM emotion animation started: ${talk.emotion}`) + } + } }) .catch((error) => { console.error('Error in processAndSynthesizePromise chain:', error) diff --git a/src/features/messages/speakQueue.ts b/src/features/messages/speakQueue.ts index 52e21d9b..4c9b620c 100644 --- a/src/features/messages/speakQueue.ts +++ b/src/features/messages/speakQueue.ts @@ -12,7 +12,6 @@ type SpeakTask = { } export class SpeakQueue { - private static readonly QUEUE_CHECK_DELAY = 1500 private queue: SpeakTask[] = [] private isProcessing = false private currentSessionId: string | null = null @@ -148,8 +147,9 @@ export class SpeakQueue { private async scheduleNeutralExpression() { const initialLength = this.queue.length + const ss = settingsStore.getState() await new Promise((resolve) => - setTimeout(resolve, SpeakQueue.QUEUE_CHECK_DELAY) + setTimeout(resolve, ss.queueCheckDelay * 1000) ) if (this.shouldResetToNeutral(initialLength)) { @@ -159,6 +159,7 @@ export class SpeakQueue { await Live2DHandler.resetToIdle() } else { await hs.viewer.model?.playEmotion('neutral') + hs.viewer.switchToIdleAnimation() } } } diff --git a/src/features/stores/settings.ts b/src/features/stores/settings.ts index f7b8c827..4ecae84b 100644 --- a/src/features/stores/settings.ts +++ b/src/features/stores/settings.ts @@ -206,6 +206,7 @@ interface General { whisperTranscriptionModel: WhisperTranscriptionModel initialSpeechTimeout: number chatLogWidth: number + queueCheckDelay: number imageDisplayPosition: 'input' | 'side' | 'icon' multiModalMode: 'ai-decide' | 'always' | 'never' multiModalAiDecisionPrompt: string @@ -538,6 +539,8 @@ const getInitialValuesFromEnv = (): SettingsState => ({ angryMotionGroup: process.env.NEXT_PUBLIC_ANGRY_MOTION_GROUP || '', relaxedMotionGroup: process.env.NEXT_PUBLIC_RELAXED_MOTION_GROUP || '', surprisedMotionGroup: process.env.NEXT_PUBLIC_SURPRISED_MOTION_GROUP || '', + queueCheckDelay: + parseFloat(process.env.NEXT_PUBLIC_QUEUE_CHECK_DELAY || '1.5') || 1.5, }) const settingsStore = create()( @@ -710,6 +713,7 @@ const settingsStore = create()( enableMultiModal: state.enableMultiModal, colorTheme: state.colorTheme, customModel: state.customModel, + queueCheckDelay: state.queueCheckDelay, }), }) ) diff --git a/src/features/vrmViewer/model.ts b/src/features/vrmViewer/model.ts index 5886f8c0..2bae1c15 100644 --- a/src/features/vrmViewer/model.ts +++ b/src/features/vrmViewer/model.ts @@ -11,6 +11,8 @@ import { VRMLookAtSmootherLoaderPlugin } from '@/lib/VRMLookAtSmootherLoaderPlug import { LipSync } from '../lipSync/lipSync' import { EmoteController } from '../emoteController/emoteController' import { Talk } from '../messages/messages' +import { loadVRMAnimationClip } from '@/lib/VRMAnimation/loadVRMAnimation' +import { buildUrl } from '@/utils/buildUrl' /** * 3Dキャラクターを管理するクラス @@ -19,6 +21,21 @@ export class Model { public vrm?: VRM | null public mixer?: THREE.AnimationMixer public emoteController?: EmoteController + private _animationActions: Map = new Map() + private _currentAction: THREE.AnimationAction | null = null + private _idleAnimationName: string = 'idle_default' // Default idle animation name + + // 仮の感情アニメーションマッピング (パスはsettingsStoreなどから取得することを想定) + private _emotionAnimationPaths: Map = new Map([ + ['neutral', '/neutral.vrma'], // デフォルトアイドルをneutralとする + ['happy', '/vrma/happy.vrma'], // TODO: 実際のパスに置き換える + ['sad', '/vrma/sad.vrma'], // TODO: 実際のパスに置き換える + ['angry', '/vrma/angry.vrma'], // TODO: 実際のパスに置き換える + // 他の感情も追加可能 + ]) + private _isEmotionAnimating: boolean = false + private _watchingEmotionAction: THREE.AnimationAction | null = null + private _emotionFadeOutThreshold: number = 0.5 // 終了の何秒前にアイドルへ移行するか private _lookAtTargetParent: THREE.Object3D private _lipSync?: LipSync @@ -56,19 +73,241 @@ export class Model { } /** - * VRMアニメーションを読み込む - * - * https://github.com/vrm-c/vrm-specification/blob/master/specification/VRMC_vrm_animation-1.0/README.ja.md + * VRMアニメーションを読み込む(従来の方法) + */ + public async loadAnimation( + vrmAnimation: VRMAnimation, + animationName?: string + ): Promise { + if (this.vrm == null || this.mixer == null) { + console.error('VRM or Mixer not initialized in loadAnimation') + throw new Error('You have to load VRM first') + } + + const clip = vrmAnimation.createAnimationClip(this.vrm) + const action = this.mixer.clipAction(clip) + + const name = + animationName || clip.name || `animation_${this._animationActions.size}` + clip.name = name + if (this._animationActions.has(name)) { + console.warn(`Animation "${name}" already exists. Overwriting.`) + this._animationActions.get(name)?.stop() + } + this._animationActions.set(name, action) + + console.log(`Animation "${name}" loaded.`) + return action + } + + /** + * VRMアニメーションを読み込む(ベストプラクティス準拠) */ - public async loadAnimation(vrmAnimation: VRMAnimation): Promise { - const { vrm, mixer } = this - if (vrm == null || mixer == null) { + public async loadAnimationFromUrl( + url: string, + animationName?: string + ): Promise { + if (this.vrm == null || this.mixer == null) { + console.error('VRM or Mixer not initialized in loadAnimationFromUrl') throw new Error('You have to load VRM first') } - const clip = vrmAnimation.createAnimationClip(vrm) - const action = mixer.clipAction(clip) - action.play() + try { + const clip = await loadVRMAnimationClip(url, this.vrm) + if (!clip) { + console.warn(`Failed to load animation clip from ${url}`) + return undefined + } + + const action = this.mixer.clipAction(clip) + const name = + animationName || clip.name || `animation_${this._animationActions.size}` + clip.name = name + + if (this._animationActions.has(name)) { + console.warn(`Animation "${name}" already exists. Overwriting.`) + this._animationActions.get(name)?.stop() + } + this._animationActions.set(name, action) + + console.log(`Animation "${name}" loaded from URL with best practices.`) + return action + } catch (error) { + console.error(`Error loading animation from ${url}:`, error) + return undefined + } + } + + /** + * 不要なTranslationトラックを除去(Hips以外) + */ + private removeUnnecessaryTranslationTracks(clip: THREE.AnimationClip) { + clip.tracks = clip.tracks.filter((track) => { + // Hipsボーンの位置変化は保持、その他のTranslationは除去 + if ( + track.name.includes('.position') && + !track.name.toLowerCase().includes('hips') + ) { + console.log(`Removing unnecessary translation track: ${track.name}`) + return false + } + return true + }) + } + + /** + * 足を地面に固定する処理 + */ + private constrainFeetToGround() { + if (!this.vrm?.humanoid) return + + const leftFoot = this.vrm.humanoid.getNormalizedBoneNode('leftFoot') + const rightFoot = this.vrm.humanoid.getNormalizedBoneNode('rightFoot') + + // 地面のY座標(通常は0) + const groundY = 0 + + if (leftFoot) { + const worldPosition = new THREE.Vector3() + leftFoot.getWorldPosition(worldPosition) + + // 足が地面より下にある場合、地面の高さに調整 + if (worldPosition.y < groundY) { + const localPosition = leftFoot.position.clone() + localPosition.y += groundY - worldPosition.y + leftFoot.position.copy(localPosition) + } + } + + if (rightFoot) { + const worldPosition = new THREE.Vector3() + rightFoot.getWorldPosition(worldPosition) + + // 足が地面より下にある場合、地面の高さに調整 + if (worldPosition.y < groundY) { + const localPosition = rightFoot.position.clone() + localPosition.y += groundY - worldPosition.y + rightFoot.position.copy(localPosition) + } + } + } + + public async loadFbxAnimation( + clip: THREE.AnimationClip, + animationName?: string // アニメーションを識別するための名前 + ): Promise { + if (this.vrm == null || this.mixer == null) { + throw new Error('You have to load VRM first') + } + + // this.mixer = new THREE.AnimationMixer(this.vrm.scene) // reset animation mixer, otherwise funny merge + + const action = this.mixer.clipAction(clip) + const name = + animationName || clip.name || `animation_${this._animationActions.size}` + clip.name = name // Ensure the AnimationClip itself has the correct name + this._animationActions.set(name, action) + + return action + } + + /** + * 指定された名前のアニメーションを再生する(クロスフェードなし) + */ + public playAnimation( + name: string, + loop: THREE.AnimationActionLoopStyles = THREE.LoopRepeat + ) { + const action = this._animationActions.get(name) + if (!action) { + console.warn(`Animation "${name}" not found.`) + return + } + + if (this._currentAction && this._currentAction !== action) { + this._currentAction.stop() + } + + action.reset().setLoop(loop, Infinity).play() + this._currentAction = action + } + + /** + * 指定された名前のアニメーションにクロスフェードで遷移する + * @param name 再生するアニメーション名 + * @param duration フェード時間 (秒) + */ + public crossFadeToAnimation( + name: string, + duration: number, + loop: THREE.AnimationActionLoopStyles = THREE.LoopRepeat + ) { + const nextAction = this._animationActions.get(name) + if (!nextAction) { + console.warn(`Animation "${name}" not found for crossfade.`) + return + } + + // 現在アクションが同じで既に再生中の場合は何もしない + if (this._currentAction === nextAction && nextAction.isRunning()) { + console.log(`Animation "${name}" is already current and running.`) + return + } + + // crossfade 可能かどうか判定 + const canCrossFade = + this._currentAction && this._currentAction !== nextAction + + console.log( + `[crossFadeToAnimation] canCrossFade: ${canCrossFade}, currentAction: ${this._currentAction?.getClip().name}, nextAction: ${nextAction.getClip().name}` + ) + + // 次のアクションを準備して再生 + nextAction.reset().setLoop(loop, Infinity).play() + + if (canCrossFade) { + // 通常のクロスフェード + this._currentAction!.crossFadeTo(nextAction, duration, true) + } else { + // クロスフェードできない場合はすぐに次アクションへ切り替え + // 1) 以前のアクションがまだ残っていれば停止してウェイトを 0 にする + if (this._currentAction && this._currentAction !== nextAction) { + this._currentAction.stop() + } + + // 2) 次アクションを有効化しウェイトを 1 に設定して即時反映 (T ポーズ防止) + nextAction.enabled = true + nextAction.setEffectiveWeight(0.05) + } + + this._currentAction = nextAction + this._isEmotionAnimating = + name !== this._idleAnimationName && loop === THREE.LoopOnce + } + + /** + * 現在のアニメーションを停止する + */ + public stopCurrentAnimation(duration: number = 0.25) { + if (this._currentAction) { + // アイドルアニメーションなどがあれば、そちらにフェードアウトすることも検討 + this._currentAction.fadeOut(duration) + // this._currentAction = null // アイドルへの遷移後にnull化など + } + } + + /** + * すべてのアニメーションアクションを停止・リセットする + */ + public resetAnimations() { + this.mixer?.stopAllAction() + // アクション自体は保持しておくので、クリアはしない + // this._animationActions.forEach(action => { + // // 必要に応じて action.reset() なども + // }) + this._currentAction = null + this._isEmotionAnimating = false + this._watchingEmotionAction = null } /** @@ -105,6 +344,66 @@ export class Model { this.emoteController?.playEmotion(preset) } + /** + * アイドルアニメーションの名前を設定する + */ + public setIdleAnimationName(name: string) { + this._idleAnimationName = name + } + + /** + * 定義された感情アニメーションをすべてロードする + */ + public async loadAllEmotionAnimations(): Promise { + if (!this.vrm || !this.mixer) { + console.error('VRM or Mixer not ready for loading emotion animations.') + return + } + console.log('Loading all emotion animations...') + for (const [emotion, path] of this._emotionAnimationPaths) { + try { + // buildUrlを使用してパスを構築 + const fullPath = buildUrl(path) + // ベストプラクティス準拠の新しいメソッドを使用 + await this.loadAnimationFromUrl(fullPath, emotion) + } catch (error) { + console.error( + `Error loading emotion animation for "${emotion}" from ${path}:`, + error + ) + } + } + console.log('Finished loading emotion animations.') + } + + /** + * 指定された感情のアニメーションを再生する。 + * 再生終了後 (ループしない場合) はアイドルアニメーションに戻る。 + */ + public playEmotionAnimation( + emotionName: string, + crossFadeDuration: number = 0.3 + ) { + if (!this._animationActions.has(this._idleAnimationName)) { + console.warn( + 'Idle animation is not loaded. Cannot properly manage emotion animations.' + ) + // アイドルがない場合は、単純に指定された感情アニメーションを再生するだけでも良いかもしれない + } + + const emotionAction = this._animationActions.get(emotionName) + if (!emotionAction) { + console.warn(`Emotion animation "${emotionName}" not found.`) + return + } + + console.log( + `[playEmotionAnimation] Starting emotion: "${emotionName}". Current action: "${this._currentAction?.getClip().name}", Crossfade duration: ${crossFadeDuration}` + ) + this.crossFadeToAnimation(emotionName, crossFadeDuration, THREE.LoopRepeat) + this._isEmotionAnimating = true // 感情アニメーション中フラグを立てる + } + public update(delta: number): void { if (this._lipSync) { const { volume } = this._lipSync.update() @@ -113,6 +412,50 @@ export class Model { this.emoteController?.update(delta) this.mixer?.update(delta) + + // アニメーション更新後に足を地面に固定 + if (this._currentAction && this._currentAction.isRunning()) { + this.constrainFeetToGround() + } + this.vrm?.update(delta) } + + /** + * アイドルアニメーションに戻る + * @param crossFadeDuration クロスフェード時間 (秒) + */ + public returnToIdleAnimation(crossFadeDuration: number = 0.3) { + if (!this.vrm || !this.mixer) { + console.warn('VRM or Mixer not ready to return to idle animation.') + return + } + + if (!this._animationActions.has(this._idleAnimationName)) { + console.warn( + `Idle animation "${this._idleAnimationName}" not loaded. Cannot return to idle.` + ) + return + } + + // 現在再生中のアニメーションがアイドルアニメーションでなければ遷移する + if ( + !this._currentAction || + this._currentAction.getClip().name !== this._idleAnimationName + ) { + console.log( + `[returnToIdleAnimation] Transitioning to idle: "${this._idleAnimationName}", Crossfade duration: ${crossFadeDuration}` + ) + this.crossFadeToAnimation( + this._idleAnimationName, + crossFadeDuration, + THREE.LoopRepeat + ) + this._isEmotionAnimating = false + } else { + console.log( + `[returnToIdleAnimation] Already in idle animation: "${this._idleAnimationName}". No transition needed.` + ) + } + } } diff --git a/src/features/vrmViewer/viewer.ts b/src/features/vrmViewer/viewer.ts index 582e9d1f..21e9770d 100644 --- a/src/features/vrmViewer/viewer.ts +++ b/src/features/vrmViewer/viewer.ts @@ -1,3 +1,4 @@ +const defaultAnimationUrl = buildUrl('/idle_loop.vrma') import * as THREE from 'three' import { Model } from './model' import { loadVRMAnimation } from '@/lib/VRMAnimation/loadVRMAnimation' @@ -6,13 +7,15 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import settingsStore from '@/features/stores/settings' /** - * three.jsを使った3Dビューワー + * three.js を使用した 3D ビューア * - * setup()でcanvasを渡してから使う + * setup() で canvas を渡してから使用 */ export class Viewer { public isReady: boolean public model?: Model + private readonly idleAnimationName = 'idle_default' // アイドルアニメーションのデフォルト名 + private readonly defaultIdleAnimationPath = '/idle_loop.vrma' // デフォルトアイドルアニメーションのパス private _renderer?: THREE.WebGLRenderer private _clock: THREE.Clock @@ -22,7 +25,14 @@ export class Viewer { private _directionalLight?: THREE.DirectionalLight private _ambientLight?: THREE.AmbientLight + private _currentAnimationUrl: string + private _currentAnimationType: string + constructor() { + // current animation + this._currentAnimationUrl = defaultAnimationUrl + this._currentAnimationType = 'vrma' + this.isReady = false // scene @@ -65,11 +75,37 @@ export class Viewer { }) this._scene.add(this.model.vrm.scene) + this.model.setIdleAnimationName(this.idleAnimationName) // アイドルアニメーション名を設定 + + // Load and play default idle animation + // _currentAnimationUrl と _currentAnimationType はデフォルトアイドルアニメーションのパスとタイプとして解釈 + // 将来的には settingsStore などから取得するのが良いと想定 + this._currentAnimationUrl = buildUrl(this.defaultIdleAnimationPath) + this._currentAnimationType = 'vrma' + + if (this._currentAnimationUrl && this._currentAnimationType === 'vrma') { + try { + const vrma = await loadVRMAnimation(this._currentAnimationUrl) // buildUrl で処理されたパスを使用 + if (vrma && this.model) { + await this.model.loadAnimation(vrma, this.idleAnimationName) + this.model.playAnimation(this.idleAnimationName, THREE.LoopRepeat) + console.log( + `Default animation '${this.idleAnimationName}' loaded and playing.` + ) + + // アイドルアニメーションをロードした後、他の感情アニメーションもロード + await this.model.loadAllEmotionAnimations() + } else { + console.warn( + 'Failed to load default VRMA animation or model not ready.' + ) + } + } catch (error) { + console.error('Error loading default VRMA animation:', error) + } + } - const vrma = await loadVRMAnimation(buildUrl('/idle_loop.vrma')) - if (vrma) this.model.loadAnimation(vrma) - - // HACK: アニメーションの原点がずれているので再生後にカメラ位置を調整する + // HACK: アニメーションの原点に誤差があるため、後処理でカメラ位置を調整します。 requestAnimationFrame(() => { this.resetCamera() }) @@ -79,12 +115,63 @@ export class Viewer { public unloadVRM(): void { if (this.model?.vrm) { this._scene.remove(this.model.vrm.scene) + this.model?.resetAnimations() // Unload時にアニメーションもリセット this.model?.unLoadVrm() + this.model = undefined + } + } + + public async loadVrma( + url: string, + animationName?: string, + loop: boolean = true + ) { + if (!this.model?.vrm) { + console.warn('VRM model not loaded. Cannot load VRMA animation.') + return + } + const nameToLoad = animationName || `vrma_${Date.now()}` // 適切な名前を生成 + // this._currentAnimationUrl = url // 最後にロードしたURLを維持する必要性は減りました。 + // this._currentAnimationType = 'vrma' + + try { + const vrma = await loadVRMAnimation(url) + if (vrma && this.model) { + await this.model.loadAnimation(vrma, nameToLoad) + this.model.crossFadeToAnimation( + nameToLoad, + 0.5, + loop ? THREE.LoopRepeat : THREE.LoopOnce + ) + console.log( + `VRMA animation '${nameToLoad}' loaded and playing with crossfade (loop: ${loop}).` + ) + } else { + console.warn(`Failed to load VRMA from ${url} or model not ready.`) + } + } catch (error) { + console.error(`Error loading VRMA animation from ${url}:`, error) + } + } + + /** + * 指定された感情のアニメーションを再生します。 + * @param emotionName 再生する感情の名前 (例: "happy", "sad") + * @param crossFadeDuration フェード時間 (秒) + */ + public playEmotionAnimation( + emotionName: string, + crossFadeDuration: number = 0.3 + ) { + if (this.model) { + this.model.playEmotionAnimation(emotionName, crossFadeDuration) + } else { + console.warn('Model not loaded. Cannot play emotion animation.') } } /** - * Reactで管理しているCanvasを後から設定する + * React で管理する Canvas を後で設定する */ public setup(canvas: HTMLCanvasElement) { const parentElement = canvas.parentElement @@ -130,7 +217,7 @@ export class Viewer { } /** - * canvasの親要素を参照してサイズを変更する + * canvas の親要素を参照してサイズを変更します。 */ public resize() { if (!this._renderer) return @@ -150,7 +237,7 @@ export class Viewer { } /** - * VRMのheadノードを参照してカメラ位置を調整する + * VRM の head ノードを参照してカメラ位置を調整します。 */ public resetCamera() { const { fixedCharacterPosition } = settingsStore.getState() @@ -187,6 +274,18 @@ export class Viewer { } } + /** + * アイドルアニメーションに切り替える + * @param crossFadeDuration クロスフェード時間 (秒) + */ + public switchToIdleAnimation(crossFadeDuration: number = 0.3) { + if (this.model) { + this.model.returnToIdleAnimation(crossFadeDuration) + } else { + console.warn('Model not loaded. Cannot switch to idle animation.') + } + } + /** * 現在のカメラ位置を設定に保存する */ diff --git a/src/lib/VRMAnimation/loadVRMAnimation.ts b/src/lib/VRMAnimation/loadVRMAnimation.ts index aaacc4f5..56515c00 100644 --- a/src/lib/VRMAnimation/loadVRMAnimation.ts +++ b/src/lib/VRMAnimation/loadVRMAnimation.ts @@ -1,8 +1,12 @@ +import * as THREE from 'three' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { VRMLoaderPlugin } from '@pixiv/three-vrm' import { VRMAnimation } from './VRMAnimation' import { VRMAnimationLoaderPlugin } from './VRMAnimationLoaderPlugin' +import type { VRM } from '@pixiv/three-vrm' const loader = new GLTFLoader() +loader.register((parser) => new VRMLoaderPlugin(parser)) loader.register((parser) => new VRMAnimationLoaderPlugin(parser)) export async function loadVRMAnimation( @@ -15,3 +19,25 @@ export async function loadVRMAnimation( return vrmAnimation ?? null } + +/** + * ベストプラクティスに従ったVRMAアニメーションの読み込みとAnimationClip作成 + */ +export async function loadVRMAnimationClip( + url: string, + vrm: VRM +): Promise { + try { + const gltf = await loader.loadAsync(url) + const vrmAnimations = gltf.userData.vrmAnimations + if (vrmAnimations && vrmAnimations.length > 0) { + // VRMAnimationからAnimationClipを作成 + const vrmAnimation: VRMAnimation = vrmAnimations[0] + return vrmAnimation.createAnimationClip(vrm) + } + return null + } catch (error) { + console.error('Error loading VRM animation clip:', error) + return null + } +}