feat(stage-tamagotchi,stage-ui-live2d): procedural motion generation (#2376)

---------

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Rin
2026-08-29 18:15:28 +08:00
committed by GitHub
co-authored by Neko Ayaka
parent 898a62e3b4
commit 8ddb35353e
106 changed files with 62603 additions and 110 deletions
@@ -14,5 +14,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict>
</plist>
@@ -210,6 +210,7 @@ export default {
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device',
NSCameraUsageDescription: 'AIRI requires camera access for vision understanding',
NSBluetoothAlwaysUsageDescription: 'AIRI uses Bluetooth to read game controller input',
},
// For self-publishing, testing, and distribution after modified the code without access to
// an Apple Developer account, comment and uncomment the following 4 lines.
+2 -1
View File
@@ -63,6 +63,8 @@
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/input-gamepad-vueuse": "workspace:^",
"@proj-airi/model-driver-lipsync": "workspace:^",
"@proj-airi/pipelines-audio": "workspace:^",
"@proj-airi/plugin-sdk-tamagotchi": "workspace:^",
"@proj-airi/server-sdk": "workspace:*",
@@ -102,7 +104,6 @@
"chess.js": "catalog:",
"colorjs.io": "catalog:",
"culori": "catalog:",
"d3": "catalog:",
"date-fns": "catalog:",
"defu": "catalog:",
"destr": "catalog:",
+3 -3
View File
@@ -38,7 +38,7 @@ import { setupExtensionHost } from './services/airi/plugins'
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
import { setupAutoUpdater } from './services/electron/auto-updater'
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
import { setupMediaPermissionHandlers } from './services/electron/media-permissions'
import { setupPermissionHandlers } from './services/electron/media-permissions'
import { setupTray } from './tray'
import { setupAboutWindowReusable } from './windows/about'
import { setupBeatSync } from './windows/beat-sync'
@@ -149,7 +149,7 @@ app.whenReady().then(async () => {
return
}
setupMediaPermissionHandlers(session.defaultSession, hasSelectedScreenCaptureSource)
setupPermissionHandlers(session.defaultSession, hasSelectedScreenCaptureSource)
// Initialize file logger and register the hook
fileLogger = await setupFileLogger()
@@ -220,7 +220,7 @@ app.whenReady().then(async () => {
const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService())
// BeatSync will create a background window to capture and process audio.
// Beat Sync uses a background renderer because Web Audio processing needs a DOM runtime.
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
@@ -1,8 +1,8 @@
import type { MediaAccessPermissionRequest, PermissionCheckHandlerHandlerDetails, WebContents } from 'electron'
import type { DevicePermissionHandlerHandlerDetails, HIDDevice, MediaAccessPermissionRequest, PermissionCheckHandlerHandlerDetails, Session, WebContents } from 'electron'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { shouldGrantAudioCapturePermission, shouldGrantElectronPermission } from './media-permissions'
import { setupPermissionHandlers, shouldGrantAudioCapturePermission, shouldGrantElectronPermission } from './media-permissions'
const localWebContents = {
getURL: () => 'file:///app/index.html',
@@ -29,6 +29,31 @@ function createPermissionCheckDetails(overrides: Partial<PermissionCheckHandlerH
}
}
function createHIDPermissionDetails(overrides: Partial<DevicePermissionHandlerHandlerDetails> = {}): DevicePermissionHandlerHandlerDetails {
const device: HIDDevice = {
collections: [{
children: [],
featureReports: [],
inputReports: [],
outputReports: [],
type: 1,
usage: 0x05,
usagePage: 0x01,
}],
deviceId: 'dualsense-1',
name: 'DualSense Wireless Controller',
productId: 0x0CE6,
vendorId: 0x054C,
}
return {
device,
deviceType: 'hid',
origin: 'file://',
...overrides,
}
}
/**
* @example
* shouldGrantElectronPermission(localWebContents, 'media', origin, details)
@@ -302,4 +327,37 @@ describe('media permissions', () => {
createPermissionCheckDetails(),
)).toBe(false)
})
it('grants local AIRI pages access to HID devices through the device permission handler', () => {
const targetSession = {
setDevicePermissionHandler: vi.fn<Session['setDevicePermissionHandler']>(),
setPermissionCheckHandler: vi.fn<Session['setPermissionCheckHandler']>(),
setPermissionRequestHandler: vi.fn<Session['setPermissionRequestHandler']>(),
}
setupPermissionHandlers(targetSession, () => false)
expect(targetSession.setDevicePermissionHandler).toHaveBeenCalledOnce()
const handler = targetSession.setDevicePermissionHandler.mock.calls[0]?.[0]
expect(handler).not.toBeNull()
expect(handler?.(createHIDPermissionDetails())).toBe(true)
expect(handler?.(createHIDPermissionDetails({ origin: 'https://example.com' }))).toBe(false)
expect(handler?.(createHIDPermissionDetails({ deviceType: 'usb' }))).toBe(false)
})
it('allows HID permission checks only for local AIRI pages', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'hid',
'file:///app/index.html',
createPermissionCheckDetails(),
)).toBe(true)
expect(shouldGrantElectronPermission(
localWebContents,
'hid',
'https://example.com',
createPermissionCheckDetails(),
)).toBe(false)
})
})
@@ -1,4 +1,4 @@
import type { Session, WebContents } from 'electron'
import type { DevicePermissionHandlerHandlerDetails, HIDDevice, Session, WebContents } from 'electron'
import { isLocalAppURL } from '../../libs/electron/url'
@@ -11,6 +11,14 @@ type LocalAppWebContents = Pick<WebContents, 'getURL'>
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
'display-capture',
'clipboard-sanitized-write',
'hid',
])
const GENERIC_DESKTOP_USAGE_PAGE = 0x01
const GAME_CONTROLLER_USAGES = new Set([
0x04, // Joystick
0x05, // Game Pad
0x08, // Multi-axis Controller
])
/**
@@ -71,6 +79,36 @@ function shouldGrantLocalAppPermission(
return isLocalAppURL(webContents?.getURL())
}
function isGameController(device: HIDDevice): boolean {
return device.collections.some(collection =>
collection.usagePage === GENERIC_DESKTOP_USAGE_PAGE
&& GAME_CONTROLLER_USAGES.has(collection.usage),
)
}
/**
* Grants device access only to game controllers requested by an AIRI-owned page.
*
* Triggering workflow:
*
* {@link Navigator.hid}
* -> {@link Session.setDevicePermissionHandler}
* -> `hid`
* -> {@link shouldGrantDevicePermission}
*
* Upstream:
* - {@link setupPermissionHandlers}
*
* Downstream:
* - {@link isLocalAppURL}
*/
function shouldGrantDevicePermission(details: DevicePermissionHandlerHandlerDetails): boolean {
if (details.deviceType !== 'hid' || !('collections' in details.device) || !isLocalAppURL(details.origin))
return false
return isGameController(details.device)
}
/**
* Decides whether an Electron media operation is an AIRI-owned audio-only request.
*
@@ -151,10 +189,10 @@ export function shouldGrantElectronPermission(
* - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source
*
* Returns:
* - Nothing; both handlers are installed on the supplied session
* - Nothing; permission request, permission check, and device permission handlers are installed
*/
export function setupMediaPermissionHandlers(
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
export function setupPermissionHandlers(
targetSession: Pick<Session, 'setDevicePermissionHandler' | 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
isDesktopCaptureAuthorized: () => boolean,
): void {
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
@@ -164,4 +202,6 @@ export function setupMediaPermissionHandlers(
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized)
})
targetSession.setDevicePermissionHandler(shouldGrantDevicePermission)
}
@@ -7,6 +7,7 @@ import { BrowserWindow } from 'electron'
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
import { protectPrivilegedWindowNavigation } from '../shared/window'
/** Creates the hidden renderer that owns the Beat Sync capture and detector. */
export async function setupBeatSync() {
const window = new BrowserWindow({
show: false,
@@ -19,7 +20,6 @@ export async function setupBeatSync() {
protectPrivilegedWindowNavigation(window)
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'), 'beat-sync.html'))
initScreenCaptureForWindow(window)
return window
@@ -1,5 +1,6 @@
import { join, resolve } from 'node:path'
import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main'
import { BrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
@@ -49,6 +50,7 @@ export function setupDevtoolsWindow(): DevtoolsWindowManager {
reusableWindows.delete(key)
})
protectPrivilegedWindowNavigation(window)
initScreenCaptureForWindow(window)
await load(window, withHashRoute(rendererBase, route, {
query: { 'synced-leader': 'false' },
@@ -12,39 +12,19 @@ import {
} from '@proj-airi/stage-shared/beat-sync'
const context = createContext()
const changeState = defineInvoke(context, beatSyncStateChangedInvokeEventa)
const signalState = defineInvoke(context, beatSyncStateChangedInvokeEventa)
const signalBeat = defineInvoke(context, beatSyncBeatSignaledInvokeEventa)
const detector = createBeatSyncDetector({ env: StageEnvironment.Tamagotchi })
const detector = createBeatSyncDetector({
env: StageEnvironment.Tamagotchi,
})
detector.on('stateChange', state => changeState(state))
detector.on('beat', (e) => {
// eslint-disable-next-line no-console
console.debug('[beat]', e) // This could be noisy.
signalBeat(e)
})
detector.on('stateChange', state => void signalState(state))
detector.on('beat', event => void signalBeat(event))
defineInvokeHandler(context, beatSyncToggleInvokeEventa, async (enabled) => {
// eslint-disable-next-line no-console
console.log('[toggle]', enabled)
if (enabled) {
detector.startScreenCapture()
}
else {
if (enabled)
await detector.startScreenCapture()
else
detector.stop()
}
})
defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => detector.state)
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async (params) => {
// eslint-disable-next-line no-console
console.log('[update-params]', params)
detector.updateParameters(params)
})
defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => {
// eslint-disable-next-line no-console
console.debug('[get-input-byte-frequency-data]') // This could be noisy.
return detector.getInputByteFrequencyData()
})
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async params => detector.updateParameters(params))
defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => detector.getInputByteFrequencyData())
@@ -0,0 +1,325 @@
import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture'
import type { Live2DLipSync } from '@proj-airi/model-driver-lipsync'
import type { Profile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import type {
SystemAudioLipSyncCallbacks,
SystemAudioLipSyncDriver,
SystemAudioLipSyncOptions,
SystemAudioLipSyncOutput,
} from '@proj-airi/stage-ui/stores/system-audio-lipsync'
import { setupElectronScreenCapture } from '@proj-airi/electron-screen-capture/renderer'
import { getElectronEventaContext } from '@proj-airi/electron-vueuse'
import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import { clamp } from 'es-toolkit'
const inputAnalyserFFTSize = 1024
/** Electron adapter that connects renderer-local system audio to Live2D lipsync. */
export class Live2DSystemAudioLipSyncDriver implements SystemAudioLipSyncDriver {
private readonly screenCapture = setupElectronScreenCapture(getElectronEventaContext())
private generation = 0
private stream: MediaStream | undefined
private pendingStart: Promise<void> | undefined
private processor: Live2DLipSyncProcessor | undefined
private callbacks: SystemAudioLipSyncCallbacks | undefined
async start(options: SystemAudioLipSyncOptions, callbacks: SystemAudioLipSyncCallbacks): Promise<void> {
if (this.stream) {
this.callbacks = callbacks
this.updateOptions(options)
return
}
if (this.pendingStart) {
await this.pendingStart
if (!this.stream)
await this.start(options, callbacks)
return
}
this.callbacks = callbacks
const generation = ++this.generation
const processor = new Live2DLipSyncProcessor(output => this.callbacks?.onOutput(output))
this.processor = processor
processor.updateOptions(options)
this.pendingStart = this.screenCapture.selectWithSource(
(sources: SerializableDesktopCapturerSource[]) => {
if (sources.length === 0)
throw new Error('No screen source available')
return sources[0].id
},
() => navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }),
{ sourcesOptions: { types: ['screen'] } },
)
.then(async (stream) => {
stream.getVideoTracks().forEach((track) => {
track.stop()
stream.removeTrack(track)
})
if (stream.getAudioTracks().length === 0) {
stream.getTracks().forEach(track => track.stop())
throw new Error('No audio track available in the system audio stream')
}
if (generation !== this.generation) {
stream.getTracks().forEach(track => track.stop())
return
}
try {
await processor.start(stream)
if (generation !== this.generation) {
processor.stop()
stream.getTracks().forEach(track => track.stop())
return
}
this.stream = stream
stream.getAudioTracks().forEach((track) => {
track.addEventListener('ended', () => {
if (this.stream !== stream)
return
if (stream.getAudioTracks().every(audioTrack => audioTrack.readyState === 'ended'))
this.handleInputEnded()
}, { once: true })
})
}
catch (error) {
processor.stop()
if (this.processor === processor) {
this.processor = undefined
this.callbacks = undefined
}
stream.getTracks().forEach(track => track.stop())
throw error
}
})
.catch((error) => {
if (this.processor === processor) {
processor.stop()
this.processor = undefined
this.callbacks = undefined
}
throw error
})
.finally(() => {
this.pendingStart = undefined
})
return this.pendingStart
}
updateOptions(options: SystemAudioLipSyncOptions): void {
this.processor?.updateOptions(options)
}
stop(): void {
this.generation++
const stream = this.stream
this.stream = undefined
this.processor?.stop()
this.processor = undefined
this.callbacks = undefined
stream?.getTracks().forEach(track => track.stop())
}
dispose(): void {
this.stop()
}
private handleInputEnded(): void {
this.generation++
this.stream = undefined
this.processor?.stop()
this.processor = undefined
this.callbacks?.onEnded()
this.callbacks = undefined
}
}
/** Converts one system-audio stream into serializable Live2D mouth movement. */
class Live2DLipSyncProcessor {
private context: AudioContext | undefined
private source: MediaStreamAudioSourceNode | undefined
private analyser: AnalyserNode | undefined
private frequencies: Uint8Array<ArrayBuffer> | undefined
private lipSync: Live2DLipSync | undefined
private outputFrameId = 0
private mouthGateOpen = false
private outputMouthOpen = 0
private lastMouthOutputMs = 0
private highMouthStartedMs = 0
private highMouthDurationMs = 0
private forcedMouthCloseUntilMs = 0
private options: SystemAudioLipSyncOptions = {
inputVolumeThreshold: 0.08,
randomCloseDelayMs: 300,
randomCloseProbability: 1,
}
constructor(private readonly emitOutput: (output: SystemAudioLipSyncOutput) => void) {}
async start(stream: MediaStream): Promise<void> {
this.stop()
try {
this.context = new AudioContext()
this.source = this.context.createMediaStreamSource(stream)
this.analyser = this.context.createAnalyser()
this.analyser.fftSize = inputAnalyserFFTSize
this.analyser.smoothingTimeConstant = 0.8
this.frequencies = new Uint8Array(this.analyser.frequencyBinCount)
this.source.connect(this.analyser)
this.lipSync = await createLive2DLipSync(
this.context,
wlipsyncProfile as Profile,
{
cap: 1,
volumeScale: 1.1,
volumeExponent: 0.6,
mouthUpdateIntervalMs: 20,
mouthLerpWindowMs: 0,
},
)
this.lipSync.connectSource(this.source)
this.updateOutput()
}
catch (error) {
this.stop()
throw error
}
}
stop(): void {
cancelAnimationFrame(this.outputFrameId)
this.outputFrameId = 0
this.lipSync?.node.disconnect()
this.lipSync = undefined
this.analyser?.disconnect()
this.analyser = undefined
this.frequencies = undefined
this.source?.disconnect()
this.source = undefined
void this.context?.close()
this.context = undefined
this.resetMouthOutput()
this.emitOutput({ inputLevel: 0, mouthOpen: 0 })
}
updateOptions(options: SystemAudioLipSyncOptions): void {
this.options = {
inputVolumeThreshold: clamp(options.inputVolumeThreshold, 0, 1),
randomCloseDelayMs: clamp(options.randomCloseDelayMs, 100, 1000),
randomCloseProbability: clamp(options.randomCloseProbability, 0, 1),
}
this.highMouthStartedMs = 0
this.highMouthDurationMs = 0
}
private updateOutput = (): void => {
if (!this.analyser || !this.frequencies)
return
this.analyser.getByteFrequencyData(this.frequencies)
const inputLevel = this.frequencies.length
? this.frequencies.reduce((peak, value) => Math.max(peak, value), 0) / 255
: 0
const timestamp = performance.now()
const rawMouthOpen = inputLevel >= this.options.inputVolumeThreshold
? this.lipSync?.getMouthOpen() ?? 0
: 0
const mouthOpen = this.smoothMouthClose(
this.applySustainedMouthClosure(
this.shapeMouthOpen(rawMouthOpen),
timestamp,
),
timestamp,
)
this.emitOutput({ inputLevel, mouthOpen })
this.outputFrameId = requestAnimationFrame(this.updateOutput)
}
private shapeMouthOpen(rawMouthOpen: number): number {
if (this.mouthGateOpen) {
if (rawMouthOpen <= 0.035)
this.mouthGateOpen = false
}
else if (rawMouthOpen >= 0.08) {
this.mouthGateOpen = true
}
if (!this.mouthGateOpen)
return 0
const normalized = clamp((rawMouthOpen - 0.035) / (1 - 0.035), 0, 1)
const emphasized = clamp(normalized ** 0.72 * 1.65, 0, 1)
return (Math.sign(emphasized * 2 - 1) * Math.abs(emphasized * 2 - 1) ** 0.85 + 1) / 2
}
// NOTICE:
// This deliberate short closure breaks up unnaturally sustained system-audio mouth openings.
// The current analyzer can hold a high value across several spoken words without a visible consonant closure.
// This workaround is local to the Live2D lipsync processor and does not change phoneme detection.
// Remove it when the lipsync analyzer provides reliable short-term mouth-closure timing.
private applySustainedMouthClosure(mouthOpen: number, timestamp: number): number {
if (timestamp < this.forcedMouthCloseUntilMs)
return 0
if (mouthOpen < 0.72) {
this.highMouthStartedMs = 0
this.highMouthDurationMs = 0
return mouthOpen
}
if (this.highMouthStartedMs === 0) {
this.highMouthStartedMs = timestamp
this.highMouthDurationMs = this.randomDuration(this.options.randomCloseDelayMs / 2, this.options.randomCloseDelayMs)
return mouthOpen
}
if (timestamp - this.highMouthStartedMs < this.highMouthDurationMs)
return mouthOpen
if (Math.random() > this.options.randomCloseProbability) {
this.highMouthStartedMs = timestamp
this.highMouthDurationMs = this.randomDuration(this.options.randomCloseDelayMs / 2, this.options.randomCloseDelayMs)
return mouthOpen
}
this.forcedMouthCloseUntilMs = timestamp + this.randomDuration(40, 100)
this.highMouthStartedMs = 0
this.highMouthDurationMs = 0
return 0
}
private smoothMouthClose(target: number, timestamp: number): number {
if (this.lastMouthOutputMs === 0 || target >= this.outputMouthOpen) {
this.outputMouthOpen = target
this.lastMouthOutputMs = timestamp
return this.outputMouthOpen
}
const alpha = 1 - Math.exp(-(timestamp - this.lastMouthOutputMs) / 18)
this.outputMouthOpen += (target - this.outputMouthOpen) * alpha
this.lastMouthOutputMs = timestamp
if (this.outputMouthOpen < 0.01)
this.outputMouthOpen = target
return this.outputMouthOpen
}
private resetMouthOutput(): void {
this.mouthGateOpen = false
this.outputMouthOpen = 0
this.lastMouthOutputMs = 0
this.highMouthStartedMs = 0
this.highMouthDurationMs = 0
this.forcedMouthCloseUntilMs = 0
}
private randomDuration(minimumMs: number, maximumMs: number): number {
return minimumMs + Math.random() * (maximumMs - minimumMs)
}
}
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useStandardGamepad } from '@proj-airi/input-gamepad-vueuse'
import { Live2DMotionDevtools } from '@proj-airi/stage-ui/features/devtools/motion/live2d'
import { useSystemAudioLipSyncStore } from '@proj-airi/stage-ui/stores/system-audio-lipsync'
import { BasicButton } from '@proj-airi/ui'
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Live2DSystemAudioLipSyncDriver } from '../../features/live2d/system-audio-lipsync'
const { t } = useI18n()
const router = useRouter()
const { snapshot: gamepad } = useStandardGamepad()
const systemAudio = useSystemAudioLipSyncStore()
const systemAudioDriver = new Live2DSystemAudioLipSyncDriver()
onMounted(() => systemAudio.setDriver(systemAudioDriver))
onUnmounted(() => {
systemAudio.clearDriver(systemAudioDriver)
systemAudioDriver.dispose()
})
</script>
<template>
<main :class="['flex h-full min-h-0 w-full flex-col overflow-hidden', 'bg-neutral-50/80 dark:bg-neutral-950']">
<header
:class="[
'drag-region flex shrink-0 items-center gap-3 border-b border-neutral-200/70 px-4 py-3',
'bg-white/75 backdrop-blur-xl dark:border-neutral-800/70 dark:bg-neutral-950/75',
]"
>
<BasicButton
size="sm"
:title="t('tamagotchi.settings.devtools.pages.live2d-motion.actions.back')"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.actions.back')"
:class="['[-webkit-app-region:no-drag]']"
@click="router.back()"
>
<span :class="['i-mingcute:arrow-left-line size-4']" />
</BasicButton>
<div :class="['min-w-0']">
<h1 :class="['truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.title') }}
</h1>
<p :class="['truncate text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.description') }}
</p>
</div>
</header>
<Live2DMotionDevtools :gamepad="gamepad" />
</main>
</template>
<route lang="yaml">
meta:
layout: plain
titleKey: tamagotchi.settings.devtools.pages.live2d-motion.title
subtitleKey: tamagotchi.settings.devtools.title
</route>
@@ -61,6 +61,12 @@ const menu = computed(() => [
icon: 'i-solar:chart-bold-duotone',
to: '/devtools/beat-sync',
},
{
title: t('tamagotchi.settings.devtools.pages.live2d-motion.title'),
description: t('tamagotchi.settings.devtools.pages.live2d-motion.description'),
icon: 'i-mingcute:game-2-fill',
to: '/devtools/live2d-motion',
},
{
title: 'WebSocket Inspector',
description: 'Inspect raw WebSocket traffic',
@@ -0,0 +1,177 @@
# Live2D recording curve fitting
## Decision
Use a channel-aware fitting pipeline, not one generic simplification pass.
1. Preserve event boundaries and important extrema.
2. Split discontinuous and step data before curve fitting.
3. Use a vertical-error Ramer-Douglas-Peucker variant as the baseline and fallback.
4. Fit continuous regions with fixed-time cubic Bézier segments.
5. Keep the dense recording as the source until the user accepts the fitted curves.
Do not use Visvalingam-Whyatt as the primary fitter. Its area measure does not give a direct maximum value-error bound.
## Current AIRI contract
The current `airi-live2d-motion/v6` format stores ordered millisecond samples. Each sample contains 13 normalized scalar channels. Eleven channels are visible in the editor. `eyeOpen` is an editor projection of `1 - eyeSquint`. The two offset channels remain hidden. See the [recording schema](../../../../../apps/stage-tamagotchi/src/renderer/composables/live2d-motion-recording.ts) and [pose contract](../../../../../packages/stage-ui-live2d/src/stores/motion-control.ts).
The current conversion copies every sample to every track. It then rebuilds samples at the union of all track times. See [the current keyframe conversion](../../../../../apps/stage-tamagotchi/src/renderer/composables/live2d-motion-keyframes.ts). This design makes the editor dense even when one channel contains little motion.
Recorded playback currently applies each due sample and holds it until the next sample. The keyframe editor uses linear interpolation. Curve conversion must make this change of interpolation explicit.
## Algorithm comparison
| Method | Output | Error control | Strength | Main problem for this timeline |
| --- | --- | --- | --- | --- |
| Ramer-Douglas-Peucker | Piecewise linear keys | Maximum distance from each source point to a retained segment | Small, deterministic, and easy to validate | Standard Euclidean distance mixes milliseconds and parameter values |
| Visvalingam-Whyatt | Piecewise linear keys | Effective triangle area | Progressive removal gives useful levels of detail | Area is not a direct bound on playback value error |
| Schneider cubic fitting | Piecewise cubic Bézier curves | Maximum squared geometric error | Smooth curves with fewer editable segments | The original algorithm fits a parametric plane curve, not a scalar value at a fixed time |
| Smoothing B-spline | Spline knots and coefficients | Usually a weighted sum of squared residuals | Good noise smoothing and periodic fitting | A global fit can blur short events and is harder to edit locally |
### Ramer-Douglas-Peucker
Ramer approximates a region with its endpoint line. The algorithm splits at the source point with the largest distance until the fit meets a tolerance. The result has a bounded maximum distance, but it does not guarantee the minimum key count. [Ramer's original paper](https://doi.org/10.1016/S0146-664X(72)80017-0) and [Douglas and Peucker's original paper](https://doi.org/10.3138/FM57-6770-U75U-7727) describe this family.
Standard implementations treat `(time, value)` as a Euclidean point. For example, Simplify.js calculates squared `x` and `y` distance to a segment before recursive splitting. It includes TypeScript declarations. [Simplify.js source](https://github.com/mourner/simplify-js/blob/master/simplify.js) and [type declaration](https://github.com/mourner/simplify-js/blob/master/index.d.ts) show this behavior.
That metric is unsuitable without scaling. A duration of 20,000 ms dominates a value range of 2. Scaling time changes which details survive. A screen-space scale also makes results depend on editor size.
For AIRI, use time only to evaluate the candidate line. Measure the vertical residual at each original sample:
```text
u = (sampleTime - leftTime) / (rightTime - leftTime)
predictedValue = leftValue + u * (rightValue - leftValue)
error = abs(sampleValue - predictedValue)
```
This variant gives a maximum error in the channel's normalized value units. It also keeps each retained key at its original time.
### Visvalingam-Whyatt
Visvalingam and Whyatt repeatedly remove the point with the smallest effective triangle area. Their paper presents the method as progressive line simplification. [The authors' institutional record and paper](https://hull-repository.worktribe.com/output/376330/line-generalisation-by-repeated-elimination-of-points) describe the effective-area rule.
The method can produce visually balanced polylines. However, its threshold has `time × value` units for this data. It does not directly limit the value error at source times. Mandatory extrema and discontinuity anchors would also require a custom implementation.
Visvalingam-Whyatt is useful as an optional visual comparison. It is not the best acceptance rule for motion parameters.
### Schneider cubic Bézier fitting
Schneider fits a cubic Bézier to digitized points with endpoint tangents and least squares. The algorithm uses chord-length parameters, improves them with Newton-Raphson iterations, and splits at the largest squared error. [The original Graphics Gems chapter](https://lhf.impa.br/cursos/tmg/Schneider-1990.pdf) and [original C source](https://github.com/erich666/GraphicsGems/blob/master/gems/FitCurves.c) define the algorithm.
Schneider also recommends preprocessing coincident points and splitting at corners or discontinuities. Each resulting subcurve is fitted independently. This rule applies directly to sharp blinks, button-like channels, and capture gaps.
The original algorithm treats time and value as two geometric coordinates. Therefore, time/value scaling changes chord lengths, tangents, and fitting error. A fitted control point can also move backward in time.
Cubism already defines linear, cubic Bézier, stepped, and inverse-stepped motion segments. Its restricted cubic segments place control times at one-third and two-thirds of the segment duration. [The official `motion3.json` specification](https://github.com/Live2D/CubismSpecs/blob/master/FileFormats/motion3.json.md) defines these segments.
For AIRI, adapt Schneider to a scalar time function:
- Fix each segment's endpoint times.
- Fix the two control times at one-third and two-thirds of the duration.
- Use actual normalized time as the Bézier parameter.
- Solve only the two control values with least squares.
- Measure maximum vertical error at every source time.
- Split at the largest error and fit each side again.
This adaptation removes temporal scaling from the fit. It also produces curves that map directly to Cubism's restricted Bézier representation. This is an AIRI design recommendation, not a claim from Schneider's paper.
A TypeScript port of Schneider's general algorithm exists in [`odiak/fit-curve`](https://github.com/odiak/fit-curve/blob/master/packages/fit-curve/src/index.ts). It is useful as a reference or experiment. It still uses parametric two-dimensional error, so it does not provide AIRI's required value-at-time guarantee without adaptation.
### Smoothing splines
Smoothing splines are useful when sensor noise is the main problem. SciPy's official `splprep` interface uses weighted least squares and a smoothing condition based on summed squared residuals. It also supports periodic fitting. [The SciPy documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.splprep.html) describes these controls and recommends its newer replacement for new code.
This objective can hide one bad blink or mouth peak inside a low total error. A global spline can also change distant regions after one edit. These properties make smoothing splines a poor first representation for the timeline.
## Channel policy
### Continuous bounded channels
The head, body, eye direction, mouth shape, model offsets, eye squint, and mouth opening are continuous scalars. Fit each track independently. Validate the fitted result against that track's legal range.
Do not rely only on runtime clamping. A cubic overshoot can create a flat clipped region. Sample every fitted segment densely and reject or split a segment that leaves its legal range.
### Extrema
Ramer notes that a large tolerance can remove important features. Cubic fitting can also round a short peak. Preserve these points before fitting:
- the first and last sample;
- accepted local maxima and minima;
- both ends of each discontinuity;
- user-authored keys;
- loop boundaries.
Raw sign changes can identify noise as extrema. Use a prominence or hysteresis threshold tied to the channel error tolerance. For `eyeSquint`, preserve the most closed point of each blink. For `mouthOpen`, preserve meaningful opening peaks.
### Discontinuities and capture gaps
Do not fit across duplicate timestamps, long capture gaps, or changes classified as discontinuous. Split the track first. Preserve both sides of the split.
Represent a true jump with a stepped or inverse-stepped segment. Cubism uses both segment types, and the Cubism editor exposes linear, stepped, inverse-step, and Bézier curves. See the [Cubism Graph Editor manual](https://docs.live2d.com/en/cubism-editor-manual/grapheditor/).
### Cyclic channels
The current v6 channels are bounded values, not cyclic angles. If a future track wraps, unwrap it before fitting. Then enforce matching loop endpoints and derivatives before wrapping the evaluated result. A periodic spline can provide an offline comparison, but it is not necessary for the first implementation.
### Error report
Use maximum absolute value error as the acceptance rule. Also report root-mean-square error and the retained-key ratio for diagnostics. Calculate all errors at the original sample times.
Do not use root-mean-square error alone. One short facial event can have a large local error and a small total contribution.
## Practical TypeScript options
| Option | Use |
| --- | --- |
| Small local vertical-RDP function | Recommended baseline. It matches AIRI's scalar error rule and needs no dependency. |
| `simplify-js` | Good experiment for standard geometric RDP. Use `highQuality: true` to skip its radial pre-pass. It still needs coordinate scaling. |
| Adapted local cubic fitter | Recommended production target. It can share segmentation, anchors, and validation with vertical RDP. |
| `odiak/fit-curve` | Good Schneider reference and prototype. Do not adopt it unchanged for scalar timeline curves. |
| [Mapshaper](https://github.com/mbloch/mapshaper/blob/master/docs/guides/simplification.md) | It contains Douglas-Peucker and Visvalingam variants, but its cartographic scope is too large for this devtool. |
No matching simplification or fitting package is present in the current workspace lockfile. If implementation uses a new package, select it with the user before adding it.
## Staged implementation plan
### Stage 1: establish a safe baseline
1. Add fixtures for a head turn, body sway, blink, mouth pulse, pause, discontinuity, and loop boundary.
2. Add track metadata for range, tolerance, interpolation policy, and cyclic behavior.
3. Detect segment boundaries and mandatory anchors.
4. Implement vertical-error RDP between mandatory anchors.
5. Compare source and reconstructed values at every original timestamp.
6. Show source samples as a faint line and simplified keys as editable points.
Start with a maximum normalized error of `0.02`. Tune each track from visual fixtures. Use a smaller error for channels where the model amplifies small parameter changes.
### Stage 2: fit editable cubic curves
1. Add linear, cubic, and step segment types to the editor model.
2. Implement the fixed-time cubic fit described above.
3. Split failed fits at the sample with the largest vertical error.
4. Keep RDP output when cubic fitting gives no useful key reduction.
5. Add value-range and time-order checks for every segment.
6. Add draggable Bézier handles without changing anchor times by default.
### Stage 3: integrate recording safely
1. Keep the original dense v6 recording while the derived fit remains unaccepted.
2. Fit each channel separately after recording or import.
3. Play the derived curves through the existing spring target.
4. Refit only the changed source range after a local edit.
5. Export a curve-native format after its interpolation semantics are stable.
6. Bake curves to v6 samples only when compatibility requires dense output.
### Acceptance criteria
- No fitted sample exceeds its per-track maximum error.
- Mandatory extrema keep their source time and value.
- Discontinuities never receive a linear or cubic bridge.
- Bounded channels do not overshoot their legal range.
- A loop has no unintended value or velocity seam.
- The editor shows tens of keys for a typical 20-second track, not hundreds.
## Recommendation
Implement Stage 1 first, then use the fixed-time cubic fitter as the normal continuous-track representation. Keep vertical RDP as the test oracle and fallback. Preserve blinks, mouth peaks, discontinuities, and loop boundaries as mandatory anchors. This approach gives a measurable value-error bound and produces curves that match Live2D's native segment model.
+11
View File
@@ -161,6 +161,17 @@
status: preferred
context: Live2D Settings
- id: magic-motion
subject: rendering
translatable: false
definition: A motion generator that fits VAR or AR-HMM models to a reference dataset.
note: Keep the name uppercase in every language.
terms:
- text: MAGIC
part-of-speech: proper noun
status: preferred
context: MAGIC motion driver
- id: vrm
subject: rendering
translatable: false
@@ -162,6 +162,33 @@ live2d:
title: Extract
animation:
title: Animation
motion-driver:
title: Motion driver
description: Select how AIRI creates idle body and eye motion.
options:
universal:
title: Universal
description: Use model motions, idle eye movement, and mouse tracking.
magic:
title: MAGIC
description: Generate motion from the bundled MAGIC dataset.
magic:
profile:
title: MAGIC profile
description: Select the bundled motion dataset that fits the MAGIC driver.
options:
idle-calm:
title: Idle / calm
description: Use the bundled idle and calm motion recording.
speaking-excited:
title: Speaking / excited
description: Use the bundled speaking and excited motion recording.
skip-mouth-open:
title: Skip generated mouth opening
description: Keep speech lip sync in control. Disable this option to use mouth opening from the profile.
force-view-target:
title: Force forward view target
description: Keep the eyes aimed forward while MAGIC moves the head.
focus:
title: Enable mouse tracking
description: Look at the cursor while it is moving. Idle eye movement resumes after the cursor stops.
@@ -55,6 +55,257 @@ devtools:
button: Open Editor
lag-visualizer:
title: Lag Visualizer
live2d-motion:
title: Live2D Motion Control
description: Control Live2D eye, head, and body motion.
actions:
back: Back
system-audio:
title: System audio lip sync
description: Capture audio from other players. The same input drives Beat Sync and Live2D mouth movement.
signal: Input signal
mouth-open: Mouth open
input-volume-threshold: Input volume threshold
random-close-delay: Random close delay
random-close-probability: Random close probability
actions:
start: Start input
stop: Stop input
panels:
direct-control: Direct control
preview: Model preview
timeline: Timeline
inference: Model inference
preview:
groups:
expression: Expression
offset: Position offset
values:
squint: Squint
mouth-form: Form
mouth-open: Open
joystick-label: Live2D motion joystick
wave:
toggle: Boat mode
instructions: Drag the joystick, or use the arrow keys for X/Y. R adds a slight squint. F adds a stronger squint. C closes the eyes. W/S changes the mouth shape. Space opens the mouth. A/D moves the body on X. Q/E tilts the head. Released keys return smoothly to center.
parameter-note: The joystick controls X/Y. R maps to 1/3 squint. F maps to 2/3 squint. C maps to fully closed eyes. W/S maps to mouth form ±1. Space maps to mouth open 1. A/D maps to body X ±10°. Q/E maps to head Z ±30°.
input:
keyboard: Keyboard
controller: Controller
input-actions:
move-body-head: Move body and head
tilt-head: Tilt head
blink: Blink
open-mouth: Open mouth
mouth-shape: Change mouth shape
eye-view:
title: Latched eye view
description: Choose a target. The eyes counter head movement to keep looking at it.
pad-label: Latched eye view target
actions:
enable: Enable fixation
disable: Disable fixation
center: Center target
counter:
label: Head counter strength
description: How strongly the eyes offset head X/Y movement.
values:
target: Target
output: Eye output
editor:
title: Motion editor
instructions: The gray curve is the source. Add motion overlays or view-target keyframes. Select a track, then drag its points.
play: Play timeline
pause: Pause timeline
undo: Undo
redo: Redo
crop-to-view: Crop to view
reset-view: Reset view
export-project: Export project
import-project: Import project
import-error: This file is not a valid AIRI Live2D motion project.
playback:
label: Timeline playback controls
start: Go to start
step-backward: Step backward
step-forward: Step forward
end: Go to end
timecode: Current and total timecode
input-actions:
play: Play
stop: Stop
start: Go to start
end: Go to end
step-backward: Step backward
step-forward: Step forward
restart-recording: Record again
clear-timeline: Clear timeline
previous-track: Select previous track
next-track: Select next track
timeline-label: Motion timeline
timeline-help: Drag the ruler to scrub. Use the wheel to zoom. Hold Shift and drag to pan. Double-click a selected curve to add a point. Each view-target key holds until the next key.
overlay-count: '{count} overlays'
controls:
add: Add
replace: Replace
create-keyframes: Create keyframes
weight: Weight
remove: Remove
tracks:
viewTargetX: View target X
viewTargetY: View target Y
eyeX: Eye X
eyeY: Eye Y
eyeOpen: Eye open
headX: Head X
headY: Head Y
headZ: Head Z
bodyX: Body X
bodyY: Body Y
bodyZ: Body Z
mouthForm: Mouth shape
mouthOpen: Mouth open
var:
title: VAR idle-motion generator
description: Fits recorded motion, then generates a new idle sequence.
actions:
fit: Fit current motion
refit: Refit current motion
generate: Generate motion
stop: Stop generation
new-seed: New seed
order:
label: VAR order
description: Past frames used for each prediction.
residual:
label: Residual strength
description: Generated variation. Zero is deterministic.
diagnostics:
source: Source
model: Model
fit: Fit
run: Generated run
status:
no-source: Import or record a motion first
source-ready: '{count} samples · {duration}s'
fit-failed: The VAR fit failed.
values:
frames: '{count} frames'
model: '{channels} channels · {features} terms'
fit: '{frames} frames · RMSE {error} · {duration}ms'
run: 'seed {seed} · {duration}'
ar-hmm:
title: AR-HMM idle-motion generator
description: Learns motion states and transitions for varied idle motion.
actions:
fit: Fit motion
refit: Refit
generate: Generate
stop: Stop
new-seed: New seed
states:
label: Hidden states
description: Motion patterns the model can learn.
order:
label: AR order
description: Past frames used within each state.
residual:
label: Innovation strength
description: Variation within each state.
diagnostics:
source: Source
model: Model
fit: Fit
states: State occupancy
run: Generated run
status:
no-source: Import or record a motion first
source-ready: '{count} samples · {duration}s'
fit-failed: The AR-HMM fit failed.
values:
count: '{count} states'
frames: '{count} frames'
model: '{states} states · {channels} channels · {features} terms'
fit: 'log p/frame {likelihood} · {duration}ms'
states: '{occupancy} · dwell {dwell}s'
run: 'state {state} · seed {seed} · {duration}'
output-filter:
title: Generator output filter
description: Smooths generator output and holds small changes before the spring receives each pose.
actions:
enable: Enable filter
disable: Disable filter
reset: Reset state
smoothing:
label: EMA smoothing
description: Blends each accepted target with the previous output. Higher values remove more jitter but add more delay.
cutoff:
label: Change cutoff
description: Holds a channel until its cumulative normalized change reaches this value.
diagnostics:
status: Status
input-change: Raw change MAE
output-change: Filtered change MAE
cutoff-tracks: Held channels
status:
waiting: Waiting for a generator
filtering: Filtering generator output
bypassed: Passing raw generator output
breath:
title: Manual breath
description: Controls ParamBreath with adjustable inhale, exhale, and dwell timing, without adding breath data to recordings.
actions:
enable: Enable breath
disable: Disable breath
reset: Reset cycle
cycle:
label: Breath duration
description: Sets the time for one inhale and exhale, before the exhaled dwell.
inhale:
label: Inhale share
description: Sets the part of the breath duration used to inhale. The remaining time is the exhale.
dwell:
label: Exhaled dwell
description: Holds ParamBreath at the minimum before the next inhale.
minimum:
label: Minimum
description: Sets the lowest value written to ParamBreath.
maximum:
label: Maximum
description: Sets the highest value written to ParamBreath.
diagnostics:
stage: Stage
phase: Cycle progress
output: ParamBreath output
stage:
inhale: Inhale
exhale: Exhale
dwell: Exhaled dwell
spring:
follow:
label: Follow
description: How quickly motion follows the joystick.
inertia:
label: Inertia
description: How much momentum the motion keeps.
groups:
eyes: Eyes
head: Head
body: Body
recording:
actions:
record: Record
stop-recording: Stop recording
play: Play
stop-playback: Stop playback
export: Export
import: Import
status:
empty: No motion recording
recording: Recording motion
playing: Playing motion
ready: '{count} samples · {duration}'
import-error: This file is not a valid AIRI Live2D motion recording.
performance-visualizer:
title: Performance Visualizer
description: Inspect live performance metrics and runtime diagnostics
@@ -147,6 +147,33 @@ live2d:
title: 提取
animation:
title: 动画
motion-driver:
title: 动作驱动器
description: 选择 AIRI 如何生成空闲身体和眼部动作。
options:
universal:
title: 通用
description: 使用模型动作、空闲眼部动作和鼠标跟踪。
magic:
title: MAGIC
description: 使用内置 MAGIC 数据集生成动作。
magic:
profile:
title: MAGIC 配置
description: 选择用于拟合 MAGIC 驱动器的内置动作数据集。
options:
idle-calm:
title: 空闲 / 平静
description: 使用内置的空闲和平静动作录制数据。
speaking-excited:
title: 说话 / 兴奋
description: 使用内置的说话和兴奋动作录制数据。
skip-mouth-open:
title: 忽略生成的嘴部开合
description: 让语音口型控制嘴部开合。关闭此选项后将使用配置中的嘴部开合。
force-view-target:
title: 强制视线朝前
description: MAGIC 转动头部时,让眼睛持续看向正前方。
focus:
title: 启用鼠标跟踪
description: 鼠标移动时注视光标;光标停止后恢复空闲眼部动作。
@@ -51,6 +51,244 @@ devtools:
title: 上下文流程
lag-visualizer:
title: 卡頓可視化
live2d-motion:
title: Live2D 動作控制
description: 控制 Live2D 的眼睛、頭部與身體動作。
actions:
back: 返回
panels:
direct-control: 直接控制
preview: 模型預覽
timeline: 時間軸
inference: 模型推理
preview:
groups:
expression: 表情
offset: 位置偏移
values:
squint: 瞇眼
mouth-form: 嘴型
mouth-open: 張開
joystick-label: Live2D 動作搖桿
instructions: 拖曳搖桿,或使用方向鍵控制 X/Y 軸。R 讓眼睛微瞇。F 讓眼睛更瞇。C 閉上眼睛。W/S 改變嘴型。空白鍵張開嘴巴。A/D 控制身體 X 軸。Q/E 讓頭部左右傾斜。放開按鍵後會平滑回到中央。
parameter-note: 搖桿控制 X/Y 軸。R 映射至 1/3 瞇眼。F 映射至 2/3 瞇眼。C 映射至完全閉眼。W/S 映射至嘴型 ±1。空白鍵映射至嘴巴張開 1。A/D 映射至身體 X 軸 ±10°。Q/E 映射至頭部 Z 軸 ±30°。
input:
keyboard: 鍵盤
controller: 控制器
input-actions:
move-body-head: 移動身體與頭部
tilt-head: 傾斜頭部
blink: 眨眼
open-mouth: 張開嘴巴
mouth-shape: 改變嘴型
eye-view:
title: 固定視線
description: 選擇目標後,眼睛會抵銷頭部動作並持續注視目標。
pad-label: 固定視線目標
actions:
enable: 啟用注視
disable: 停用注視
center: 目標置中
counter:
label: 頭部抵銷強度
description: 眼睛抵銷頭部 X/Y 軸動作的強度。
values:
target: 目標
output: 眼睛輸出
editor:
title: 動作編輯器
instructions: 灰色曲線是原始動作。新增動作疊加層或視線目標關鍵影格。選取軌道後,拖曳其控制點。
play: 播放時間軸
pause: 暫停時間軸
undo: 復原
redo: 重做
crop-to-view: 裁切至檢視範圍
reset-view: 重設檢視
export-project: 匯出專案
import-project: 匯入專案
import-error: 此檔案不是有效的 AIRI Live2D 動作專案。
playback:
label: 時間軸播放控制
start: 跳至開頭
step-backward: 後退一格
step-forward: 前進一格
end: 跳至結尾
timecode: 目前與總時間碼
input-actions:
play: 播放
stop: 停止
start: 跳至開頭
end: 跳至結尾
step-backward: 後退一格
step-forward: 前進一格
restart-recording: 重新錄製
clear-timeline: 清空時間軸
previous-track: 選取上一條軌道
next-track: 選取下一條軌道
timeline-label: 動作時間軸
timeline-help: 拖曳尺規可搜尋時間。使用滾輪縮放。按住 Shift 並拖曳可平移。在所選曲線按兩下可新增控制點。每個視線目標關鍵影格會持續至下一個關鍵影格。
overlay-count: '{count} 個疊加層'
controls:
add: 相加
replace: 取代
create-keyframes: 建立關鍵影格
weight: 權重
remove: 移除
tracks:
viewTargetX: 視線目標 X
viewTargetY: 視線目標 Y
eyeX: 眼睛 X
eyeY: 眼睛 Y
eyeOpen: 眼睛張開
headX: 頭部 X
headY: 頭部 Y
headZ: 頭部 Z
bodyX: 身體 X
bodyY: 身體 Y
bodyZ: 身體 Z
mouthForm: 嘴型
mouthOpen: 嘴巴張開
var:
title: VAR 待機動作產生器
description: 擬合已錄製動作,再產生新的待機動作序列。
actions:
fit: 擬合目前動作
refit: 重新擬合目前動作
generate: 產生動作
stop: 停止產生
new-seed: 新種子
order:
label: VAR 階數
description: 每次預測使用的先前影格數。
residual:
label: 殘差強度
description: 產生的變化量;零代表確定性預測。
diagnostics:
source: 來源
model: 模型
fit: 擬合
run: 已產生動作
status:
no-source: 請先匯入或錄製動作
source-ready: '{count} 個取樣 · {duration}s'
fit-failed: VAR 擬合失敗。
values:
frames: '{count} 個影格'
model: '{channels} 個通道 · {features} 個項目'
fit: '{frames} 個影格 · RMSE {error} · {duration}ms'
run: '種子 {seed} · {duration}'
ar-hmm:
title: AR-HMM 待機動作產生器
description: 學習動作狀態與轉換,以產生更多變化的待機動作。
actions:
fit: 擬合動作
refit: 重新擬合
generate: 產生
stop: 停止
new-seed: 新種子
states:
label: 隱藏狀態
description: 模型可學習的動作模式數量。
order:
label: AR 階數
description: 每個狀態使用的先前影格數。
residual:
label: 創新強度
description: 每個狀態內的變化量。
diagnostics:
source: 來源
model: 模型
fit: 擬合
states: 狀態占比
run: 已產生動作
status:
no-source: 請先匯入或錄製動作
source-ready: '{count} 個取樣 · {duration}s'
fit-failed: AR-HMM 擬合失敗。
values:
count: '{count} 個狀態'
frames: '{count} 個影格'
model: '{states} 個狀態 · {channels} 個通道 · {features} 個項目'
fit: 'log p/影格 {likelihood} · {duration}ms'
states: '{occupancy} · 停留 {dwell}s'
run: '狀態 {state} · 種子 {seed} · {duration}'
output-filter:
title: 產生器輸出濾波器
description: 在彈簧接收每個姿勢前,平滑產生器輸出並抑制微小變化。
actions:
enable: 啟用濾波器
disable: 停用濾波器
reset: 重設狀態
smoothing:
label: EMA 平滑
description: 將每個接受的目標與上一個輸出混合。數值越高,移除的抖動越多,但延遲也越長。
cutoff:
label: 變化截止值
description: 在累積的正規化變化達到此值前,保持通道的上一個目標。
diagnostics:
status: 狀態
input-change: 原始變化 MAE
output-change: 濾波後變化 MAE
cutoff-tracks: 保持的通道
status:
waiting: 等待產生器
filtering: 正在濾波產生器輸出
bypassed: 正在傳遞原始產生器輸出
breath:
title: 手動呼吸
description: 使用可調整的吸氣、呼氣與停留時間控制 ParamBreath,且不將呼吸資料加入錄製內容。
actions:
enable: 啟用呼吸
disable: 停用呼吸
reset: 重設週期
cycle:
label: 呼吸時間
description: 設定一次吸氣與呼氣的時間,不包含呼氣後停留。
inhale:
label: 吸氣占比
description: 設定呼吸時間中用於吸氣的比例。剩餘時間用於呼氣。
dwell:
label: 呼氣後停留
description: 在下一次吸氣前,將 ParamBreath 保持在最小值。
minimum:
label: 最小值
description: 設定寫入 ParamBreath 的最低值。
maximum:
label: 最大值
description: 設定寫入 ParamBreath 的最高值。
diagnostics:
stage: 階段
phase: 週期進度
output: ParamBreath 輸出
stage:
inhale: 吸氣
exhale: 呼氣
dwell: 呼氣後停留
spring:
follow:
label: 跟隨
description: 動作跟隨搖桿的速度。
inertia:
label: 慣性
description: 動作保留的動量。
groups:
eyes: 眼睛
head: 頭部
body: 身體
recording:
actions:
record: 錄製
stop-recording: 停止錄製
play: 播放
stop-playback: 停止播放
export: 匯出
import: 匯入
status:
empty: 尚無動作錄製
recording: 正在錄製動作
playing: 正在播放動作
ready: '{count} 個取樣 · {duration}'
import-error: 此檔案不是有效的 AIRI Live2D 動作錄製檔。
performance-visualizer:
title: 效能顯示器
description: 切換 FPS/長任務/記憶體覆蓋
@@ -0,0 +1,22 @@
# `@proj-airi/model-driver-magic-live2d`
This package applies MAGIC motion generators to normalized Live2D poses.
It owns pose conversion, fixed-rate scheduling, output filtering, and target release.
## Use the package
```ts
import { createDriver } from '@proj-airi/model-driver-magic-live2d'
const driver = createDriver({
target: {
apply: pose => motionControl.setPose(ownerId, pose, dynamics),
release: () => motionControl.release(ownerId),
},
})
driver.start(model.toGenerator({ seed: 1 }))
```
The package does not import Vue, Pinia, BroadcastChannel, or a Live2D renderer.
The application supplies the target adapter at the driver seam.
@@ -0,0 +1,41 @@
{
"name": "@proj-airi/model-driver-magic-live2d",
"type": "module",
"version": "0.12.0-beta.1",
"private": true,
"description": "Live2D model driver for MAGIC motion",
"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/model-driver-magic-live2d"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"build": "tsdown",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@proj-airi/motion-driver-magic": "workspace:^",
"es-toolkit": "catalog:"
},
"devDependencies": {}
}
@@ -0,0 +1,129 @@
import type { Generator } from '@proj-airi/motion-driver-magic'
import type { Pose } from './pose'
import { describe, expect, it, vi } from 'vitest'
import { createDriver } from './driver'
import { neutralPose, valuesFromPose } from './pose'
function createGenerator(overrides: Partial<Pose> = {}): Generator<number> {
let state = 0
return {
sampleRateHz: 30,
next: vi.fn(() => {
state++
return {
values: valuesFromPose({ ...neutralPose, headX: state / 10, ...overrides }),
state,
}
}),
}
}
describe('driver', () => {
it('applies the first pose and only publishes the newest catch-up frame', () => {
const apply = vi.fn()
const onGenerate = vi.fn()
const callbacks: FrameRequestCallback[] = []
const generator = createGenerator()
const driver = createDriver({
target: { apply, release: vi.fn() },
filterOptions: { enabled: false, smoothing: 0, cutoff: 0 },
now: () => 0,
requestFrame: (callback) => {
callbacks.push(callback)
return callbacks.length
},
cancelFrame: vi.fn(),
onGenerate,
})
driver.start(generator)
callbacks[0](101)
expect(generator.next).toHaveBeenCalledTimes(4)
expect(onGenerate).toHaveBeenCalledTimes(4)
expect(apply).toHaveBeenCalledTimes(2)
expect(apply.mock.calls[0][0].headX).toBe(0.1)
expect(apply.mock.calls[1][0].headX).toBe(0.4)
})
it('filters the newest catch-up pose once per display update', () => {
const apply = vi.fn()
const callbacks: FrameRequestCallback[] = []
const driver = createDriver({
target: { apply, release: vi.fn() },
filterOptions: { enabled: true, smoothing: 0.5, cutoff: 0 },
now: () => 0,
requestFrame: (callback) => {
callbacks.push(callback)
return callbacks.length
},
cancelFrame: vi.fn(),
})
driver.start(createGenerator())
callbacks[0](101)
expect(apply).toHaveBeenCalledTimes(2)
expect(apply.mock.calls[0][0].headX).toBe(0.1)
expect(apply.mock.calls[1][0].headX).toBeCloseTo(0.25)
})
it('skips generated mouth opening by default', () => {
const apply = vi.fn()
const driver = createDriver({
target: { apply, release: vi.fn() },
requestFrame: vi.fn(() => 1),
cancelFrame: vi.fn(),
})
driver.start(createGenerator({ mouthOpen: 0.7 }))
expect(apply).toHaveBeenCalledOnce()
expect(apply.mock.calls[0][0].mouthOpen).toBe(0)
})
it('publishes generated mouth opening when the output is enabled', () => {
const apply = vi.fn()
const driver = createDriver({
target: { apply, release: vi.fn() },
skipMouthOpen: () => false,
requestFrame: vi.fn(() => 1),
cancelFrame: vi.fn(),
})
driver.start(createGenerator({ mouthOpen: 0.7 }))
expect(apply).toHaveBeenCalledOnce()
expect(apply.mock.calls[0][0].mouthOpen).toBe(0.7)
})
it('replaces the generator without releasing the target', () => {
const release = vi.fn()
const cancelFrame = vi.fn()
const callbacks: FrameRequestCallback[] = []
const driver = createDriver({
target: { apply: vi.fn(), release },
now: () => 0,
requestFrame: (callback) => {
callbacks.push(callback)
return callbacks.length
},
cancelFrame,
})
driver.start(createGenerator())
driver.replace(createGenerator())
expect(driver.playing).toBe(true)
expect(release).not.toHaveBeenCalled()
driver.stop()
expect(driver.playing).toBe(false)
expect(cancelFrame).toHaveBeenCalledOnce()
expect(release).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,168 @@
import type { GenerateOptions, Generator } from '@proj-airi/motion-driver-magic'
import type { OutputFilterFrame, OutputFilterOptions } from './filter'
import type { Pose } from './pose'
import { createOutputFilter, defaultOutputFilterOptions } from './filter'
import { neutralPose, poseFromValues } from './pose'
/** Receives generated poses and releases ownership when playback stops. */
export interface Target {
apply: (pose: Pose) => void
release: () => void
}
/** Runtime interfaces for one MAGIC Live2D driver. */
export interface DriverOptions<TState> {
target: Target
/** Supplies controls that can change between generated frames. */
generateOptions?: () => GenerateOptions
/** Receives every generated model state, including catch-up frames. */
onGenerate?: (state: TState) => void
/** Receives the newest processed output, or `undefined` after reset. */
onOutput?: (frame: OutputFilterFrame | undefined) => void
/** Sets generated mouth opening to neutral before output. @default true */
skipMouthOpen?: () => boolean
/** Supplies a monotonic timestamp in milliseconds. @default performance.now */
now?: () => number
/** Schedules the next display update. @default requestAnimationFrame */
requestFrame?: (callback: FrameRequestCallback) => number
/** Cancels a scheduled display update. @default cancelAnimationFrame */
cancelFrame?: (handle: number) => void
/** Supplies the first output-filter controls. */
filterOptions?: OutputFilterOptions
}
/** Owns fixed-rate generation and one Live2D target lifecycle. */
export interface Driver<TState> {
readonly playing: boolean
/** Starts a stopped driver and applies the first generated pose immediately. */
start: (generator: Generator<TState>) => void
/** Replaces the active generator without releasing the target. */
replace: (generator: Generator<TState>) => void
/** Stops generation and releases the target. */
stop: () => void
/** Changes output-filter controls without replacing the driver. */
setFilterOptions: (options: OutputFilterOptions) => void
/** Clears filter history and reapplies the latest raw pose. */
resetFilter: () => void
/** Stops generation and releases runtime resources. */
dispose: () => void
}
/** Creates a MAGIC driver for one Live2D target. */
export function createDriver<TState>(options: DriverOptions<TState>): Driver<TState> {
const now = options.now ?? (() => performance.now())
const requestFrame = options.requestFrame ?? (callback => requestAnimationFrame(callback))
const cancelFrame = options.cancelFrame ?? (handle => cancelAnimationFrame(handle))
const filter = createOutputFilter(options.filterOptions ?? defaultOutputFilterOptions)
let generator: Generator<TState> | undefined
let animationFrame: number | undefined
let lastFrameAt = 0
let accumulatedMs = 0
let outputFrame: OutputFilterFrame | undefined
function generatePose(): Pose {
const frame = generator!.next(options.generateOptions?.())
options.onGenerate?.(frame.state)
return poseFromValues(frame.values)
}
function applyPose(pose: Pose) {
const filteredFrame = filter.process(pose)
outputFrame = (options.skipMouthOpen?.() ?? true)
? {
...filteredFrame,
pose: {
...filteredFrame.pose,
mouthOpen: neutralPose.mouthOpen,
},
}
: filteredFrame
options.onOutput?.(outputFrame)
options.target.apply(outputFrame.pose)
}
function generationFrame(timestamp: number) {
if (!generator)
return
const frameIntervalMs = 1000 / generator.sampleRateHz
accumulatedMs += Math.min(250, Math.max(0, timestamp - lastFrameAt))
lastFrameAt = timestamp
let nextPose: Pose | undefined
while (accumulatedMs >= frameIntervalMs) {
nextPose = generatePose()
accumulatedMs -= frameIntervalMs
}
if (nextPose)
applyPose(nextPose)
animationFrame = requestFrame(generationFrame)
}
function start(nextGenerator: Generator<TState>) {
if (generator)
throw new Error('The MAGIC Live2D driver is already playing.')
generator = nextGenerator
accumulatedMs = 0
lastFrameAt = now()
filter.reset()
outputFrame = undefined
options.onOutput?.(undefined)
applyPose(generatePose())
animationFrame = requestFrame(generationFrame)
}
function replace(nextGenerator: Generator<TState>) {
if (!generator)
throw new Error('The MAGIC Live2D driver is not playing.')
generator = nextGenerator
accumulatedMs = 0
lastFrameAt = now()
}
function stop() {
if (!generator)
return
if (animationFrame !== undefined)
cancelFrame(animationFrame)
generator = undefined
animationFrame = undefined
accumulatedMs = 0
filter.reset()
outputFrame = undefined
options.onOutput?.(undefined)
options.target.release()
}
function setFilterOptions(filterOptions: OutputFilterOptions) {
filter.setOptions(filterOptions)
}
function resetFilter() {
const inputPose = outputFrame?.inputPose
filter.reset()
outputFrame = undefined
options.onOutput?.(undefined)
if (generator && inputPose)
applyPose(inputPose)
}
return {
get playing() {
return generator !== undefined
},
start,
replace,
stop,
setFilterOptions,
resetFilter,
dispose: stop,
}
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { createOutputFilter, defaultOutputFilterOptions } from './filter'
import { neutralPose } from './pose'
function pose(overrides: Partial<typeof neutralPose> = {}) {
return { ...neutralPose, ...overrides }
}
describe('output filter', () => {
it('uses the tuned generator cutoff by default', () => {
expect(defaultOutputFilterOptions).toEqual({
enabled: true,
smoothing: 0.8,
cutoff: 0.0575,
})
})
it('holds cumulative changes below the cutoff', () => {
const filter = createOutputFilter({ enabled: true, smoothing: 0, cutoff: 0.05 })
filter.process(pose())
const firstNoise = filter.process(pose({ headX: 0.01 }))
const accumulatedNoise = filter.process(pose({ headX: 0.04 }))
const acceptedChange = filter.process(pose({ headX: 0.06 }))
expect(firstNoise.pose.headX).toBe(0)
expect(firstNoise.cutoffTrackCount).toBe(1)
expect(accumulatedNoise.pose.headX).toBe(0)
expect(acceptedChange.pose.headX).toBe(0.06)
expect(acceptedChange.cutoffTrackCount).toBe(0)
})
it('applies EMA smoothing after the cutoff stage', () => {
const filter = createOutputFilter({ enabled: true, smoothing: 0.75, cutoff: 0 })
filter.process(pose())
const firstStep = filter.process(pose({ headX: 1 }))
const secondStep = filter.process(pose({ headX: 1 }))
expect(firstStep.pose.headX).toBeCloseTo(0.25)
expect(secondStep.pose.headX).toBeCloseTo(0.4375)
expect(firstStep.outputChangeMeanAbsolute).toBeLessThan(firstStep.inputChangeMeanAbsolute)
})
it('bypasses the filter when disabled', () => {
const filter = createOutputFilter({ enabled: false, smoothing: 0.99, cutoff: 1 })
filter.process(pose())
const frame = filter.process(pose({ headX: 0.4, mouthOpen: 0.7 }))
expect(frame.pose).toEqual(frame.inputPose)
expect(frame.pose.headX).toBe(0.4)
expect(frame.pose.mouthOpen).toBe(0.7)
})
it('passes the first pose through after reset', () => {
const filter = createOutputFilter(defaultOutputFilterOptions)
filter.process(pose())
filter.process(pose({ headX: 1 }))
filter.reset()
const restarted = filter.process(pose({ headX: -0.5 }))
expect(restarted.pose.headX).toBe(-0.5)
expect(restarted.inputChangeMeanAbsolute).toBe(0)
expect(restarted.outputChangeMeanAbsolute).toBe(0)
})
it('resets stale history when the enabled state changes', () => {
const filter = createOutputFilter({ enabled: true, smoothing: 0.9, cutoff: 0 })
filter.process(pose())
filter.process(pose({ headX: 1 }))
filter.setOptions({ enabled: false, smoothing: 0.9, cutoff: 0 })
const bypassed = filter.process(pose({ headX: -0.75 }))
expect(bypassed.pose.headX).toBe(-0.75)
})
})
@@ -0,0 +1,142 @@
import type { Pose } from './pose'
import { clamp } from 'es-toolkit/math'
import { poseAxes } from './pose'
/** Controls the generated-pose output filter. */
export interface OutputFilterOptions {
/** Applies the cutoff and EMA stages to generated poses. @default true */
enabled: boolean
/** Weight of the previous output from 0 (raw) to 0.999 (slow). @default 0.8 */
smoothing: number
/** Smallest normalized change that updates a channel target. @default 0.0575 */
cutoff: number
}
/** One processed generator frame and its filter diagnostics. */
export interface OutputFilterFrame {
/** Raw pose emitted by VAR or AR-HMM. */
inputPose: Pose
/** Pose sent to the Live2D target. */
pose: Pose
/** Mean absolute channel change in the raw generator output. */
inputChangeMeanAbsolute: number
/** Mean absolute channel change after cutoff and EMA smoothing. */
outputChangeMeanAbsolute: number
/** Number of changed channels held below the cutoff. */
cutoffTrackCount: number
}
/** A stateful cutoff and EMA processor for fixed-rate generator poses. */
export interface OutputFilter {
/** Processes one generated pose and advances the filter state. */
process: (pose: Pose) => OutputFilterFrame
/** Clears accepted targets and EMA history before another generated run. */
reset: () => void
/** Changes filter controls without replacing the active processor. */
setOptions: (options: OutputFilterOptions) => void
}
/** Tuned controls used when a driver does not supply filter options. */
export const defaultOutputFilterOptions: Readonly<OutputFilterOptions> = Object.freeze({
enabled: true,
smoothing: 0.8,
cutoff: 0.0575,
})
function normalizeOptions(options: OutputFilterOptions): OutputFilterOptions {
return {
enabled: options.enabled,
smoothing: clamp(options.smoothing, 0, 0.999),
cutoff: clamp(options.cutoff, 0, 1),
}
}
function meanAbsoluteDifference(left: Pose, right: Pose): number {
const total = poseAxes.reduce((sum, axis) => sum + Math.abs(left[axis] - right[axis]), 0)
return total / poseAxes.length
}
/** Creates one generated-pose output filter. */
export function createOutputFilter(
initialOptions: OutputFilterOptions = defaultOutputFilterOptions,
): OutputFilter {
let options = normalizeOptions(initialOptions)
let previousInput: Pose | undefined
let acceptedPose: Pose | undefined
let outputPose: Pose | undefined
function reset() {
previousInput = undefined
acceptedPose = undefined
outputPose = undefined
}
function setOptions(nextOptions: OutputFilterOptions) {
const normalizedOptions = normalizeOptions(nextOptions)
if (normalizedOptions.enabled !== options.enabled)
reset()
options = normalizedOptions
}
function process(input: Pose): OutputFilterFrame {
const inputPose = { ...input }
if (!previousInput || !acceptedPose || !outputPose) {
previousInput = inputPose
acceptedPose = inputPose
outputPose = inputPose
return {
inputPose,
pose: inputPose,
inputChangeMeanAbsolute: 0,
outputChangeMeanAbsolute: 0,
cutoffTrackCount: 0,
}
}
const previousOutput = outputPose
const inputChangeMeanAbsolute = meanAbsoluteDifference(inputPose, previousInput)
previousInput = inputPose
if (!options.enabled) {
acceptedPose = inputPose
outputPose = inputPose
return {
inputPose,
pose: inputPose,
inputChangeMeanAbsolute,
outputChangeMeanAbsolute: meanAbsoluteDifference(inputPose, previousOutput),
cutoffTrackCount: 0,
}
}
const nextAcceptedPose = { ...acceptedPose }
const nextOutputPose = { ...outputPose }
let cutoffTrackCount = 0
for (const axis of poseAxes) {
const changeFromAccepted = Math.abs(inputPose[axis] - acceptedPose[axis])
if (changeFromAccepted >= options.cutoff)
nextAcceptedPose[axis] = inputPose[axis]
else if (changeFromAccepted > 0)
cutoffTrackCount++
nextOutputPose[axis]
= outputPose[axis] * options.smoothing
+ nextAcceptedPose[axis] * (1 - options.smoothing)
}
acceptedPose = nextAcceptedPose
outputPose = nextOutputPose
return {
inputPose,
pose: nextOutputPose,
inputChangeMeanAbsolute,
outputChangeMeanAbsolute: meanAbsoluteDifference(nextOutputPose, previousOutput),
cutoffTrackCount,
}
}
return { process, reset, setOptions }
}
@@ -0,0 +1,3 @@
export * from './driver'
export * from './filter'
export * from './pose'
@@ -0,0 +1,88 @@
import type { TrainingSequence } from '@proj-airi/motion-driver-magic'
/** A normalized pose that the MAGIC Live2D driver can apply. */
export interface Pose {
eyeX: number
eyeY: number
/** Squint amount from 0 (open) to 1 (closed). */
eyeSquint: number
headX: number
headY: number
/** Head roll from -1 (left) to 1 (right). */
headZ: number
bodyX: number
bodyY: number
/** Body roll from -1 (left) to 1 (right). */
bodyZ: number
/** Mouth shape from -1 to 1. */
mouthForm: number
/** Mouth opening from 0 (closed) to 1 (open). */
mouthOpen: number
/** Horizontal model translation from -1 to 1. */
offsetX: number
/** Vertical model translation from -1 to 1. */
offsetY: number
}
/** Pose axes used by Live2D motion adapters and filters. */
export const poseAxes = [
'eyeX',
'eyeY',
'eyeSquint',
'headX',
'headY',
'headZ',
'bodyX',
'bodyY',
'bodyZ',
'mouthForm',
'mouthOpen',
'offsetX',
'offsetY',
] as const satisfies readonly (keyof Pose)[]
/** A pose with every normalized axis at its neutral value. */
export const neutralPose: Readonly<Pose> = Object.freeze({
eyeX: 0,
eyeY: 0,
eyeSquint: 0,
headX: 0,
headY: 0,
headZ: 0,
bodyX: 0,
bodyY: 0,
bodyZ: 0,
mouthForm: 0,
mouthOpen: 0,
offsetX: 0,
offsetY: 0,
})
/** Converts one Live2D pose to the stable MAGIC channel order. */
export function valuesFromPose(pose: Pose): number[] {
return poseAxes.map(axis => pose[axis])
}
/** Converts one MAGIC frame from the stable channel order to a Live2D pose. */
export function poseFromValues(values: readonly number[]): Pose {
if (values.length !== poseAxes.length)
throw new Error(`The MAGIC frame must contain ${poseAxes.length} Live2D values.`)
const pose = { ...neutralPose }
for (let index = 0; index < poseAxes.length; index++)
pose[poseAxes[index]] = values[index]
return pose
}
/** Converts a fixed-rate Live2D pose sequence to MAGIC training frames. */
export function createTrainingSequence(source: {
sampleRateHz: number
sourceDurationMs: number
poses: readonly Pose[]
}): TrainingSequence {
return {
sampleRateHz: source.sampleRateHz,
sourceDurationMs: source.sourceDurationMs,
frames: source.poses.map(valuesFromPose),
}
}
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"DOM",
"ESNext"
]
},
"include": [
"src/**/*.ts",
"tsdown.config.ts",
"vitest.config.ts"
],
"exclude": [
"dist",
"node_modules"
]
}
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: 'src/index.ts',
},
dts: true,
platform: 'browser',
treeshake: true,
})
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+31
View File
@@ -0,0 +1,31 @@
# `@proj-airi/motion-driver-magic`
MAGIC means Markovian Animation Generator with Illusory Conditioning.
This package fits procedural motion models and generates normalized motion frames.
Its root entry point exports the VAR and AR-HMM models.
## Use the package
```ts
import type { TrainingSequence } from '@proj-airi/motion-driver-magic'
import { fit } from '@proj-airi/motion-driver-magic'
const sequence: TrainingSequence = {
sampleRateHz: 30,
sourceDurationMs: 1000,
frames,
}
const model = fit(sequence, { method: 'var', order: 12, ridge: 0.001 })
const generator = model.toGenerator({ seed: 1 })
const frame = generator.next({ noiseScale: 1 })
```
## When to use it
Use this package for model fitting and seeded procedural motion generation.
The caller controls scheduling, filtering, transport, and renderer integration.
Do not import Vue, renderer objects, recording editors, or browser scheduling into this package.
Convert application recordings to fixed-width numeric frames before fitting a model.
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@proj-airi/motion-driver-magic",
"type": "module",
"version": "0.12.0-beta.1",
"private": true,
"description": "Markovian Animation Generator with Illusory Conditioning 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/motion-driver-magic"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"build": "tsdown",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"es-toolkit": "catalog:"
},
"devDependencies": {}
}
+453
View File
@@ -0,0 +1,453 @@
import type { VarParameters } from './shared/var'
import type {
Frame,
GenerateOptions,
Generator,
GeneratorOptions,
Model as MotionModel,
TrainingSequence,
} from './types'
import { clamp } from 'es-toolkit/math'
import { frameFromChannels } from './shared/channels'
import { cholesky, createAutoregressiveFeature, predictAutoregressiveValues, solvePositiveDefinite } from './shared/numeric'
import { createNormalRandom, createSeededRandom, sampleCategorical } from './shared/random'
import { fitVarParameters } from './shared/var'
/** Fit controls for the experimental autoregressive hidden Markov model. */
export interface FitOptions {
/** Number of hidden motion regimes. */
stateCount: number
/** Number of fixed-rate history frames in each state-specific prediction. */
order: number
/** Ridge penalty relative to each state's effective sample count. */
ridge: number
/** Number of expectation-maximization updates. */
iterations: number
}
interface StateParameters {
coefficients: number[][]
covariance: number[][]
covarianceCholesky: number[][]
}
interface Parameters {
options: FitOptions
/** Shared fixed-rate frames, channel mapping, and source statistics. */
sourceModel: VarParameters
/** Initial hidden-state probabilities. */
initialProbabilities: number[]
/** Probability of each next state, indexed by current state and next state. */
transitionProbabilities: number[][]
/** State-specific autoregressive coefficients and Gaussian noise. */
states: StateParameters[]
/** Smoothed hidden-state probabilities for each fitted source frame. */
posteriorProbabilities: number[][]
}
/** Stable measurements from one AR-HMM fit. */
export interface Diagnostics {
/** Number of fixed-rate source frames used by the fit. */
sourceFrameCount: number
/** Number of varying, non-duplicate motion channels. */
channelCount: number
/** Number of intercept and lag terms in each state equation. */
featureCount: number
/** Number of hidden motion regimes. */
stateCount: number
/** Final marginal log likelihood divided by the fitted frame count. */
meanLogLikelihoodPerFrame: number
/** Fraction of fitted source frames assigned to each hidden state. */
stateOccupancy: readonly number[]
/** Occupancy-weighted geometric state duration in seconds. */
meanDwellSeconds: number
}
/** A reusable AR-HMM model. */
export type ArHmmModel = MotionModel<'ar-hmm', number, Diagnostics>
interface ExpectationResult {
gamma: number[][]
transitionCounts: number[][]
logLikelihood: number
}
function logSumExp(values: readonly number[]): number {
const maximum = Math.max(...values)
let sum = 0
for (const value of values)
sum += Math.exp(value - maximum)
return maximum + Math.log(sum)
}
function normalizeProbabilities(values: readonly number[]): number[] {
const sum = values.reduce((total, value) => total + value, 0)
return values.map(value => value / sum)
}
function squaredDistance(left: readonly number[], right: readonly number[]): number {
return left.reduce((sum, value, index) => sum + (value - right[index]) ** 2, 0)
}
function createClusterFeatures(frames: readonly number[][], order: number): number[][] {
const channelCount = frames[0].length
const velocityScales = Array.from<number>({ length: channelCount }).fill(0)
for (let frameIndex = order; frameIndex < frames.length; frameIndex++) {
for (let channel = 0; channel < channelCount; channel++)
velocityScales[channel] += (frames[frameIndex][channel] - frames[frameIndex - 1][channel]) ** 2
}
for (let channel = 0; channel < channelCount; channel++)
velocityScales[channel] = Math.max(1e-6, Math.sqrt(velocityScales[channel] / (frames.length - order)))
return frames.slice(order).map((frame, rowIndex) => [
...frame,
...frame.map((value, channel) => (value - frames[rowIndex + order - 1][channel]) / velocityScales[channel]),
])
}
function initializeAssignments(features: readonly number[][], stateCount: number): number[] {
const velocityOffset = features[0].length / 2
let quietestIndex = 0
let quietestSpeed = Number.POSITIVE_INFINITY
for (let index = 0; index < features.length; index++) {
const speed = features[index].slice(velocityOffset).reduce((sum, value) => sum + value ** 2, 0)
if (speed < quietestSpeed) {
quietestSpeed = speed
quietestIndex = index
}
}
const centroids = [[...features[quietestIndex]]]
while (centroids.length < stateCount) {
let farthestIndex = 0
let farthestDistance = -1
for (let index = 0; index < features.length; index++) {
const distance = Math.min(...centroids.map(centroid => squaredDistance(features[index], centroid)))
if (distance > farthestDistance) {
farthestDistance = distance
farthestIndex = index
}
}
centroids.push([...features[farthestIndex]])
}
const assignments = Array.from<number>({ length: features.length }).fill(0)
for (let iteration = 0; iteration < 12; iteration++) {
for (let row = 0; row < features.length; row++) {
let bestState = 0
let bestDistance = Number.POSITIVE_INFINITY
for (let state = 0; state < stateCount; state++) {
const distance = squaredDistance(features[row], centroids[state])
if (distance < bestDistance) {
bestDistance = distance
bestState = state
}
}
assignments[row] = bestState
}
const nextCentroids = Array.from(
{ length: stateCount },
() => Array.from<number>({ length: features[0].length }).fill(0),
)
const counts = Array.from<number>({ length: stateCount }).fill(0)
for (let row = 0; row < features.length; row++) {
counts[assignments[row]]++
for (let feature = 0; feature < features[row].length; feature++)
nextCentroids[assignments[row]][feature] += features[row][feature]
}
for (let state = 0; state < stateCount; state++) {
if (counts[state] === 0)
continue
centroids[state] = nextCentroids[state].map(value => value / counts[state])
}
}
return assignments
}
function createInitialExpectations(assignments: readonly number[], stateCount: number): ExpectationResult {
const probabilityFloor = 0.001
const gamma = assignments.map(assignedState => Array.from(
{ length: stateCount },
(_, state) => state === assignedState ? 1 - probabilityFloor * (stateCount - 1) : probabilityFloor,
))
const transitionCounts = Array.from(
{ length: stateCount },
() => Array.from<number>({ length: stateCount }).fill(0.1),
)
for (let row = 1; row < assignments.length; row++)
transitionCounts[assignments[row - 1]][assignments[row]]++
return { gamma, transitionCounts, logLikelihood: Number.NEGATIVE_INFINITY }
}
function fitStateCoefficients(
frames: readonly number[][],
gamma: readonly number[][],
state: number,
sourceModel: VarParameters,
options: FitOptions,
): number[][] {
const channelCount = sourceModel.channelCount
const featureCount = 1 + options.order * channelCount
const gram = Array.from({ length: featureCount }, () => Array.from<number>({ length: featureCount }).fill(0))
const cross = Array.from({ length: featureCount }, () => Array.from<number>({ length: channelCount }).fill(0))
let stateWeight = 0
for (let row = 0; row < gamma.length; row++) {
const weight = gamma[row][state]
const frameIndex = row + options.order
const feature = createAutoregressiveFeature(frames, options.order, channelCount, frameIndex)
const target = frames[frameIndex]
stateWeight += weight
for (let featureRow = 0; featureRow < featureCount; featureRow++) {
for (let featureColumn = 0; featureColumn <= featureRow; featureColumn++)
gram[featureRow][featureColumn] += weight * feature[featureRow] * feature[featureColumn]
for (let output = 0; output < channelCount; output++)
cross[featureRow][output] += weight * feature[featureRow] * target[output]
}
}
if (stateWeight < featureCount + 1)
return sourceModel.coefficients.map(row => [...row])
for (let row = 0; row < featureCount; row++) {
for (let column = 0; column < row; column++)
gram[column][row] = gram[row][column]
}
for (let index = 1; index < featureCount; index++)
gram[index][index] += options.ridge * stateWeight
return solvePositiveDefinite(gram, cross, 'The AR-HMM fit produced a singular covariance.')
}
function fitStateCovariance(
frames: readonly number[][],
gamma: readonly number[][],
state: number,
coefficients: readonly number[][],
options: FitOptions,
): number[][] {
const channelCount = frames[0].length
const covariance = Array.from({ length: channelCount }, () => Array.from<number>({ length: channelCount }).fill(0))
let stateWeight = 0
for (let row = 0; row < gamma.length; row++) {
const weight = gamma[row][state]
const frameIndex = row + options.order
const feature = createAutoregressiveFeature(frames, options.order, channelCount, frameIndex)
const prediction = predictAutoregressiveValues(coefficients, feature)
const residual = frames[frameIndex].map((value, channel) => value - prediction[channel])
stateWeight += weight
for (let left = 0; left < channelCount; left++) {
for (let right = 0; right <= left; right++)
covariance[left][right] += weight * residual[left] * residual[right]
}
}
for (let left = 0; left < channelCount; left++) {
for (let right = 0; right <= left; right++) {
covariance[left][right] /= stateWeight
covariance[right][left] = covariance[left][right]
}
covariance[left][left] += 0.0025
}
return covariance
}
function maximizeParameters(
sourceModel: VarParameters,
expectation: ExpectationResult,
options: FitOptions,
): Pick<Parameters, 'initialProbabilities' | 'transitionProbabilities' | 'states'> {
const initialProbabilities = normalizeProbabilities(expectation.gamma[0].map(value => value + 0.1))
const transitionProbabilities = expectation.transitionCounts.map((row, state) => normalizeProbabilities(
row.map((value, nextState) => value + (state === nextState ? 2 : 0.1)),
))
const states = Array.from({ length: options.stateCount }, (_, state) => {
const coefficients = fitStateCoefficients(sourceModel.trainingFrames, expectation.gamma, state, sourceModel, options)
const covariance = fitStateCovariance(sourceModel.trainingFrames, expectation.gamma, state, coefficients, options)
return {
coefficients,
covariance,
covarianceCholesky: cholesky(covariance, 'The AR-HMM fit produced a singular covariance.'),
}
})
return { initialProbabilities, transitionProbabilities, states }
}
function emissionLogProbability(
state: StateParameters,
feature: readonly number[],
observation: readonly number[],
): number {
const prediction = predictAutoregressiveValues(state.coefficients, feature)
const solved = Array.from<number>({ length: observation.length }).fill(0)
for (let row = 0; row < observation.length; row++) {
let value = observation[row] - prediction[row]
for (let column = 0; column < row; column++)
value -= state.covarianceCholesky[row][column] * solved[column]
solved[row] = value / state.covarianceCholesky[row][row]
}
const quadratic = solved.reduce((sum, value) => sum + value ** 2, 0)
const logDeterminant = 2 * state.covarianceCholesky.reduce((sum, row, index) => sum + Math.log(row[index]), 0)
return -0.5 * (observation.length * Math.log(2 * Math.PI) + logDeterminant + quadratic)
}
function expectationStep(
sourceModel: VarParameters,
parameters: Pick<Parameters, 'initialProbabilities' | 'transitionProbabilities' | 'states'>,
options: FitOptions,
): ExpectationResult {
const frames = sourceModel.trainingFrames
const rowCount = frames.length - options.order
const emissions = Array.from({ length: rowCount }, (_, row) => {
const frameIndex = row + options.order
const feature = createAutoregressiveFeature(frames, options.order, sourceModel.channelCount, frameIndex)
return parameters.states.map(state => emissionLogProbability(state, feature, frames[frameIndex]))
})
const logTransitions = parameters.transitionProbabilities.map(row => row.map(Math.log))
const alpha = Array.from({ length: rowCount }, () => Array.from<number>({ length: options.stateCount }).fill(0))
const firstAlpha = parameters.initialProbabilities.map((probability, state) => Math.log(probability) + emissions[0][state])
let scale = logSumExp(firstAlpha)
let logLikelihood = scale
alpha[0] = firstAlpha.map(value => value - scale)
for (let row = 1; row < rowCount; row++) {
const nextAlpha = Array.from({ length: options.stateCount }, (_, state) => emissions[row][state] + logSumExp(
alpha[row - 1].map((value, previousState) => value + logTransitions[previousState][state]),
))
scale = logSumExp(nextAlpha)
logLikelihood += scale
alpha[row] = nextAlpha.map(value => value - scale)
}
const beta = Array.from({ length: rowCount }, () => Array.from<number>({ length: options.stateCount }).fill(0))
for (let row = rowCount - 2; row >= 0; row--) {
const nextBeta = Array.from({ length: options.stateCount }, (_, state) => logSumExp(
beta[row + 1].map((value, nextState) => logTransitions[state][nextState] + emissions[row + 1][nextState] + value),
))
const betaScale = logSumExp(nextBeta)
beta[row] = nextBeta.map(value => value - betaScale)
}
const gamma = alpha.map((row, rowIndex) => {
const values = row.map((value, state) => value + beta[rowIndex][state])
const normalization = logSumExp(values)
return values.map(value => Math.exp(value - normalization))
})
const transitionCounts = Array.from(
{ length: options.stateCount },
() => Array.from<number>({ length: options.stateCount }).fill(0),
)
for (let row = 0; row < rowCount - 1; row++) {
const values = alpha[row].flatMap((value, state) => parameters.states.map(
(_nextStateModel, nextState) => value + logTransitions[state][nextState] + emissions[row + 1][nextState] + beta[row + 1][nextState],
))
const normalization = logSumExp(values)
for (let state = 0; state < options.stateCount; state++) {
for (let nextState = 0; nextState < options.stateCount; nextState++)
transitionCounts[state][nextState] += Math.exp(values[state * options.stateCount + nextState] - normalization)
}
}
return { gamma, transitionCounts, logLikelihood }
}
/** Creates a linear Gaussian AR-HMM model with deterministic clustering and EM updates. */
export function createArHmmModel(sequence: TrainingSequence, options: FitOptions): ArHmmModel {
if (options.stateCount < 2 || !Number.isInteger(options.stateCount))
throw new Error('The AR-HMM state count must be an integer greater than one.')
if (options.iterations < 1 || !Number.isInteger(options.iterations))
throw new Error('The AR-HMM iteration count must be a positive integer.')
const sourceModel = fitVarParameters(sequence, {
order: options.order,
ridge: options.ridge,
})
const rowCount = sourceModel.trainingFrames.length - options.order
if (rowCount < options.stateCount * (sourceModel.featureCount + 1))
throw new Error('The current motion is too short for this AR-HMM shape.')
const clusterFeatures = createClusterFeatures(sourceModel.trainingFrames, options.order)
const assignments = initializeAssignments(clusterFeatures, options.stateCount)
let expectation = createInitialExpectations(assignments, options.stateCount)
let stateParameters = maximizeParameters(sourceModel, expectation, options)
const logLikelihoods: number[] = []
for (let iteration = 0; iteration < options.iterations; iteration++) {
expectation = expectationStep(sourceModel, stateParameters, options)
logLikelihoods.push(expectation.logLikelihood)
stateParameters = maximizeParameters(sourceModel, expectation, options)
}
expectation = expectationStep(sourceModel, stateParameters, options)
logLikelihoods.push(expectation.logLikelihood)
const stateWeights = Array.from({ length: options.stateCount }, (_, state) => expectation.gamma.reduce(
(sum, probabilities) => sum + probabilities[state],
0,
))
const stateOccupancy = normalizeProbabilities(stateWeights)
const meanDwellFrames = stateOccupancy.reduce((sum, occupancy, state) => {
const leaveProbability = Math.max(1 / rowCount, 1 - stateParameters.transitionProbabilities[state][state])
return sum + occupancy / leaveProbability
}, 0)
const modelParameters: Parameters = {
options,
sourceModel,
...stateParameters,
posteriorProbabilities: expectation.gamma,
}
const diagnostics: Diagnostics = Object.freeze({
sourceFrameCount: sourceModel.sourceFrameCount,
channelCount: sourceModel.channelCount,
featureCount: sourceModel.featureCount,
stateCount: options.stateCount,
meanLogLikelihoodPerFrame: logLikelihoods.at(-1)! / expectation.gamma.length,
stateOccupancy: Object.freeze([...stateOccupancy]),
meanDwellSeconds: meanDwellFrames / sourceModel.sampleRateHz,
})
return Object.freeze({
method: 'ar-hmm',
sampleRateHz: sourceModel.sampleRateHz,
diagnostics,
toGenerator: (generatorOptions: GeneratorOptions) => toGenerator(modelParameters, generatorOptions),
})
}
function toGenerator(model: Parameters, options: GeneratorOptions): Generator<number> {
const random = createSeededRandom(options.seed)
const normalRandom = createNormalRandom(random)
const maximumStart = model.sourceModel.trainingFrames.length - model.options.order
const start = Math.floor(random() * maximumStart)
const history = model.sourceModel.trainingFrames
.slice(start, start + model.options.order)
.map(frame => [...frame])
let state = sampleCategorical(model.posteriorProbabilities[start], random)
function next(generateOptions?: GenerateOptions): Frame<number> {
const noiseScale = generateOptions?.noiseScale ?? 1
state = sampleCategorical(model.transitionProbabilities[state], random)
const stateModel = model.states[state]
const feature = createAutoregressiveFeature(history, model.options.order, model.sourceModel.channelCount)
const prediction = predictAutoregressiveValues(stateModel.coefficients, feature)
const gaussian = Array.from({ length: model.sourceModel.channelCount }, normalRandom)
const nextValues = prediction.map((value, channel) => {
let noise = 0
for (let source = 0; source <= channel; source++)
noise += stateModel.covarianceCholesky[channel][source] * gaussian[source]
const sourceChannel = model.sourceModel.channels[channel]
const rawValue = sourceChannel.mean + (value + noise * noiseScale) * sourceChannel.scale
const clampedValue = clamp(rawValue, sourceChannel.minimum, sourceChannel.maximum)
return (clampedValue - sourceChannel.mean) / sourceChannel.scale
})
history.shift()
history.push(nextValues)
return {
values: frameFromChannels(model.sourceModel.baselineFrame, model.sourceModel.channels, nextValues),
state,
}
}
return { sampleRateHz: model.sourceModel.sampleRateHz, next }
}
+34
View File
@@ -0,0 +1,34 @@
import type { FitOptions as ArHmmFitOptions, ArHmmModel } from './ar-hmm'
import type { TrainingSequence } from './types'
import type { FitOptions as VarFitOptions, VarModel } from './var'
import { createArHmmModel } from './ar-hmm'
import { createVarModel } from './var'
/** Selects one MAGIC method and supplies its fit controls. */
export type FitOptions
= | ({ method: 'ar-hmm' } & ArHmmFitOptions)
| ({ method: 'var' } & VarFitOptions)
/** A fitted model from any MAGIC method. */
export type MagicModel = ArHmmModel | VarModel
/** Fits one MAGIC model from a fixed-rate motion sequence. */
export function fit(sequence: TrainingSequence, options: { method: 'ar-hmm' } & ArHmmFitOptions): ArHmmModel
export function fit(sequence: TrainingSequence, options: { method: 'var' } & VarFitOptions): VarModel
export function fit(sequence: TrainingSequence, options: FitOptions): MagicModel
export function fit(sequence: TrainingSequence, options: FitOptions): MagicModel {
if (options.method === 'var') {
return createVarModel(sequence, {
order: options.order,
ridge: options.ridge,
})
}
return createArHmmModel(sequence, {
stateCount: options.stateCount,
order: options.order,
ridge: options.ridge,
iterations: options.iterations,
})
}
+14
View File
@@ -0,0 +1,14 @@
export { createArHmmModel } from './ar-hmm'
export type {
Diagnostics as ArHmmDiagnostics,
FitOptions as ArHmmFitOptions,
ArHmmModel,
} from './ar-hmm'
export * from './fit'
export * from './types'
export { createVarModel } from './var'
export type {
Diagnostics as VarDiagnostics,
FitOptions as VarFitOptions,
VarModel,
} from './var'
@@ -0,0 +1,82 @@
import { clamp } from 'es-toolkit/math'
/** One varying source channel and every exact duplicate value that shares it. */
export interface Channel {
valueIndices: number[]
mean: number
scale: number
minimum: number
maximum: number
}
function mean(values: readonly number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function standardDeviation(values: readonly number[], average: number): number {
const variance = values.reduce((sum, value) => sum + (value - average) ** 2, 0) / values.length
return Math.sqrt(variance)
}
function tracksMatch(
frames: readonly (readonly number[])[],
left: number,
right: number,
): boolean {
return frames.every(frame => Math.abs(frame[left] - frame[right]) <= 1e-8)
}
/** Extracts varying channels and folds exact duplicate source curves together. */
export function createMotionChannels(frames: readonly (readonly number[])[]): Channel[] {
const channels: Channel[] = []
for (let valueIndex = 0; valueIndex < frames[0].length; valueIndex++) {
const values = frames.map(frame => frame[valueIndex])
const average = mean(values)
const scale = standardDeviation(values, average)
if (scale <= 1e-8)
continue
const matchingChannel = channels.find(channel => tracksMatch(frames, channel.valueIndices[0], valueIndex))
if (matchingChannel) {
matchingChannel.valueIndices.push(valueIndex)
continue
}
channels.push({
valueIndices: [valueIndex],
mean: average,
scale,
minimum: Math.min(...values),
maximum: Math.max(...values),
})
}
return channels
}
/** Creates the mean frame, including values that are constant in the source. */
export function createBaselineFrame(frames: readonly (readonly number[])[]): number[] {
return Array.from(
{ length: frames[0].length },
(_, valueIndex) => mean(frames.map(frame => frame[valueIndex])),
)
}
/** Projects normalized channel values back into a complete, bounded frame. */
export function frameFromChannels(
baselineFrame: readonly number[],
channels: readonly Channel[],
values: readonly number[],
): number[] {
const frame = [...baselineFrame]
for (let channelIndex = 0; channelIndex < channels.length; channelIndex++) {
const channel = channels[channelIndex]
const rawValue = clamp(
channel.mean + values[channelIndex] * channel.scale,
channel.minimum,
channel.maximum,
)
for (const valueIndex of channel.valueIndices)
frame[valueIndex] = rawValue
}
return frame
}
@@ -0,0 +1,83 @@
/** Creates one intercept-and-lag feature vector from autoregressive history. */
export function createAutoregressiveFeature(
frames: readonly number[][],
order: number,
channelCount: number,
endIndex = frames.length,
): number[] {
const feature = [1]
for (let lag = 1; lag <= order; lag++) {
const frame = frames[endIndex - lag]
for (let channel = 0; channel < channelCount; channel++)
feature.push(frame[channel])
}
return feature
}
/** Applies autoregressive coefficients to one feature vector. */
export function predictAutoregressiveValues(
coefficients: readonly number[][],
feature: readonly number[],
): number[] {
const outputCount = coefficients[0].length
const prediction = Array.from<number>({ length: outputCount }).fill(0)
for (let featureIndex = 0; featureIndex < feature.length; featureIndex++) {
for (let outputIndex = 0; outputIndex < outputCount; outputIndex++)
prediction[outputIndex] += feature[featureIndex] * coefficients[featureIndex][outputIndex]
}
return prediction
}
/** Computes the lower-triangular Cholesky factor of a positive-definite matrix. */
export function cholesky(matrix: readonly number[][], singularMessage: string): number[][] {
const size = matrix.length
const lower = Array.from({ length: size }, () => Array.from<number>({ length: size }).fill(0))
for (let row = 0; row < size; row++) {
for (let column = 0; column <= row; column++) {
let value = matrix[row][column]
for (let index = 0; index < column; index++)
value -= lower[row][index] * lower[column][index]
if (row === column) {
if (value <= 1e-12)
throw new Error(singularMessage)
lower[row][column] = Math.sqrt(value)
}
else {
lower[row][column] = value / lower[column][column]
}
}
}
return lower
}
/** Solves a positive-definite linear system for one or more target columns. */
export function solvePositiveDefinite(
matrix: readonly number[][],
targets: readonly number[][],
singularMessage: string,
): number[][] {
const lower = cholesky(matrix, singularMessage)
const size = matrix.length
const outputCount = targets[0].length
const intermediate = Array.from({ length: size }, () => Array.from<number>({ length: outputCount }).fill(0))
for (let row = 0; row < size; row++) {
for (let output = 0; output < outputCount; output++) {
let value = targets[row][output]
for (let column = 0; column < row; column++)
value -= lower[row][column] * intermediate[column][output]
intermediate[row][output] = value / lower[row][row]
}
}
const solution = Array.from({ length: size }, () => Array.from<number>({ length: outputCount }).fill(0))
for (let row = size - 1; row >= 0; row--) {
for (let output = 0; output < outputCount; output++) {
let value = intermediate[row][output]
for (let column = row + 1; column < size; column++)
value -= lower[column][row] * solution[column][output]
solution[row][output] = value / lower[row][row]
}
}
return solution
}
@@ -0,0 +1,40 @@
/** Creates a deterministic uniform random stream from one unsigned seed. */
export function createSeededRandom(seed: number): () => number {
let state = seed >>> 0
return () => {
state += 0x6D2B79F5
let value = state
value = Math.imul(value ^ value >>> 15, value | 1)
value ^= value + Math.imul(value ^ value >>> 7, value | 61)
return ((value ^ value >>> 14) >>> 0) / 4294967296
}
}
/** Samples an index from a normalized categorical distribution. */
export function sampleCategorical(probabilities: readonly number[], random: () => number): number {
const target = random()
let cumulative = 0
for (let index = 0; index < probabilities.length; index++) {
cumulative += probabilities[index]
if (target <= cumulative)
return index
}
return probabilities.length - 1
}
/** Creates a standard-normal random stream from a uniform random stream. */
export function createNormalRandom(random: () => number): () => number {
let spare: number | undefined
return () => {
if (spare !== undefined) {
const value = spare
spare = undefined
return value
}
const radius = Math.sqrt(-2 * Math.log(Math.max(Number.EPSILON, random())))
const angle = 2 * Math.PI * random()
spare = radius * Math.sin(angle)
return radius * Math.cos(angle)
}
}
@@ -0,0 +1,144 @@
import type { GenerateOptions, Generator, GeneratorOptions, TrainingSequence } from '../types'
import type { Channel } from './channels'
import { createBaselineFrame, createMotionChannels, frameFromChannels } from './channels'
import { createAutoregressiveFeature, predictAutoregressiveValues, solvePositiveDefinite } from './numeric'
import { createSeededRandom } from './random'
/** Fit controls shared by the VAR and AR-HMM implementations. */
export interface VarFitOptions {
/** Number of fixed-rate history frames in each generation step. */
order: number
/** Ridge penalty relative to the number of training rows. */
ridge: number
}
/** Internal VAR parameters shared by the VAR and AR-HMM implementations. */
export interface VarParameters {
options: VarFitOptions
sampleRateHz: number
sourceFrameCount: number
channelCount: number
featureCount: number
residualRootMeanSquare: number
channels: Channel[]
coefficients: number[][]
residuals: number[][]
trainingFrames: number[][]
baselineFrame: number[]
}
function fitCoefficients(frames: readonly number[][], options: VarFitOptions): number[][] {
const channelCount = frames[0].length
const featureCount = 1 + options.order * channelCount
const gram = Array.from({ length: featureCount }, () => Array.from<number>({ length: featureCount }).fill(0))
const cross = Array.from({ length: featureCount }, () => Array.from<number>({ length: channelCount }).fill(0))
for (let frameIndex = options.order; frameIndex < frames.length; frameIndex++) {
const feature = createAutoregressiveFeature(frames, options.order, channelCount, frameIndex)
const target = frames[frameIndex]
for (let row = 0; row < featureCount; row++) {
for (let column = 0; column <= row; column++)
gram[row][column] += feature[row] * feature[column]
for (let output = 0; output < channelCount; output++)
cross[row][output] += feature[row] * target[output]
}
}
for (let row = 0; row < featureCount; row++) {
for (let column = 0; column < row; column++)
gram[column][row] = gram[row][column]
}
const trainingRowCount = frames.length - options.order
for (let index = 1; index < featureCount; index++)
gram[index][index] += options.ridge * trainingRowCount
return solvePositiveDefinite(gram, cross, 'The VAR fit is numerically singular. Increase the ridge penalty.')
}
function createResiduals(frames: readonly number[][], coefficients: readonly number[][], order: number): number[][] {
const channelCount = frames[0].length
const residuals: number[][] = []
for (let frameIndex = order; frameIndex < frames.length; frameIndex++) {
const feature = createAutoregressiveFeature(frames, order, channelCount, frameIndex)
const prediction = predictAutoregressiveValues(coefficients, feature)
residuals.push(frames[frameIndex].map((value, channel) => value - prediction[channel]))
}
return residuals
}
/** Fits the VAR parameters that both public methods use. */
export function fitVarParameters(sequence: TrainingSequence, options: VarFitOptions): VarParameters {
if (!Number.isFinite(sequence.sampleRateHz) || sequence.sampleRateHz <= 0)
throw new Error('The motion sample rate must be positive.')
if (options.order < 1 || !Number.isInteger(options.order))
throw new Error('The VAR order must be a positive integer.')
const frames = sequence.frames
if (frames.length === 0 || frames[0].length === 0)
throw new Error('The motion sequence must contain at least one value.')
if (frames.some(frame => frame.length !== frames[0].length))
throw new Error('Every motion frame must have the same number of values.')
const channels = createMotionChannels(frames)
if (channels.length === 0)
throw new Error('The current motion has no changing channels.')
if (frames.length <= options.order + 1)
throw new Error('The current motion is too short for this VAR order.')
const baselineFrame = createBaselineFrame(frames)
const trainingFrames = frames.map(frame => channels.map(
channel => (frame[channel.valueIndices[0]] - channel.mean) / channel.scale,
))
const coefficients = fitCoefficients(trainingFrames, options)
const residuals = createResiduals(trainingFrames, coefficients, options.order)
const squaredResidualSum = residuals.reduce(
(sum, residual) => sum + residual.reduce((channelSum, value) => channelSum + value ** 2, 0),
0,
)
return {
options,
sampleRateHz: sequence.sampleRateHz,
sourceFrameCount: frames.length,
channelCount: channels.length,
featureCount: coefficients.length,
residualRootMeanSquare: Math.sqrt(squaredResidualSum / (residuals.length * channels.length)),
channels,
coefficients,
residuals,
trainingFrames,
baselineFrame,
}
}
/** Creates one independent VAR generator from internal model parameters. */
export function toVarGenerator(parameters: VarParameters, options: GeneratorOptions): Generator {
const random = createSeededRandom(options.seed)
const maximumStart = parameters.trainingFrames.length - parameters.options.order
const start = Math.floor(random() * maximumStart)
const history = parameters.trainingFrames
.slice(start, start + parameters.options.order)
.map(frame => [...frame])
function next(generateOptions?: GenerateOptions) {
const noiseScale = generateOptions?.noiseScale ?? 1
const feature = createAutoregressiveFeature(history, parameters.options.order, parameters.channelCount)
const prediction = predictAutoregressiveValues(parameters.coefficients, feature)
const residual = parameters.residuals[Math.floor(random() * parameters.residuals.length)]
const nextFrame = prediction.map((value, channelIndex) => {
const channel = parameters.channels[channelIndex]
const rawValue = channel.mean + (value + residual[channelIndex] * noiseScale) * channel.scale
const clampedValue = Math.min(channel.maximum, Math.max(channel.minimum, rawValue))
return (clampedValue - channel.mean) / channel.scale
})
history.shift()
history.push(nextFrame)
return {
values: frameFromChannels(parameters.baselineFrame, parameters.channels, nextFrame),
state: undefined,
}
}
return { sampleRateHz: parameters.sampleRateHz, next }
}
+59
View File
@@ -0,0 +1,59 @@
/** A fixed-width frame of normalized motion values. */
export type MotionValues = readonly number[]
/** A fixed-rate motion sequence that is ready for model fitting. */
export interface TrainingSequence {
/** Sampling cadence of `frames`, in frames per second. */
sampleRateHz: number
/** Duration of the source before fixed-rate sampling, in milliseconds. */
sourceDurationMs: number
/** Fixed-width frames in time order. */
frames: readonly MotionValues[]
}
/** The mathematical method used to fit a motion model. */
export type Method = 'ar-hmm' | 'var'
/** Controls that can change between generated frames. */
export interface GenerateOptions {
/** Scale of the sampled model noise. @default 1 */
noiseScale?: number
}
/** Options that initialize one independent generator. */
export interface GeneratorOptions {
/** Seed for generator history and random sampling. */
seed: number
}
/** One generated motion frame and its method-specific state. */
export interface Frame<TState = undefined> {
/** Generated normalized motion values. */
values: number[]
/** Method-specific state after this frame. */
state: TState
}
/** One stateful, seeded stream of fixed-rate motion frames. */
export interface Generator<TState = undefined> {
/** Generation cadence in frames per second. */
readonly sampleRateHz: number
/** Advances the generator by one frame. */
next: (options?: GenerateOptions) => Frame<TState>
}
/** A reusable model produced by one motion method. */
export interface Model<
TMethod extends Method,
TState,
TDiagnostics,
> {
/** Mathematical method that produced this model. */
readonly method: TMethod
/** Generation cadence in frames per second. */
readonly sampleRateHz: number
/** Stable measurements from the fit. */
readonly diagnostics: Readonly<TDiagnostics>
/** Creates an independent generator without changing this model. */
toGenerator: (options: GeneratorOptions) => Generator<TState>
}
@@ -0,0 +1,113 @@
import type { TrainingSequence } from './index'
import { describe, expect, it } from 'vitest'
import { fit } from './index'
const eyeXIndex = 0
const headXIndex = 3
const headYIndex = 4
const mouthOpenIndex = 10
function createTrainingSequence(): TrainingSequence {
const sampleRateHz = 30
const frames = Array.from({ length: 240 }, (_, frame) => {
const regime = Math.floor(frame / 60) % 2
const phase = (frame % 60) / 60 * Math.PI * 2
const headX = Math.sin(phase) * (regime === 0 ? 0.25 : 0.7)
const headY = Math.cos(phase * 0.5) * (regime === 0 ? 0.2 : 0.55)
return [
headX,
0,
0,
headX,
headY,
0,
headX * 0.4,
headY * 0.3,
0,
0,
(Math.sin(phase * 2) + 1) * (regime === 0 ? 0.1 : 0.35),
0,
0,
]
})
return {
sampleRateHz,
sourceDurationMs: (frames.length - 1) / sampleRateHz * 1000,
frames,
}
}
describe('var motion model', () => {
it('folds duplicate tracks and creates deterministic seeded predictions', () => {
const model = fit(createTrainingSequence(), {
method: 'var',
order: 4,
ridge: 0.001,
})
const left = model.toGenerator({ seed: 42 })
const right = model.toGenerator({ seed: 42 })
const leftFrames = Array.from({ length: 8 }, () => left.next({ noiseScale: 1 }))
const rightFrames = Array.from({ length: 8 }, () => right.next({ noiseScale: 1 }))
expect(model.diagnostics.sourceFrameCount).toBe(240)
expect(leftFrames).toEqual(rightFrames)
expect(leftFrames.every(frame => frame.values[eyeXIndex] === frame.values[headXIndex])).toBe(true)
expect(leftFrames.slice(0, 3).map(frame => [
frame.values[headXIndex],
frame.values[headYIndex],
frame.values[mouthOpenIndex],
])).toEqual([
[0.12799432561135884, 0.054573315614377164, 0.024153378051292806],
[0.10509858715154652, 0.03374690235924656, 0.056620804071637526],
[0.08510790150474039, 0.04028754800254499, 0.07558465056506322],
])
})
it('changes the generated stream when the seed changes', () => {
const model = fit(createTrainingSequence(), {
method: 'var',
order: 4,
ridge: 0.001,
})
const first = model.toGenerator({ seed: 1 }).next({ noiseScale: 1 })
const second = model.toGenerator({ seed: 2 }).next({ noiseScale: 1 })
expect(first.values).not.toEqual(second.values)
})
})
describe('ar-hmm motion model', () => {
it('fits normalized state probabilities and creates deterministic seeded predictions', () => {
const model = fit(createTrainingSequence(), {
method: 'ar-hmm',
stateCount: 2,
order: 2,
ridge: 0.003,
iterations: 3,
})
const left = model.toGenerator({ seed: 42 })
const right = model.toGenerator({ seed: 42 })
const leftFrames = Array.from({ length: 8 }, () => left.next({ noiseScale: 0.8 }))
const rightFrames = Array.from({ length: 8 }, () => right.next({ noiseScale: 0.8 }))
expect(model.diagnostics.stateCount).toBe(2)
expect(model.diagnostics.stateOccupancy.reduce((sum, value) => sum + value, 0)).toBeCloseTo(1)
expect(leftFrames).toEqual(rightFrames)
expect(leftFrames.every(frame => frame.state >= 0 && frame.state < 2)).toBe(true)
expect(leftFrames.slice(0, 3).map(frame => [
frame.values[headXIndex],
frame.values[headYIndex],
frame.values[mouthOpenIndex],
frame.state,
])).toEqual([
[0.13943473079372, 0.06070498660996517, 0.020640206789858145, 0],
[0.11433926174359062, 0.06803350647613264, 0.04315048588173365, 0],
[0.08294112596838775, 0.03427563830236432, 0.07306459062250431, 0],
])
})
})
+40
View File
@@ -0,0 +1,40 @@
import type { VarFitOptions } from './shared/var'
import type { GeneratorOptions, Model as MotionModel, TrainingSequence } from './types'
import { fitVarParameters, toVarGenerator } from './shared/var'
/** Controls for one VAR fit. */
export type FitOptions = VarFitOptions
/** Stable measurements from one VAR fit. */
export interface Diagnostics {
/** Number of fixed-rate source frames used by the fit. */
sourceFrameCount: number
/** Number of varying, non-duplicate motion channels. */
channelCount: number
/** Number of intercept and lag terms in each channel equation. */
featureCount: number
/** Root mean square of the normalized one-step residuals. */
residualRootMeanSquare: number
}
/** A reusable VAR model. */
export type VarModel = MotionModel<'var', undefined, Diagnostics>
/** Creates a ridge-regularized VAR model from a fixed-rate motion sequence. */
export function createVarModel(sequence: TrainingSequence, options: FitOptions): VarModel {
const parameters = fitVarParameters(sequence, options)
const diagnostics: Diagnostics = Object.freeze({
sourceFrameCount: parameters.sourceFrameCount,
channelCount: parameters.channelCount,
featureCount: parameters.featureCount,
residualRootMeanSquare: parameters.residualRootMeanSquare,
})
return Object.freeze({
method: 'var',
sampleRateHz: parameters.sampleRateHz,
diagnostics,
toGenerator: (generatorOptions: GeneratorOptions) => toVarGenerator(parameters, generatorOptions),
})
}
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"ESNext"
]
},
"include": [
"src/**/*.ts",
"tsdown.config.ts",
"vitest.config.ts"
],
"exclude": [
"dist",
"node_modules"
]
}
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: 'src/index.ts',
},
dts: true,
platform: 'browser',
treeshake: true,
})
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+7 -12
View File
@@ -5,7 +5,8 @@ import type { BeatSyncDetectorState } from './types'
import { createContext as createWebContext, defineInvokeEventa } from '@moeru/eventa'
import { createContext as createBroadcastChannelContext } from '@moeru/eventa/adapters/broadcast-channel'
import { isElectronWindow } from '@proj-airi/stage-shared'
import { isElectronWindow } from '../window'
// Functions
export const beatSyncToggleInvokeEventa = defineInvokeEventa<void, boolean>('eventa:invoke:electron:beat-sync:toggle')
@@ -17,21 +18,15 @@ export const beatSyncGetInputByteFrequencyDataInvokeEventa = defineInvokeEventa<
export const beatSyncStateChangedInvokeEventa = defineInvokeEventa<void, BeatSyncDetectorState>('eventa:event:electron:beat-sync:state-changed')
export const beatSyncBeatSignaledInvokeEventa = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:beat-signaled')
let _broadcastChannel: BroadcastChannel | undefined
let broadcastChannel: BroadcastChannel | undefined
function getBroadcastChannel() {
if (!_broadcastChannel) {
_broadcastChannel = new BroadcastChannel('airi::beat-sync')
_broadcastChannel.onmessage = () => {
// TODO: do we need to handle this?
// REVIEW(nekomeowww): do we need to handle this?
}
}
return _broadcastChannel
broadcastChannel ??= new BroadcastChannel('airi::beat-sync')
return broadcastChannel
}
export function createContext(): InvocableEventContext<any, { raw?: any }> {
export function createContext(): InvocableEventContext<unknown, { raw?: unknown }> {
if (isElectronWindow(window)) {
return createBroadcastChannelContext(getBroadcastChannel()).context as InvocableEventContext<any, { raw?: any }>
return createBroadcastChannelContext(getBroadcastChannel()).context as InvocableEventContext<unknown, { raw?: unknown }>
}
else {
return createWebContext()
+1
View File
@@ -50,6 +50,7 @@
"@pixi/sprite": "catalog:",
"@pixi/ticker": "catalog:",
"@pixi/utils": "catalog:",
"@proj-airi/model-driver-magic-live2d": "workspace:^",
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@vueuse/core": "catalog:",
@@ -3,7 +3,7 @@ import type { Live2DEyeFocusSource } from '../../composables/live2d'
import { Screen } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import Live2DCanvas from './live2d/Canvas.vue'
import Live2DModel from './live2d/Model.vue'
@@ -45,6 +45,7 @@ const activeCursorPosition = ref<Live2DEyeFocusSource | null>(null)
let clearCursorFocusTimeout: ReturnType<typeof setTimeout> | undefined
const {
live2dMotionDriver,
live2dEyeTracking,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
@@ -55,6 +56,7 @@ const {
live2dRenderScale,
live2dShadowEnabled,
} = storeToRefs(useSettingsLive2d())
const universalMotionEnabled = computed(() => live2dMotionDriver.value === 'universal')
const mouseFocus = useLive2DEyeFocusFor({
canvas: () => live2dCanvasRef.value?.canvasElement(),
model: () => ({
@@ -64,7 +66,6 @@ const mouseFocus = useLive2DEyeFocusFor({
}),
source: activeCursorPosition,
})
watch(() => props.cursorPosition, (cursorPosition) => {
activeCursorPosition.value = cursorPosition ? { ...cursorPosition } : null
if (clearCursorFocusTimeout)
@@ -120,12 +121,12 @@ defineExpose({
:height="height"
:paused="paused"
:focus-at="mouseFocus"
:eye-tracking="live2dEyeTracking"
:eye-focus-source-active="!!activeCursorPosition"
:eye-tracking="universalMotionEnabled && live2dEyeTracking"
:eye-focus-source-active="universalMotionEnabled && !!activeCursorPosition"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
:live2d-force-idle-eye-animation="live2dForceIdleEyeAnimation"
:live2d-idle-animation-enabled="universalMotionEnabled && live2dIdleAnimationEnabled"
:live2d-force-idle-eye-animation="universalMotionEnabled && live2dForceIdleEyeAnimation"
:live2d-auto-blink-enabled="live2dAutoBlinkEnabled"
:live2d-force-auto-blink-enabled="live2dForceAutoBlinkEnabled"
:live2d-expression-enabled="live2dExpressionEnabled"
@@ -16,18 +16,22 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from
import {
createBeatSyncController,
createLive2DMotionSpring,
disableLive2DSdkBreath,
useExpressionController,
useLive2DMotionManagerUpdate,
useMotionUpdatePluginAutoEyeBlink,
useMotionUpdatePluginBeatSync,
useMotionUpdatePluginBreathControl,
useMotionUpdatePluginExpression,
useMotionUpdatePluginIdleDisable,
useMotionUpdatePluginIdleFocus,
useMotionUpdatePluginLipSync,
useMotionUpdatePluginManualControl,
} from '../../../composables/live2d'
import { useFitModel } from '../../../composables/live2d/fit-model'
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useL2dViewControl, useLive2dParams } from '../../../stores'
import { getLive2DMotionControlModelOffset, useL2dViewControl, useLive2DMotionControl, useLive2dParams } from '../../../stores'
const props = withDefaults(defineProps<{
modelSrc?: string
@@ -76,6 +80,10 @@ const emits = defineEmits<{
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const { position, scale } = useL2dViewControl()
const {
breathControl: manualBreathControl,
control: manualMotionControl,
} = storeToRefs(useLive2DMotionControl())
const modelSrcRef = toRef(() => props.modelSrc)
@@ -85,9 +93,11 @@ let isUnmounted = false
const modelLoadMutex = new Mutex()
const manualMotionSpring = createLive2DMotionSpring()
const manualControlOffset = computed(() => getLive2DMotionControlModelOffset(manualMotionSpring.output.value))
const offset = computed(() => ({
x: (position.value.x / 100) * props.width,
y: -(position.value.y / 100) * props.height,
x: (position.value.x / 100) * props.width + manualControlOffset.value.x,
y: -(position.value.y / 100) * props.height + manualControlOffset.value.y,
}))
const pixiApp = toRef(() => props.app)
@@ -291,6 +301,7 @@ async function performModelLoad() {
const internalModel = model.value.internalModel
const coreModel = internalModel.coreModel
const motionManager = internalModel.motionManager
disableLive2DSdkBreath(internalModel)
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
availableMotions.value = Object
@@ -368,6 +379,8 @@ async function performModelLoad() {
motionManagerUpdate.register(useMotionUpdatePluginExpression(expressionController), 'final')
motionManagerUpdate.register(useMotionUpdatePluginAutoEyeBlink(live2dExpressionEnabled), 'final')
motionManagerUpdate.register(useMotionUpdatePluginLipSync(mouthOpenSize, nowSpeaking), 'final')
motionManagerUpdate.register(useMotionUpdatePluginManualControl(manualMotionControl, manualMotionSpring), 'final')
motionManagerUpdate.register(useMotionUpdatePluginBreathControl(manualBreathControl), 'final')
const hookedUpdate = motionManager.update as (model: PixiLive2DInternalModel['coreModel'], now: number) => boolean
motionManager.update = function (model: PixiLive2DInternalModel['coreModel'], now: number) {
@@ -3,4 +3,5 @@ export * from './beat-sync'
export * from './expression-controller'
export * from './eye-tracking'
export * from './live2d'
export * from './motion-control-spring'
export * from './motion-manager'
@@ -24,6 +24,14 @@ describe('useSettingsLive2d', () => {
setActivePinia(createPinia())
})
it('defaults the motion driver to Universal', async () => {
const { useSettingsLive2d } = await import('./live2d')
const settings = useSettingsLive2d()
expect(settings.live2dMotionDriver).toBe('universal')
})
/**
* @example
* expect(settings.live2dEyeTracking).toBe(true)
@@ -1,6 +1,9 @@
import { useLocalStorageManualReset, useVersionedLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
export type Live2DMotionDriver = 'magic' | 'universal'
const live2dMotionDriver = useLocalStorageManualReset<Live2DMotionDriver>('settings/live2d/motion-driver', 'universal')
const live2dEyeTracking = useLocalStorageManualReset<boolean>('settings/live2d/eye-tracking', true)
/** Offset from model center to the eyes of the model, in percentages of full model width/height */
const live2dModelEyeOffset = useLocalStorageManualReset('settings/live2d/model-eye-offset', { x: 0, y: 0 })
@@ -33,6 +36,7 @@ const live2dMaxFps = useLocalStorageManualReset<number>('settings/live2d/max-fps
const live2dRenderScale = useLocalStorageManualReset<number>('settings/live2d/render-scale', 2)
function resetState() {
live2dMotionDriver.reset()
live2dEyeTracking.reset()
live2dModelEyeOffset.reset()
live2dIdleAnimationEnabled.reset()
@@ -47,6 +51,7 @@ function resetState() {
export const useSettingsLive2d = defineStore('settings-live2d', () => {
return {
live2dMotionDriver,
live2dEyeTracking,
live2dModelEyeOffset,
live2dIdleAnimationEnabled,
@@ -0,0 +1,64 @@
import type { Live2DMotionControlState } from '../../stores/motion-control'
import { describe, expect, it } from 'vitest'
import { neutralLive2DMotionControlPose } from '../../stores/motion-control'
import { createLive2DMotionSpring } from './motion-control-spring'
function createControl(overrides: Partial<Live2DMotionControlState> = {}): Live2DMotionControlState {
return {
active: true,
ownerId: 'devtool',
pose: { ...neutralLive2DMotionControlPose, headX: 1, headY: -0.5, headZ: 0.75, bodyZ: -1 },
dynamics: { follow: 0.6, inertia: 0.35 },
...overrides,
}
}
describe('live2D motion spring', () => {
it('moves toward the joystick target without snapping to it', () => {
const spring = createLive2DMotionSpring()
const first = spring.step(createControl(), 1 / 60)
expect(first.pose.headX).toBeGreaterThan(0)
expect(first.pose.headX).toBeLessThan(1)
expect(first.pose.headY).toBeLessThan(0)
expect(first.pose.headY).toBeGreaterThan(-0.5)
})
it('uses follow to control how quickly the output approaches the target', () => {
const slow = createLive2DMotionSpring()
const fast = createLive2DMotionSpring()
for (let frame = 0; frame < 12; frame += 1) {
slow.step(createControl({ dynamics: { follow: 0.9, inertia: 0.35 } }), 1 / 60)
fast.step(createControl({ dynamics: { follow: 1.8, inertia: 0.35 } }), 1 / 60)
}
expect(fast.output.value.pose.headX).toBeGreaterThan(slow.output.value.pose.headX)
})
it('uses inertia to preserve more movement after the target returns to center', () => {
const lowInertia = createLive2DMotionSpring({ ...neutralLive2DMotionControlPose, headX: 1 })
const highInertia = createLive2DMotionSpring({ ...neutralLive2DMotionControlPose, headX: 1 })
lowInertia.step(createControl({ active: false, dynamics: { follow: 0.6, inertia: 0 } }), 1 / 30)
highInertia.step(createControl({ active: false, dynamics: { follow: 0.6, inertia: 1 } }), 1 / 30)
expect(highInertia.output.value.pose.headX).toBeGreaterThan(lowInertia.output.value.pose.headX)
})
it('settles at neutral after manual control is released', () => {
const spring = createLive2DMotionSpring({ ...neutralLive2DMotionControlPose, headX: 1, headY: -1, headZ: 1, bodyZ: -1 })
const released = createControl({ active: false })
for (let frame = 0; frame < 600; frame += 1)
spring.step(released, 1 / 60)
expect(spring.output.value).toEqual({
active: false,
pose: neutralLive2DMotionControlPose,
})
})
})
@@ -0,0 +1,104 @@
import type { ShallowRef } from 'vue'
import type { Live2DMotionControlPose, Live2DMotionControlState } from '../../stores/motion-control'
import { poseAxes } from '@proj-airi/model-driver-magic-live2d'
import { shallowRef } from 'vue'
import { neutralLive2DMotionControlPose } from '../../stores/motion-control'
export interface Live2DMotionSpringOutput {
/** True while the spring follows a target or settles at neutral. */
active: boolean
/** The actual normalized pose after spring simulation. */
pose: Live2DMotionControlPose
}
export interface Live2DMotionSpringController {
output: Readonly<ShallowRef<Live2DMotionSpringOutput>>
/** Advances the spring and returns its current output. */
step: (control: Live2DMotionControlState, elapsedSeconds: number) => Live2DMotionSpringOutput
}
const settledPositionThreshold = 0.001
const settledVelocityThreshold = 0.001
const maximumStepSeconds = 1 / 120
function stepAxis(options: {
current: number
target: number
velocity: number
elapsedSeconds: number
stiffness: number
damping: number
mass: number
}): { position: number, velocity: number } {
const acceleration = (options.stiffness * (options.target - options.current) - options.damping * options.velocity) / options.mass
const velocity = options.velocity + acceleration * options.elapsedSeconds
return {
position: options.current + velocity * options.elapsedSeconds,
velocity,
}
}
function poseIsSettled(pose: Live2DMotionControlPose, velocity: Live2DMotionControlPose, target: Live2DMotionControlPose): boolean {
return poseAxes.every(axis => (
Math.abs(target[axis] - pose[axis]) < settledPositionThreshold
&& Math.abs(velocity[axis]) < settledVelocityThreshold
))
}
/**
* Creates one spring simulation for a manual Live2D controller.
*
* `follow` increases spring stiffness. `inertia` increases mass and reduces
* damping, which preserves velocity and permits more overshoot.
*/
export function createLive2DMotionSpring(initialPose: Live2DMotionControlPose = neutralLive2DMotionControlPose): Live2DMotionSpringController {
let pose = { ...initialPose }
let velocity = { ...neutralLive2DMotionControlPose }
const output = shallowRef<Live2DMotionSpringOutput>({
active: !poseIsSettled(pose, velocity, neutralLive2DMotionControlPose),
pose: { ...pose },
})
function step(control: Live2DMotionControlState, elapsedSeconds: number): Live2DMotionSpringOutput {
const target = control.active ? control.pose : neutralLive2DMotionControlPose
const stiffness = 30 + control.dynamics.follow * 190
const mass = 0.75 + control.dynamics.inertia * 2.25
const dampingRatio = 1 - control.dynamics.inertia * 0.75
const damping = 2 * dampingRatio * Math.sqrt(stiffness * mass)
const boundedElapsedSeconds = Math.min(Math.max(elapsedSeconds, 0), 0.05)
const stepCount = Math.max(1, Math.ceil(boundedElapsedSeconds / maximumStepSeconds))
const stepSeconds = boundedElapsedSeconds / stepCount
for (let stepIndex = 0; stepIndex < stepCount; stepIndex += 1) {
for (const axis of poseAxes) {
const next = stepAxis({
current: pose[axis],
target: target[axis],
velocity: velocity[axis],
elapsedSeconds: stepSeconds,
stiffness,
damping,
mass,
})
pose[axis] = next.position
velocity[axis] = next.velocity
}
}
const settled = poseIsSettled(pose, velocity, target)
if (settled) {
pose = { ...target }
velocity = { ...neutralLive2DMotionControlPose }
}
output.value = {
active: control.active || !settled,
pose: { ...pose },
}
return output.value
}
return { output, step }
}
@@ -1,11 +1,17 @@
import type { Live2DBreathControlState } from '../../stores/motion-control'
import type { MotionManagerPluginContext, PixiLive2DInternalModel } from './motion-manager'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { neutralLive2DMotionControlPose } from '../../stores/motion-control'
import { createLive2DMotionSpring } from './motion-control-spring'
import {
disableLive2DSdkBreath,
useMotionUpdatePluginAutoEyeBlink,
useMotionUpdatePluginBreathControl,
useMotionUpdatePluginIdleDisable,
useMotionUpdatePluginManualControl,
} from './motion-manager'
vi.mock('./animation', () => ({
@@ -67,6 +73,62 @@ function createContext(overrides: Partial<MotionManagerPluginContext> = {}): Mot
}
describe('live2d motion manager plugins', () => {
it('keeps SDK breath from changing AIRI-owned idle parameters', () => {
const updateParameters = vi.fn()
const internalModel = {
breath: { updateParameters },
}
// ROOT CAUSE:
//
// CubismBreath added periodic head, body, and breath offsets after AIRI's
// final motion plugins. These late writes made a controlled pose wiggle.
//
// We fixed this by removing the SDK breath owner during model setup.
disableLive2DSdkBreath(internalModel)
// The SDK runs this optional breath pass after the motion manager.
internalModel.breath?.updateParameters()
expect(updateParameters).not.toHaveBeenCalled()
})
it('applies manual breath after motion and restores the configured value on release', () => {
const context = createContext({
modelParameters: ref({
breath: 0.2,
leftEyeOpen: 1,
rightEyeOpen: 1,
}),
})
const breathControl = ref<Live2DBreathControlState>({
active: true,
ownerId: 'motion-devtool',
startedAtMs: 1_000,
options: {
cycleSeconds: 4,
exhaleDwellSeconds: 0,
minimum: 0.1,
maximum: 0.7,
inhaleRatio: 0.25,
},
})
const plugin = useMotionUpdatePluginBreathControl(breathControl, () => 2_000)
plugin(context)
expect(context.model.setParameterValueById).toHaveBeenLastCalledWith('ParamBreath', 0.7)
breathControl.value = {
...breathControl.value,
active: false,
ownerId: null,
}
plugin(context)
expect(context.model.setParameterValueById).toHaveBeenLastCalledWith('ParamBreath', 0.2)
})
/**
* @example
* expect(idleEyeFocus.update).toHaveBeenCalled()
@@ -136,6 +198,32 @@ describe('live2d motion manager plugins', () => {
expect(context.handled).toBe(true)
})
it('applies force blink after the SDK handles an idle-motion frame', () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
const context = createContext({
live2dAutoBlinkEnabled: ref(true),
live2dForceAutoBlinkEnabled: ref(true),
timeDelta: 4,
handled: true,
})
const plugin = useMotionUpdatePluginAutoEyeBlink(ref(false))
// ROOT CAUSE:
//
// The SDK marks normal idle-motion frames as handled. The final blink
// plug-in returned on handled frames, so force mode never advanced.
//
// We fixed this by letting force mode run after an SDK-handled frame.
plugin(context)
context.timeDelta = 0.075
plugin(context)
expect(context.model.getParameterValueById('ParamEyeLOpen')).toBe(0)
expect(context.model.getParameterValueById('ParamEyeROpen')).toBe(0)
randomSpy.mockRestore()
})
/**
* @example
* expect(context.model.getParameterValueById('ParamEyeLOpen')).toBeLessThan(1)
@@ -179,4 +267,59 @@ describe('live2d motion manager plugins', () => {
randomSpy.mockRestore()
})
it('springs eye, head, and body parameters toward the manual target', () => {
const context = createContext({ timeDelta: 1 / 60 })
const spring = createLive2DMotionSpring()
const plugin = useMotionUpdatePluginManualControl(ref({
active: true,
ownerId: 'motion-devtool',
pose: {
...neutralLive2DMotionControlPose,
eyeX: 0.25,
eyeY: -0.5,
headX: 0.5,
headY: -0.25,
headZ: -0.75,
bodyX: -0.5,
bodyY: 0.75,
bodyZ: 1,
mouthForm: -0.25,
},
dynamics: { follow: 0.6, inertia: 0.35 },
}), spring)
plugin(context)
const firstAngleX = context.model.getParameterValueById('ParamAngleX')
expect(firstAngleX).toBeGreaterThan(0)
expect(firstAngleX).toBeLessThan(15)
for (let frame = 0; frame < 300; frame += 1)
plugin(context)
expect(context.model.getParameterValueById('ParamEyeBallX')).toBeCloseTo(0.25)
expect(context.model.getParameterValueById('ParamEyeBallY')).toBeCloseTo(-0.5)
expect(context.model.getParameterValueById('ParamAngleX')).toBeCloseTo(15)
expect(context.model.getParameterValueById('ParamAngleY')).toBeCloseTo(-7.5)
expect(context.model.getParameterValueById('ParamAngleZ')).toBeCloseTo(-22.5)
expect(context.model.getParameterValueById('ParamBodyAngleX')).toBeCloseTo(-5)
expect(context.model.getParameterValueById('ParamBodyAngleY')).toBeCloseTo(7.5)
expect(context.model.getParameterValueById('ParamBodyAngleZ')).toBeCloseTo(10)
expect(context.model.getParameterValueById('ParamMouthForm')).toBeCloseTo(-0.25)
})
it('leaves motion parameters unchanged after manual control is released', () => {
const context = createContext()
useMotionUpdatePluginManualControl(ref({
active: false,
ownerId: null,
pose: neutralLive2DMotionControlPose,
dynamics: { follow: 0.6, inertia: 0.35 },
}), createLive2DMotionSpring())(context)
expect(context.model.setParameterValueById).not.toHaveBeenCalled()
})
})
@@ -1,15 +1,21 @@
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
import type { Ref } from 'vue'
import type { Live2DBreathControlState, Live2DMotionControlState } from '../../stores/motion-control'
import type { BeatSyncController } from './beat-sync'
import type { useExpressionController } from './expression-controller'
import type { Live2DMotionSpringController } from './motion-control-spring'
import { sampleLive2DBreath } from '../../stores/motion-control'
import { useLive2DIdleEyeFocus } from './animation'
type CubismModel = Cubism4InternalModel['coreModel']
type CubismEyeBlink = Cubism4InternalModel['eyeBlink']
/** The Pixi internal-model surface that AIRI motion plugins consume. */
export type PixiLive2DInternalModel = InternalModel & {
/** Cubism's breath controller, which AIRI removes before it applies its own curve. */
breath?: unknown
eyeBlink?: CubismEyeBlink
coreModel: CubismModel
}
@@ -53,6 +59,41 @@ export interface UseLive2DMotionManagerUpdateOptions {
lastUpdateTime: Ref<number>
}
/**
* Disables the periodic breath pass that the Cubism runtime applies after AIRI's motion plugins.
*/
export function disableLive2DSdkBreath(internalModel: { breath?: unknown }) {
delete internalModel.breath
}
/**
* Applies AIRI's manual breath curve after normal motion updates.
*
* A release restores the configured static breath value once. Normal model
* motion can own the parameter again on later frames.
*/
export function useMotionUpdatePluginBreathControl(
control: Ref<Live2DBreathControlState>,
nowMs: () => number = Date.now,
): MotionManagerPlugin {
let activeOwnerId: string | null = null
return (ctx) => {
const state = control.value
if (!state.active) {
if (activeOwnerId !== null)
ctx.model.setParameterValueById('ParamBreath', ctx.modelParameters.value.breath)
activeOwnerId = null
return
}
activeOwnerId = state.ownerId
const elapsedSeconds = Math.max(0, nowMs() - state.startedAtMs) / 1000
const sample = sampleLive2DBreath(state.options, elapsedSeconds)
ctx.model.setParameterValueById('ParamBreath', sample.value)
}
}
export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpdateOptions) {
const {
internalModel,
@@ -335,7 +376,7 @@ export function useMotionUpdatePluginAutoEyeBlink(
// logic from main so that hookUpdate returns the same handled state and
// the SDK eyeBlink/motion pipeline is not disrupted.
if (!live2dExpressionEnabled?.value) {
if (!ctx.isIdleMotion || ctx.handled)
if (!ctx.isIdleMotion || (ctx.handled && !ctx.live2dForceAutoBlinkEnabled.value))
return
const baseLeft = clamp01(ctx.modelParameters.value.leftEyeOpen)
@@ -456,6 +497,38 @@ export function useMotionUpdatePluginExpression(
}
}
/**
* Applies the active manual pose after normal Live2D motion updates.
*
* The normalized joystick range maps to each standard parameter range. Models
* that omit one of these parameters ignore that write through the Cubism API.
*/
export function useMotionUpdatePluginManualControl(
control: Ref<Live2DMotionControlState>,
spring: Live2DMotionSpringController,
): MotionManagerPlugin {
return (ctx) => {
const output = spring.step(control.value, ctx.timeDelta)
if (!output.active)
return
const { eyeX, eyeY, eyeSquint, headX, headY, headZ, bodyX, bodyY, bodyZ, mouthForm, mouthOpen } = output.pose
ctx.model.setParameterValueById('ParamEyeBallX', eyeX)
ctx.model.setParameterValueById('ParamEyeBallY', eyeY)
const remainingEyeOpen = 1 - eyeSquint
ctx.model.setParameterValueById('ParamEyeLOpen', ctx.model.getParameterValueById('ParamEyeLOpen') * remainingEyeOpen)
ctx.model.setParameterValueById('ParamEyeROpen', ctx.model.getParameterValueById('ParamEyeROpen') * remainingEyeOpen)
ctx.model.setParameterValueById('ParamAngleX', headX * 30)
ctx.model.setParameterValueById('ParamAngleY', headY * 30)
ctx.model.setParameterValueById('ParamAngleZ', headZ * 30)
ctx.model.setParameterValueById('ParamBodyAngleX', bodyX * 10)
ctx.model.setParameterValueById('ParamBodyAngleY', bodyY * 10)
ctx.model.setParameterValueById('ParamBodyAngleZ', bodyZ * 10)
ctx.model.setParameterValueById('ParamMouthForm', mouthForm)
ctx.model.setParameterValueById('ParamMouthOpenY', mouthOpen)
}
}
/**
* Final-phase plugin that owns ParamMouthOpenY while speech is active and
* smoothly cross-fades back to the motion-driven value when speech ends.
@@ -1,3 +1,4 @@
export * from './expression-store'
export * from './model-parameters'
export * from './motion-control'
export * from './view-control'
@@ -0,0 +1,102 @@
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import {
defaultLive2DBreathControlOptions,
defaultLive2DMotionControlDynamics,
getLive2DMotionControlModelOffset,
neutralLive2DMotionControlPose,
sampleLive2DBreath,
useLive2DMotionControl,
} from './motion-control'
describe('manual motion defaults', () => {
it('uses the tuned joystick spring and breath duration', () => {
expect(defaultLive2DMotionControlDynamics).toEqual({ follow: 1, inertia: 0.6 })
expect(defaultLive2DBreathControlOptions.cycleSeconds).toBe(2)
})
})
describe('exclusive motion control', () => {
it('keeps the newest devtools owner when an older owner releases control', () => {
// ROOT CAUSE:
//
// The Stage MAGIC driver did not know when the motion devtools window was open.
// Both drivers published poses until a devtools control started playback.
//
// We fixed this with an owner-scoped exclusive control claim. An old window
// cannot resume the Stage driver after a newer window takes control.
setActivePinia(createPinia())
const motionControl = useLive2DMotionControl()
motionControl.claimExclusiveControl('devtools-a')
expect(motionControl.exclusiveOwnerId).toBe('devtools-a')
motionControl.claimExclusiveControl('devtools-b')
expect(motionControl.exclusiveOwnerId).toBe('devtools-b')
motionControl.releaseExclusiveControl('devtools-a')
expect(motionControl.exclusiveOwnerId).toBe('devtools-b')
motionControl.releaseExclusiveControl('devtools-b')
expect(motionControl.exclusiveOwnerId).toBeNull()
})
})
describe('getLive2DMotionControlModelOffset', () => {
it('moves the model with the active joystick pose', () => {
// ROOT CAUSE:
//
// The model offset used the normal cursor position instead of the manual
// motion-control pose. Joystick recordings and playback did not move the
// model because they only publish manual motion-control events.
expect(getLive2DMotionControlModelOffset({
active: true,
pose: { ...neutralLive2DMotionControlPose, offsetX: 1, offsetY: 1 },
})).toEqual({ x: 20, y: -40 })
})
it('does not offset the model after joystick release', () => {
expect(getLive2DMotionControlModelOffset({
active: false,
pose: { ...neutralLive2DMotionControlPose, offsetX: 1, offsetY: 1 },
})).toEqual({ x: 0, y: 0 })
})
})
describe('sampleLive2DBreath', () => {
it('uses sine-like inhale and exhale spans, then holds the exhaled state', () => {
const options = {
...defaultLive2DBreathControlOptions,
cycleSeconds: 4,
exhaleDwellSeconds: 2,
minimum: 0.1,
maximum: 0.7,
inhaleRatio: 0.25,
}
expect(sampleLive2DBreath(options, 0)).toMatchObject({ phase: 0, value: 0.1, stage: 'inhale' })
expect(sampleLive2DBreath(options, 1)).toMatchObject({ phase: 1 / 6, value: 0.7, stage: 'exhale' })
expect(sampleLive2DBreath(options, 2.5).value).toBeCloseTo(0.4)
expect(sampleLive2DBreath(options, 4)).toMatchObject({ phase: 4 / 6, value: 0.1, stage: 'dwell' })
expect(sampleLive2DBreath(options, 5.5)).toMatchObject({ phase: 5.5 / 6, value: 0.1, stage: 'dwell' })
expect(sampleLive2DBreath(options, 6)).toMatchObject({ phase: 0, value: 0.1, stage: 'inhale' })
})
it('keeps the output inside the configured range', () => {
const options = {
...defaultLive2DBreathControlOptions,
cycleSeconds: 0,
exhaleDwellSeconds: Number.POSITIVE_INFINITY,
minimum: 0.8,
maximum: 0.2,
inhaleRatio: 1,
}
for (let elapsedSeconds = 0; elapsedSeconds < 30; elapsedSeconds += 0.1) {
const sample = sampleLive2DBreath(options, elapsedSeconds)
expect(sample.value).toBeGreaterThanOrEqual(0.8)
expect(sample.value).toBeLessThanOrEqual(0.8)
}
})
})
@@ -0,0 +1,392 @@
import type { Pose } from '@proj-airi/model-driver-magic-live2d'
import { neutralPose } from '@proj-airi/model-driver-magic-live2d'
import { useBroadcastChannel } from '@vueuse/core'
import { defineStore } from 'pinia'
import { nextTick, shallowRef, watch } from 'vue'
/** A normalized pose for manual Live2D motion control. */
export type Live2DMotionControlPose = Pose
/** Spring settings for manual Live2D motion. */
export interface Live2DMotionControlDynamics {
/** Target-following strength from 0 (soft) to 2 (very fast). @default 1 */
follow: number
/** Preserved momentum from 0 (settled) to 1 (bouncy). @default 0.6 */
inertia: number
}
/** The active manual control owner, target pose, and spring settings. */
export interface Live2DMotionControlState {
active: boolean
ownerId: string | null
pose: Live2DMotionControlPose
dynamics: Live2DMotionControlDynamics
}
/** Settings for the manual render-time breath curve. */
export interface Live2DBreathControlOptions {
/** Length of the inhale and exhale spans, in seconds. @default 2 */
cycleSeconds: number
/** Time held at the minimum after each exhale, in seconds. @default 1.2 */
exhaleDwellSeconds: number
/** Lowest value written to `ParamBreath`. @default 0 */
minimum: number
/** Highest value written to `ParamBreath`. @default 0.5 */
maximum: number
/** Fraction of the inhale and exhale spans used to inhale. @default 0.4 */
inhaleRatio: number
}
/** The active manual breath owner and its shared phase origin. */
export interface Live2DBreathControlState {
active: boolean
ownerId: string | null
startedAtMs: number
options: Live2DBreathControlOptions
}
/** One sampled point from the manual breath curve. */
export interface Live2DBreathSample {
phase: number
stage: 'inhale' | 'exhale' | 'dwell'
value: number
}
type Live2DMotionControlEvent
= | {
type: 'live2d-motion-control-set'
ownerId: string
pose: Live2DMotionControlPose
dynamics: Live2DMotionControlDynamics
}
| {
type: 'live2d-motion-control-release'
ownerId: string
}
| {
type: 'live2d-breath-control-set'
ownerId: string
startedAtMs: number
options: Live2DBreathControlOptions
}
| {
type: 'live2d-breath-control-release'
ownerId: string
}
| {
type: 'live2d-motion-control-claim-exclusive'
ownerId: string
}
| {
type: 'live2d-motion-control-release-exclusive'
ownerId: string
}
export const neutralLive2DMotionControlPose: Live2DMotionControlPose = neutralPose
/** Default settings for the manual Live2D breath curve. */
export const defaultLive2DBreathControlOptions: Live2DBreathControlOptions = Object.freeze({
cycleSeconds: 2,
exhaleDwellSeconds: 1.2,
minimum: 0,
maximum: 0.5,
inhaleRatio: 0.4,
})
const horizontalModelOffset = 20
/** Default spring settings for the Live2D motion devtool. */
export const defaultLive2DMotionControlDynamics: Live2DMotionControlDynamics = Object.freeze({ follow: 1, inertia: 0.6 })
function clampAxis(value: number): number {
return Math.min(1, Math.max(-1, value))
}
function clampUnit(value: number): number {
return Math.min(1, Math.max(0, value))
}
function clampFollow(value: number): number {
return Math.min(2, Math.max(0, value))
}
function normalizePose(pose: Live2DMotionControlPose): Live2DMotionControlPose {
return {
eyeX: clampAxis(pose.eyeX),
eyeY: clampAxis(pose.eyeY),
eyeSquint: clampUnit(pose.eyeSquint),
headX: clampAxis(pose.headX),
headY: clampAxis(pose.headY),
headZ: clampAxis(pose.headZ),
bodyX: clampAxis(pose.bodyX),
bodyY: clampAxis(pose.bodyY),
bodyZ: clampAxis(pose.bodyZ),
mouthForm: clampAxis(pose.mouthForm),
mouthOpen: clampUnit(pose.mouthOpen),
offsetX: clampAxis(pose.offsetX),
offsetY: clampAxis(pose.offsetY),
}
}
function normalizeDynamics(dynamics: Live2DMotionControlDynamics): Live2DMotionControlDynamics {
return {
follow: clampFollow(dynamics.follow),
inertia: clampUnit(dynamics.inertia),
}
}
function normalizeBreathOptions(options: Live2DBreathControlOptions): Live2DBreathControlOptions {
const cycleSeconds = Number.isFinite(options.cycleSeconds)
? Math.min(30, Math.max(0.5, options.cycleSeconds))
: defaultLive2DBreathControlOptions.cycleSeconds
const exhaleDwellSeconds = Number.isFinite(options.exhaleDwellSeconds)
? Math.min(30, Math.max(0, options.exhaleDwellSeconds))
: defaultLive2DBreathControlOptions.exhaleDwellSeconds
const minimum = Number.isFinite(options.minimum)
? clampUnit(options.minimum)
: defaultLive2DBreathControlOptions.minimum
const requestedMaximum = Number.isFinite(options.maximum)
? clampUnit(options.maximum)
: defaultLive2DBreathControlOptions.maximum
const inhaleRatio = Number.isFinite(options.inhaleRatio)
? Math.min(0.9, Math.max(0.1, options.inhaleRatio))
: defaultLive2DBreathControlOptions.inhaleRatio
return {
cycleSeconds,
exhaleDwellSeconds,
minimum,
maximum: Math.max(minimum, requestedMaximum),
inhaleRatio,
}
}
/**
* Samples the manual breath curve.
*
* The curve uses one half-cosine for the inhale and one for the exhale. It
* then holds the minimum value before the next inhale. The separate spans keep
* both transitions smooth when the inhale ratio changes.
*/
export function sampleLive2DBreath(options: Live2DBreathControlOptions, elapsedSeconds: number): Live2DBreathSample {
const normalized = normalizeBreathOptions(options)
const safeElapsedSeconds = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0
const repeatSeconds = normalized.cycleSeconds + normalized.exhaleDwellSeconds
const elapsedInRepeat = safeElapsedSeconds % repeatSeconds
const phase = elapsedInRepeat / repeatSeconds
if (elapsedInRepeat >= normalized.cycleSeconds) {
return {
phase,
stage: 'dwell',
value: normalized.minimum,
}
}
const breathPhase = elapsedInRepeat / normalized.cycleSeconds
if (breathPhase < normalized.inhaleRatio) {
const inhaleProgress = breathPhase / normalized.inhaleRatio
const curve = (1 - Math.cos(Math.PI * inhaleProgress)) / 2
return {
phase,
stage: 'inhale',
value: normalized.minimum + (normalized.maximum - normalized.minimum) * curve,
}
}
const exhaleProgress = (breathPhase - normalized.inhaleRatio) / (1 - normalized.inhaleRatio)
const curve = (1 + Math.cos(Math.PI * exhaleProgress)) / 2
return {
phase,
stage: 'exhale',
value: normalized.minimum + (normalized.maximum - normalized.minimum) * curve,
}
}
/**
* Maps active joystick motion to the Pixi model position.
*
* The joystick Y axis points up, while the Pixi Y axis points down.
*
* @example
* getLive2DMotionControlModelOffset({
* active: true,
* pose: { ...neutralLive2DMotionControlPose, offsetX: 1, offsetY: 1 },
* })
* // => { x: 20, y: -40 }
*/
export function getLive2DMotionControlModelOffset(control: Pick<Live2DMotionControlState, 'active' | 'pose'>): { x: number, y: number } {
if (!control.active)
return { x: 0, y: 0 }
return {
x: control.pose.offsetX * horizontalModelOffset,
y: -control.pose.offsetY * horizontalModelOffset * 2,
}
}
/**
* Shares transient manual Live2D motion and exclusive control between renderer windows.
*
* The latest set event owns the control. A release event only clears the same
* owner, so a stale window cannot release a newer controller. An exclusive
* claim pauses the production motion driver while a devtools window controls
* the model. This store uses a direct channel because joystick updates are
* transient and high frequency.
*/
export const useLive2DMotionControl = defineStore('live2d-motion-control', () => {
const { channel, data, post } = useBroadcastChannel<Live2DMotionControlEvent, Live2DMotionControlEvent>({
name: 'airi-stores-stage-ui-live2d-motion-control',
})
const control = shallowRef<Live2DMotionControlState>({
active: false,
ownerId: null,
pose: neutralLive2DMotionControlPose,
dynamics: defaultLive2DMotionControlDynamics,
})
const breathControl = shallowRef<Live2DBreathControlState>({
active: false,
ownerId: null,
startedAtMs: 0,
options: defaultLive2DBreathControlOptions,
})
const exclusiveOwnerId = shallowRef<string | null>(null)
function applyEvent(event: Live2DMotionControlEvent) {
if (event.type === 'live2d-motion-control-set') {
control.value = {
active: true,
ownerId: event.ownerId,
pose: normalizePose(event.pose),
dynamics: normalizeDynamics(event.dynamics),
}
return
}
if (event.type === 'live2d-motion-control-release') {
if (control.value.ownerId !== event.ownerId)
return
control.value = {
active: false,
ownerId: null,
pose: neutralLive2DMotionControlPose,
dynamics: control.value.dynamics,
}
return
}
if (event.type === 'live2d-breath-control-set') {
breathControl.value = {
active: true,
ownerId: event.ownerId,
startedAtMs: event.startedAtMs,
options: normalizeBreathOptions(event.options),
}
return
}
if (event.type === 'live2d-breath-control-release') {
if (breathControl.value.ownerId !== event.ownerId)
return
breathControl.value = {
active: false,
ownerId: null,
startedAtMs: 0,
options: breathControl.value.options,
}
return
}
if (event.type === 'live2d-motion-control-claim-exclusive') {
exclusiveOwnerId.value = event.ownerId
return
}
if (exclusiveOwnerId.value === event.ownerId)
exclusiveOwnerId.value = null
}
function setPose(ownerId: string, pose: Live2DMotionControlPose, dynamics: Live2DMotionControlDynamics) {
const event: Live2DMotionControlEvent = {
type: 'live2d-motion-control-set',
ownerId,
pose: normalizePose(pose),
dynamics: normalizeDynamics(dynamics),
}
applyEvent(event)
post(event)
}
function release(ownerId: string) {
const event: Live2DMotionControlEvent = {
type: 'live2d-motion-control-release',
ownerId,
}
applyEvent(event)
post(event)
}
function setBreath(ownerId: string, options: Live2DBreathControlOptions, startedAtMs = Date.now()) {
const event: Live2DMotionControlEvent = {
type: 'live2d-breath-control-set',
ownerId,
startedAtMs,
options: normalizeBreathOptions(options),
}
applyEvent(event)
post(event)
}
function releaseBreath(ownerId: string) {
const event: Live2DMotionControlEvent = {
type: 'live2d-breath-control-release',
ownerId,
}
applyEvent(event)
post(event)
}
function claimExclusiveControl(ownerId: string) {
const event: Live2DMotionControlEvent = {
type: 'live2d-motion-control-claim-exclusive',
ownerId,
}
applyEvent(event)
if (channel.value) {
post(event)
return
}
void nextTick(() => {
if (exclusiveOwnerId.value === ownerId)
post(event)
})
}
function releaseExclusiveControl(ownerId: string) {
const event: Live2DMotionControlEvent = {
type: 'live2d-motion-control-release-exclusive',
ownerId,
}
applyEvent(event)
post(event)
}
watch(data, (event) => {
if (event)
applyEvent(event)
})
return {
control,
breathControl,
exclusiveOwnerId,
setPose,
release,
setBreath,
releaseBreath,
claimExclusiveControl,
releaseExclusiveControl,
}
})
+6
View File
@@ -27,6 +27,8 @@
"./constants/*": "./src/constants/*.ts",
"./constants": "./src/constants/index.ts",
"./directives/*": "./src/directives/*.ts",
"./features/devtools/motion/live2d": "./src/features/devtools/motion/live2d/index.ts",
"./features/motions/live2d": "./src/features/motions/live2d/index.ts",
"./libs/inference/adapters/*": "./src/libs/inference/adapters/*.ts",
"./libs/inference": "./src/libs/inference/index.ts",
"./libs/analytics": "./src/libs/analytics/index.ts",
@@ -88,7 +90,10 @@
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/input-gamepad": "workspace:^",
"@proj-airi/model-driver-lipsync": "workspace:^",
"@proj-airi/model-driver-magic-live2d": "workspace:^",
"@proj-airi/motion-driver-magic": "workspace:^",
"@proj-airi/pipelines-audio": "workspace:^",
"@proj-airi/server-sdk": "workspace:^",
"@proj-airi/server-sdk-shared": "workspace:^",
@@ -127,6 +132,7 @@
"culori": "catalog:",
"d3": "catalog:",
"date-fns": "catalog:",
"dockview-vue": "catalog:",
"dompurify": "catalog:",
"embla-carousel-autoplay": "catalog:",
"embla-carousel-vue": "catalog:",
@@ -1,4 +1,7 @@
<script setup lang="ts">
import type { Live2DMotionDriver } from '@proj-airi/stage-ui-live2d'
import type { SelectTabOption } from '@proj-airi/ui'
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { defaultModelParameters, useExpressionStore, useLive2dParams, useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
@@ -8,6 +11,8 @@ import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import MagicMotionSettings from '../../../../features/motions/live2d/components/magic-settings.vue'
import { PropertyPoint } from '../../../data-pane'
import { Section } from '../../../layouts'
import { ColorPalette } from '../../../widgets'
@@ -27,6 +32,7 @@ const { t } = useI18n()
const settings = useSettingsLive2d()
const {
live2dMotionDriver,
live2dEyeTracking,
live2dModelEyeOffset,
live2dIdleAnimationEnabled,
@@ -39,6 +45,19 @@ const {
live2dForceIdleEyeAnimation,
} = storeToRefs(settings)
const motionDriverOptions = computed<SelectTabOption<Live2DMotionDriver>[]>(() => [
{
value: 'universal',
label: t('settings.live2d.animation.motion-driver.options.universal.title'),
description: t('settings.live2d.animation.motion-driver.options.universal.description'),
},
{
value: 'magic',
label: t('settings.live2d.animation.motion-driver.options.magic.title'),
description: t('settings.live2d.animation.motion-driver.options.magic.description'),
},
])
const live2d = useLive2dParams()
const {
scale,
@@ -338,13 +357,31 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
size="sm"
:expand="false"
>
<label :class="['flex flex-wrap gap-4']">
<div :class="['min-w-0 flex-1']">
<div :class="['flex items-center gap-1 text-sm font-medium']">
{{ t('settings.live2d.animation.motion-driver.title') }}
</div>
<div :class="['text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('settings.live2d.animation.motion-driver.description') }}
</div>
</div>
<SelectTab
v-model="live2dMotionDriver"
:options="motionDriverOptions"
size="sm"
:class="['shrink-0']"
/>
</label>
<MagicMotionSettings v-if="live2dMotionDriver === 'magic'" />
<FieldCheckbox
v-model="live2dEyeTracking"
:label="t('settings.live2d.animation.focus.title')"
:description="t('settings.live2d.animation.focus.description')"
:disabled="live2dMotionDriver === 'magic'"
placement="right"
/>
<div v-if="live2dEyeTracking" class="grid grid-cols-4">
<div v-if="live2dMotionDriver === 'universal' && live2dEyeTracking" :class="['grid grid-cols-4']">
<PropertyPoint
v-model:x="live2dModelEyeOffset.x"
v-model:y="live2dModelEyeOffset.y"
@@ -363,6 +400,7 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
v-model="live2dForceIdleEyeAnimation"
:label="t('settings.live2d.animation.force-idle-eye-animation.title')"
:description="t('settings.live2d.animation.force-idle-eye-animation.description')"
:disabled="live2dMotionDriver === 'magic'"
placement="right"
/>
<FieldCheckbox
@@ -389,6 +427,7 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
:placeholder="t('settings.live2d.animation.idle-motion.placeholder')"
:select-class="['w-full']"
:content-min-width="256"
:disabled="live2dMotionDriver === 'magic'"
@update:model-value="handleMotionSelect"
>
<template #empty>
@@ -14,7 +14,7 @@ import { sleep } from '@moeru/std'
import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from '@proj-airi/pipelines-audio'
import { Live2DScene, useLive2dParams, useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
import { defaultLive2DMotionControlDynamics, Live2DScene, useLive2DMotionControl, useLive2dParams, useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
import { MMDScene } from '@proj-airi/stage-ui-mmd'
import { SpineScene } from '@proj-airi/stage-ui-spine'
import { TachieScene } from '@proj-airi/stage-ui-tachie'
@@ -35,6 +35,7 @@ import { useDuckDb } from '../../composables/use-duck-db'
import { useIOTraceBridge } from '../../composables/use-io-trace-bridge'
import { initIOTracer } from '../../composables/use-io-tracer'
import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { live2dMotionMagicProfiles, useLive2DMotionMagic, useLive2DMotionMagicSettings } from '../../features/motions/live2d'
import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers'
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
@@ -83,10 +84,54 @@ const {
} = storeToRefs(settingsStore)
const {
live2dMotionDriver,
live2dShadowEnabled,
live2dMaxFps,
live2dRenderScale,
} = storeToRefs(useSettingsLive2d())
const live2dMotionControl = useLive2DMotionControl()
const { exclusiveOwnerId: live2dMotionControlOwnerId } = storeToRefs(live2dMotionControl)
const {
forceViewTarget: live2dMagicForceViewTarget,
profileId: live2dMagicProfileId,
skipMouthOpen: live2dMagicSkipMouthOpen,
} = storeToRefs(useLive2DMotionMagicSettings())
const live2dMagicMotion = useLive2DMotionMagic({
dataset: () => live2dMotionMagicProfiles[live2dMagicProfileId.value].dataset,
forceViewTarget: live2dMagicForceViewTarget,
skipMouthOpen: live2dMagicSkipMouthOpen,
disabled: () => live2dMotionControlOwnerId.value !== null,
publishPose: pose => live2dMotionControl.setPose('stage:live2d-motion-magic', pose, defaultLive2DMotionControlDynamics),
releasePose: () => live2dMotionControl.release('stage:live2d-motion-magic'),
})
let live2dMagicActivationRequest = 0
watch(
[stageModelRenderer, live2dMotionDriver, live2dMagicProfileId, () => props.paused, live2dMotionControlOwnerId],
async ([renderer, driver, , paused, controlOwnerId]) => {
const request = ++live2dMagicActivationRequest
if (renderer !== 'live2d' || driver !== 'magic' || paused || controlOwnerId !== null) {
live2dMagicMotion.stop()
return
}
if (live2dMagicMotion.status.value === 'idle')
await live2dMagicMotion.initialize()
if (
request !== live2dMagicActivationRequest
|| stageModelRenderer.value !== 'live2d'
|| live2dMotionDriver.value !== 'magic'
|| props.paused
|| live2dMotionControlOwnerId.value !== null
) {
return
}
live2dMagicMotion.start()
},
{ immediate: true },
)
const {
spinePremultipliedAlpha,
spineDefaultMixDuration,
@@ -4,6 +4,7 @@ import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useLive2dParams, useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
import { useModelStore } from '@proj-airi/stage-ui-three'
import { useLive2DMotionMagicSettings } from '../features/motions/live2d'
import { useChatStore } from '../stores/chat'
import { useChatSessionStore } from '../stores/chat/session-store'
import { useDisplayModelsStore } from '../stores/display-models'
@@ -31,6 +32,7 @@ export function useDataMaintenance() {
const audioSettingsStore = useSettingsAudioDevice()
const live2dParamsStore = useLive2dParams()
const live2dSettingsStore = useSettingsLive2d()
const live2dMagicSettingsStore = useLive2DMotionMagicSettings()
const threeStore = useModelStore()
const hearingStore = useHearingStore()
const speechStore = useSpeechStore()
@@ -95,6 +97,7 @@ export function useDataMaintenance() {
audioSettingsStore.resetState()
live2dParamsStore.resetState()
live2dSettingsStore.resetState()
live2dMagicSettingsStore.resetState()
threeStore.resetModelStore()
mcpStore.resetState()
onboardingStore.resetSetupState()
@@ -0,0 +1,187 @@
<script setup lang="ts">
import type { Live2DBreathControlOptions } from '@proj-airi/stage-ui-live2d/stores'
import { defaultLive2DBreathControlOptions, sampleLive2DBreath } from '@proj-airi/stage-ui-live2d/stores'
import { BasicButton, FieldRange } from '@proj-airi/ui'
import { useRafFn } from '@vueuse/core'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
enabled: boolean
options: Live2DBreathControlOptions
startedAtMs: number
}>()
const emit = defineEmits<{
updateEnabled: [enabled: boolean]
updateOptions: [options: Live2DBreathControlOptions]
reset: []
}>()
const { t } = useI18n()
const nowMs = shallowRef(Date.now())
useRafFn(() => {
nowMs.value = Date.now()
}, { fpsLimit: 15 })
const cycleSeconds = computed({
get: () => props.options.cycleSeconds,
set: value => updateOptions({ cycleSeconds: value }),
})
const exhaleDwellSeconds = computed({
get: () => props.options.exhaleDwellSeconds,
set: value => updateOptions({ exhaleDwellSeconds: value }),
})
const minimum = computed({
get: () => props.options.minimum,
set: value => updateOptions({ minimum: value }),
})
const maximum = computed({
get: () => props.options.maximum,
set: value => updateOptions({ maximum: value }),
})
const inhaleRatio = computed({
get: () => props.options.inhaleRatio,
set: value => updateOptions({ inhaleRatio: value }),
})
const sample = computed(() => sampleLive2DBreath(
props.options,
Math.max(0, nowMs.value - props.startedAtMs) / 1000,
))
function updateOptions(patch: Partial<Live2DBreathControlOptions>) {
emit('updateOptions', { ...props.options, ...patch })
}
function formatSeconds(value: number): string {
return `${value.toFixed(1)}s`
}
function formatBreathValue(value: number): string {
return value.toFixed(2)
}
function formatRatio(value: number): string {
return `${Math.round(value * 100)}%`
}
</script>
<template>
<section :class="['rounded-xl bg-primary-50/65 p-4 dark:bg-primary-950/25']">
<div :class="['flex flex-wrap items-start justify-between gap-3']">
<div>
<h3 :class="['mb-1 font-medium text-neutral-900 dark:text-neutral-100']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.breath.title') }}
</h3>
<p :class="['max-w-3xl text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.breath.description') }}
</p>
</div>
<div :class="['flex flex-wrap items-center gap-1']">
<BasicButton @click="emit('updateEnabled', !props.enabled)">
<span :class="[props.enabled ? 'i-mingcute:wind-fill' : 'i-mingcute:wind-line']" />
{{ props.enabled
? t('tamagotchi.settings.devtools.pages.live2d-motion.breath.actions.disable')
: t('tamagotchi.settings.devtools.pages.live2d-motion.breath.actions.enable') }}
</BasicButton>
<BasicButton @click="emit('reset')">
<span :class="['i-mingcute:refresh-anticlockwise-1-line']" />
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.breath.actions.reset') }}
</BasicButton>
</div>
</div>
<div :class="['mt-4 grid gap-4', 'md:grid-cols-2']">
<FieldRange
v-model="cycleSeconds"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.cycle.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.cycle.description')"
:min="0.5"
:max="15"
:step="0.1"
:default-value="defaultLive2DBreathControlOptions.cycleSeconds"
:format-value="formatSeconds"
as="div"
/>
<FieldRange
v-model="inhaleRatio"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.inhale.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.inhale.description')"
:min="0.1"
:max="0.9"
:step="0.01"
:default-value="defaultLive2DBreathControlOptions.inhaleRatio"
:format-value="formatRatio"
as="div"
/>
<FieldRange
v-model="exhaleDwellSeconds"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.dwell.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.dwell.description')"
:min="0"
:max="8"
:step="0.1"
:default-value="defaultLive2DBreathControlOptions.exhaleDwellSeconds"
:format-value="formatSeconds"
as="div"
/>
<FieldRange
v-model="minimum"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.minimum.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.minimum.description')"
:min="0"
:max="1"
:step="0.01"
:default-value="defaultLive2DBreathControlOptions.minimum"
:format-value="formatBreathValue"
as="div"
/>
<FieldRange
v-model="maximum"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.maximum.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.breath.maximum.description')"
:min="0"
:max="1"
:step="0.01"
:default-value="defaultLive2DBreathControlOptions.maximum"
:format-value="formatBreathValue"
as="div"
/>
</div>
<div :class="['mt-4 grid gap-2 text-xs', 'sm:grid-cols-2 xl:grid-cols-4']">
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.breath.diagnostics.stage') }}
</div>
<div :class="['mt-1 font-mono text-neutral-700 dark:text-neutral-200']">
{{ props.enabled ? t(`tamagotchi.settings.devtools.pages.live2d-motion.breath.stage.${sample.stage}`) : '—' }}
</div>
</div>
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.breath.diagnostics.phase') }}
</div>
<div :class="['mt-1 font-mono tabular-nums text-neutral-700 dark:text-neutral-200']">
{{ props.enabled ? formatRatio(sample.phase) : '—' }}
</div>
</div>
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.breath.diagnostics.output') }}
</div>
<div :class="['mt-1 font-mono tabular-nums text-neutral-700 dark:text-neutral-200']">
{{ props.enabled ? formatBreathValue(sample.value) : '—' }}
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import type { Live2DMotionViewTargetState } from '../../../../motions/live2d'
import { BasicButton, FieldRange } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
pose: Live2DMotionControlPose
view: Live2DMotionViewTargetState
}>()
const emit = defineEmits<{
updateView: [view: Live2DMotionViewTargetState]
}>()
const { t } = useI18n()
const targetStyle = computed(() => ({
left: `${50 + props.view.x * 40}%`,
top: `${50 - props.view.y * 40}%`,
}))
const counterStrength = computed({
get: () => props.view.counterStrength,
set: value => updateView({ counterStrength: value }),
})
const formatStrength = (value: number) => `${value.toFixed(2)}×`
function updateView(patch: Partial<Live2DMotionViewTargetState>) {
emit('updateView', { ...props.view, ...patch })
}
function setTargetFromPointer(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const halfWidth = bounds.width / 2
const halfHeight = bounds.height / 2
updateView({
x: Math.min(1, Math.max(-1, (event.clientX - bounds.left - halfWidth) / halfWidth)),
y: Math.min(1, Math.max(-1, (bounds.top + halfHeight - event.clientY) / halfHeight)),
})
}
function handlePointerDown(event: PointerEvent) {
if (!event.isPrimary || event.button !== 0)
return
const target = event.currentTarget as HTMLElement
target.setPointerCapture(event.pointerId)
setTargetFromPointer(event)
}
function handlePointerMove(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
if (!target.hasPointerCapture(event.pointerId))
return
setTargetFromPointer(event)
}
function handlePointerEnd(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId))
target.releasePointerCapture(event.pointerId)
}
</script>
<template>
<section :class="['rounded-xl bg-primary-50/55 p-4 dark:bg-primary-950/20']">
<div :class="['flex flex-wrap items-start justify-between gap-3']">
<div>
<h3 :class="['font-semibold text-neutral-900 dark:text-neutral-100']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.title') }}
</h3>
<p :class="['mt-1 max-w-2xl text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.description') }}
</p>
</div>
<div :class="['flex flex-wrap gap-2']">
<BasicButton @click="updateView({ enabled: !props.view.enabled })">
<span :class="[props.view.enabled ? 'i-mingcute:eye-fill' : 'i-mingcute:eye-close-line', 'mr-1.5 size-4']" />
{{ props.view.enabled
? t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.actions.disable')
: t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.actions.enable') }}
</BasicButton>
<BasicButton @click="updateView({ x: 0, y: 0 })">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.actions.center') }}
</BasicButton>
</div>
</div>
<div :class="['eye-view-layout mt-4 grid gap-4']">
<BasicButton
type="button"
size="unset"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.pad-label')"
:disabled="!props.view.enabled"
:class="[
'relative h-52 w-full touch-none select-none overflow-hidden rounded-xl',
'border border-neutral-300/80 dark:border-neutral-700/80',
'bg-white/70 dark:bg-neutral-950/50',
'shadow-inner active:scale-100!',
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerEnd"
@pointercancel="handlePointerEnd"
@contextmenu.prevent
>
<span :class="['pointer-events-none absolute inset-x-4 top-1/2 h-px', 'bg-neutral-300/80 dark:bg-neutral-700/80']" />
<span :class="['pointer-events-none absolute inset-y-4 left-1/2 w-px', 'bg-neutral-300/80 dark:bg-neutral-700/80']" />
<span
:style="targetStyle"
:class="[
'pointer-events-none absolute size-10 rounded-full -translate-x-1/2 -translate-y-1/2',
'border-2 border-primary-300 bg-primary-500/90 text-white dark:border-primary-500',
'shadow-lg shadow-primary-500/20',
]"
>
<span :class="['i-mingcute:target-fill', 'absolute inset-2']" />
</span>
</BasicButton>
<div :class="['flex flex-col gap-4']">
<FieldRange
v-model="counterStrength"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.counter.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.counter.description')"
:min="0"
:max="2"
:step="0.01"
:default-value="1"
:format-value="formatStrength"
as="div"
/>
<dl :class="['eye-view-values grid gap-2 rounded-xl bg-white/70 p-3 text-sm dark:bg-neutral-950/40']">
<div :class="['flex items-center justify-between gap-3']">
<dt :class="['text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.values.target') }} X
</dt>
<dd :class="['font-mono tabular-nums text-neutral-800 dark:text-neutral-100']">
{{ props.view.x.toFixed(2) }}
</dd>
</div>
<div :class="['flex items-center justify-between gap-3']">
<dt :class="['text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.values.target') }} Y
</dt>
<dd :class="['font-mono tabular-nums text-neutral-800 dark:text-neutral-100']">
{{ props.view.y.toFixed(2) }}
</dd>
</div>
<div :class="['flex items-center justify-between gap-3']">
<dt :class="['text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.values.output') }} X
</dt>
<dd :class="['font-mono tabular-nums text-neutral-800 dark:text-neutral-100']">
{{ props.pose.eyeX.toFixed(2) }}
</dd>
</div>
<div :class="['flex items-center justify-between gap-3']">
<dt :class="['text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.eye-view.values.output') }} Y
</dt>
<dd :class="['font-mono tabular-nums text-neutral-800 dark:text-neutral-100']">
{{ props.pose.eyeY.toFixed(2) }}
</dd>
</div>
</dl>
</div>
</div>
</section>
</template>
<style scoped>
.eye-view-layout {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.eye-view-values {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
}
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
interface Live2DMotionInputHint {
action: string
controller?: string
keyboard?: string
}
const props = defineProps<{
controllerLabel: string
hints: readonly Live2DMotionInputHint[]
keyboardLabel: string
}>()
</script>
<template>
<dl :class="['grid overflow-hidden rounded-xl bg-neutral-100/75 text-xs dark:bg-neutral-900/55']">
<div
v-for="hint in props.hints"
:key="hint.action"
:class="[
'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2',
'not-last:border-b not-last:border-neutral-200/65 dark:not-last:border-neutral-800/70',
]"
>
<dt :class="['min-w-0 leading-tight text-neutral-600 dark:text-neutral-300']">
{{ hint.action }}
</dt>
<dd :class="['flex flex-wrap items-center justify-end gap-1.5']">
<span
v-if="hint.keyboard"
:title="props.keyboardLabel"
:class="['inline-flex items-center gap-1 rounded-md bg-white/75 px-1.5 py-1 font-mono text-[10px] text-neutral-600 dark:bg-neutral-800/80 dark:text-neutral-300']"
>
<span :class="['i-mingcute:keyboard-line size-3.5']" />
{{ hint.keyboard }}
</span>
<span
v-if="hint.controller"
:title="props.controllerLabel"
:class="['inline-flex items-center gap-1 rounded-md bg-primary-100/80 px-1.5 py-1 font-mono text-[10px] text-primary-800 dark:bg-primary-900/45 dark:text-primary-200']"
>
<span :class="['i-mingcute:game-2-line size-3.5']" />
{{ hint.controller }}
</span>
</dd>
</div>
</dl>
</template>
@@ -0,0 +1,320 @@
// @vitest-environment jsdom
import type { StandardGamepadButtonName, StandardGamepadButtonState, StandardGamepadSnapshot } from '@proj-airi/input-gamepad'
import type { Live2DMotionControlDynamics, Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, shallowRef } from 'vue'
import Joystick from './joystick.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
const neutralPose = neutralLive2DMotionControlPose
const defaultDynamics: Live2DMotionControlDynamics = {
follow: 0.6,
inertia: 0.35,
}
describe('joystick', () => {
afterEach(() => {
vi.useRealTimers()
})
function mountJoystick(
move: (pose: Live2DMotionControlPose) => void,
release: () => void,
pose: Live2DMotionControlPose = neutralPose,
updateDynamics: (dynamics: Live2DMotionControlDynamics) => void = () => {},
) {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp({
render: () => h(Joystick, {
pose,
dynamics: defaultDynamics,
onMove: move,
onRelease: release,
onUpdateDynamics: updateDynamics,
}),
})
app.mount(host)
return {
app,
button: host.querySelector('button')!,
host,
}
}
it('emits follow and inertia slider changes', async () => {
const updateDynamics = vi.fn<(dynamics: Live2DMotionControlDynamics) => void>()
const mounted = mountJoystick(vi.fn(), vi.fn(), neutralPose, updateDynamics)
const sliders = mounted.host.querySelectorAll<HTMLInputElement>('input[type="range"]')
expect(sliders).toHaveLength(2)
expect(sliders[0].max).toBe('20000')
expect(sliders[1].max).toBe('10000')
sliders[0].value = '18000'
sliders[0].dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(updateDynamics).toHaveBeenLastCalledWith({ follow: 1.8, inertia: 0.35 })
sliders[1].value = '7000'
sliders[1].dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(updateDynamics).toHaveBeenLastCalledWith({ follow: 0.6, inertia: 0.7 })
mounted.app.unmount()
mounted.host.remove()
})
it('starts pointer control without using removed keyboard state', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const mounted = mountJoystick(move, vi.fn())
mounted.button.setPointerCapture = vi.fn()
vi.spyOn(mounted.button, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 200,
height: 200,
right: 200,
bottom: 200,
x: 0,
y: 0,
toJSON: () => ({}),
})
const event = new MouseEvent('pointerdown', {
bubbles: true,
button: 0,
clientX: 150,
clientY: 50,
})
Object.defineProperties(event, {
isPrimary: { value: true },
pointerId: { value: 1 },
})
expect(() => mounted.button.dispatchEvent(event)).not.toThrow()
expect(move).toHaveBeenCalledWith(expect.objectContaining({ headX: 0.5, headY: 0.5 }))
mounted.app.unmount()
mounted.host.remove()
})
it('maps A and Q to left body X and head roll targets', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const release = vi.fn()
const mounted = mountJoystick(move, release)
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }))
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'q', bubbles: true }))
expect(move.mock.lastCall?.[0]).toEqual({ ...neutralPose, headZ: -1, bodyX: -1 })
mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'a', bubbles: true }))
mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'q', bubbles: true }))
expect(release).toHaveBeenCalledOnce()
mounted.app.unmount()
mounted.host.remove()
})
it('maps D and E to right body X and head roll', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const mounted = mountJoystick(move, vi.fn())
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'D', bubbles: true }))
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'E', bubbles: true }))
expect(move.mock.lastCall?.[0]).toEqual({ ...neutralPose, headZ: 1, bodyX: 1 })
mounted.app.unmount()
mounted.host.remove()
})
it('keeps body X coupled to position keys when A and D are idle', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const mounted = mountJoystick(move, vi.fn())
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
expect(move.mock.lastCall?.[0].headX).toBe(1)
expect(move.mock.lastCall?.[0].bodyX).toBe(1)
mounted.app.unmount()
mounted.host.remove()
})
it('maps W and S to opposite mouth shapes without moving Y', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const mounted = mountJoystick(move, vi.fn())
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'w', bubbles: true }))
expect(move.mock.lastCall?.[0].mouthForm).toBe(1)
expect(move.mock.lastCall?.[0].headY).toBe(0)
mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'w', bubbles: true }))
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))
expect(move.mock.lastCall?.[0].mouthForm).toBe(-1)
expect(move.mock.lastCall?.[0].headY).toBe(0)
mounted.app.unmount()
mounted.host.remove()
})
it('maps C to fully closed eyes', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const release = vi.fn()
const mounted = mountJoystick(move, release)
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'C', bubbles: true }))
expect(move.mock.lastCall?.[0].eyeSquint).toBe(1)
mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'C', bubbles: true }))
expect(release).toHaveBeenCalledOnce()
mounted.app.unmount()
mounted.host.remove()
})
it('preserves the mouse position while tilt keys move', () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const release = vi.fn()
const mousePose: Live2DMotionControlPose = {
...neutralPose,
eyeX: 0.6,
eyeY: -0.4,
headX: 0.6,
headY: -0.4,
bodyX: 0.6,
bodyY: -0.4,
bodyZ: 0.35,
offsetX: 0.6,
offsetY: -0.4,
}
const mounted = mountJoystick(move, release, mousePose)
// ROOT CAUSE:
//
// Tilt-only keyboard input previously built a full pose with zero X/Y.
// Each smoothing frame moved the active mouse pose toward the center.
//
// We fixed this by changing only axes that the keyboard controls.
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }))
mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'q', bubbles: true }))
expect(move).toHaveBeenCalled()
for (const [pose] of move.mock.calls) {
expect(pose.headX).toBe(mousePose.headX)
expect(pose.headY).toBe(mousePose.headY)
expect(pose.bodyZ).toBe(mousePose.bodyZ)
}
mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'a', bubbles: true }))
mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'q', bubbles: true }))
expect(move.mock.lastCall?.[0]).toEqual(mousePose)
expect(release).not.toHaveBeenCalled()
mounted.app.unmount()
mounted.host.remove()
})
it('maps standard gamepad analog controls without taking ownership of eye direction', async () => {
const move = vi.fn<(pose: Live2DMotionControlPose) => void>()
const release = vi.fn()
const gamepad = shallowRef<StandardGamepadSnapshot>()
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp({
render: () => h(Joystick, {
dynamics: defaultDynamics,
gamepad: gamepad.value,
pose: neutralPose,
onMove: move,
onRelease: release,
}),
})
app.mount(host)
gamepad.value = createGamepadSnapshot({
leftStick: { x: 0.5, y: -0.25 },
leftTrigger: 0.7,
rightStick: { x: 0.4, y: 0.8 },
rightTrigger: 0.6,
})
await nextTick()
expect(move).toHaveBeenLastCalledWith({
...neutralPose,
bodyX: 0.5,
bodyY: 0.25,
eyeSquint: 0.7,
headX: 0.5,
headY: 0.25,
headZ: 0.4,
mouthOpen: 0.6,
offsetX: 0.5,
offsetY: 0.25,
})
expect(move.mock.lastCall?.[0].eyeX).toBe(0)
expect(move.mock.lastCall?.[0].eyeY).toBe(0)
gamepad.value = createGamepadSnapshot()
await nextTick()
expect(release).toHaveBeenCalledOnce()
app.unmount()
host.remove()
})
})
const standardButtonNames: readonly StandardGamepadButtonName[] = [
'dpadDown',
'dpadLeft',
'dpadRight',
'dpadUp',
'faceBottom',
'faceLeft',
'faceRight',
'faceTop',
'leftShoulder',
'leftStick',
'leftTrigger',
'rightShoulder',
'rightStick',
'rightTrigger',
'select',
'start',
]
function createGamepadSnapshot(options: {
leftStick?: { x: number, y: number }
leftTrigger?: number
rightStick?: { x: number, y: number }
rightTrigger?: number
} = {}): StandardGamepadSnapshot {
const buttons = Object.fromEntries(standardButtonNames.map((name): [StandardGamepadButtonName, StandardGamepadButtonState] => {
let value = 0
if (name === 'leftTrigger')
value = options.leftTrigger ?? 0
else if (name === 'rightTrigger')
value = options.rightTrigger ?? 0
return [name, { pressed: value > 0, touched: value > 0, value }]
})) as Record<StandardGamepadButtonName, StandardGamepadButtonState>
return {
buttons,
family: 'playstation',
id: 'DualSense Wireless Controller',
index: 0,
leftStick: options.leftStick ?? { x: 0, y: 0 },
rightStick: options.rightStick ?? { x: 0, y: 0 },
timestamp: 0,
}
}
@@ -0,0 +1,548 @@
<script setup lang="ts">
import type { StandardGamepadSnapshot } from '@proj-airi/input-gamepad'
import type { Live2DMotionControlDynamics, Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { getGamepadButtonLabel } from '@proj-airi/input-gamepad'
import { defaultLive2DMotionControlDynamics, neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { BasicButton, Button, FieldRange } from '@proj-airi/ui'
import { useRafFn } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import InputHints from './input-hints.vue'
const props = defineProps<{
pose: Live2DMotionControlPose
dynamics: Live2DMotionControlDynamics
disabled?: boolean
gamepad?: StandardGamepadSnapshot
}>()
const emit = defineEmits<{
move: [pose: Live2DMotionControlPose]
release: []
updateDynamics: [dynamics: Live2DMotionControlDynamics]
}>()
const { t } = useI18n()
const inputActive = shallowRef(false)
const waveEnabled = shallowRef(false)
let wavePhase = 0
let wavePeriodMs = 1500
let waveTargetPeriodMs = 1500
let waveTargetAtMs = 0
const pressedKeys = new Set<string>()
const positionKeys = new Set([
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
])
const headRollKeys = new Set([
'e',
'q',
])
const bodyXKeys = new Set([
'a',
'd',
])
const mouthFormKeys = new Set([
's',
'w',
])
const mouthOpenKeys = new Set([' '])
const eyeSquintKeys = new Set([
'c',
'f',
'r',
])
const movementKeys = new Set([...positionKeys, ...headRollKeys, ...bodyXKeys, ...mouthFormKeys, ...mouthOpenKeys, ...eyeSquintKeys])
let keyboardOwnsInput = false
let gamepadOwnsInput = false
let keyboardOwnsPosition = false
let keyboardOwnsHeadRoll = false
let keyboardOwnsBodyX = false
let keyboardBodyXBase = 0
let keyboardOwnsMouthForm = false
let keyboardMouthFormBase = 0
let keyboardOwnsMouthOpen = false
let keyboardMouthOpenBase = 0
let keyboardOwnsEyeSquint = false
let keyboardEyeSquintBase = 0
const knobStyle = computed(() => ({
transform: `translate(calc(-50% + ${props.pose.headX * 5.75}rem), calc(-50% - ${props.pose.headY * 5.75}rem))`,
}))
const follow = computed({
get: () => props.dynamics.follow,
set: value => emit('updateDynamics', { ...props.dynamics, follow: value }),
})
const inertia = computed({
get: () => props.dynamics.inertia,
set: value => emit('updateDynamics', { ...props.dynamics, inertia: value }),
})
const formatPercent = (value: number) => `${Math.round(value * 100)}%`
const gamepadFamily = computed(() => props.gamepad?.family ?? 'unknown')
const inputHints = computed(() => [
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.input-actions.move-body-head'),
controller: getGamepadButtonLabel(gamepadFamily.value, 'leftStick'),
keyboard: '← ↑ ↓ →',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.input-actions.tilt-head'),
controller: `${getGamepadButtonLabel(gamepadFamily.value, 'rightStick')} ← / →`,
keyboard: 'Q / E',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.input-actions.blink'),
controller: getGamepadButtonLabel(gamepadFamily.value, 'leftTrigger'),
keyboard: 'C',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.input-actions.open-mouth'),
controller: getGamepadButtonLabel(gamepadFamily.value, 'rightTrigger'),
keyboard: 'Space',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.input-actions.mouth-shape'),
keyboard: 'W / S',
},
])
useRafFn(({ delta, timestamp }) => {
if (!waveEnabled.value)
return
if (timestamp >= waveTargetAtMs) {
waveTargetPeriodMs = 1500 + Math.random() * 3500
waveTargetAtMs = timestamp + wavePeriodMs
}
wavePeriodMs += (waveTargetPeriodMs - wavePeriodMs) * (1 - Math.exp(-delta / 2000))
wavePhase += delta / wavePeriodMs * Math.PI * 2
const vertical = Math.sin(wavePhase)
const easedVertical = vertical * (1.5 - 0.5 * vertical * vertical)
inputActive.value = true
emit('move', {
...props.pose,
headZ: -Math.cos(wavePhase),
offsetY: easedVertical * 0.64,
})
})
function toggleWave() {
waveEnabled.value = !waveEnabled.value
if (waveEnabled.value) {
wavePhase = 0
wavePeriodMs = 1500
waveTargetPeriodMs = 1500
waveTargetAtMs = 0
return
}
inputActive.value = false
emit('release')
}
function setPosition(x: number, y: number) {
const magnitude = Math.hypot(x, y)
const scale = magnitude > 1 ? 1 / magnitude : 1
inputActive.value = true
emit('move', {
...props.pose,
eyeX: x * scale,
eyeY: y * scale,
headX: x * scale,
headY: y * scale,
bodyX: x * scale,
bodyY: y * scale,
offsetX: x * scale,
offsetY: y * scale,
})
}
function setPositionFromPointer(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const radius = Math.min(bounds.width, bounds.height) / 2
setPosition(
(event.clientX - (bounds.left + bounds.width / 2)) / radius,
((bounds.top + bounds.height / 2) - event.clientY) / radius,
)
}
function releaseImmediately() {
if (!inputActive.value)
return
pressedKeys.clear()
gamepadOwnsInput = false
keyboardOwnsInput = false
keyboardOwnsPosition = false
keyboardOwnsHeadRoll = false
keyboardOwnsBodyX = false
keyboardBodyXBase = 0
keyboardOwnsMouthForm = false
keyboardMouthFormBase = 0
keyboardOwnsMouthOpen = false
keyboardMouthOpenBase = 0
keyboardOwnsEyeSquint = false
keyboardEyeSquintBase = 0
inputActive.value = false
emit('release')
}
function handlePointerDown(event: PointerEvent) {
if (!event.isPrimary || event.button !== 0)
return
const target = event.currentTarget as HTMLElement
target.setPointerCapture(event.pointerId)
pressedKeys.clear()
gamepadOwnsInput = false
keyboardOwnsInput = false
keyboardOwnsPosition = false
keyboardOwnsHeadRoll = false
keyboardOwnsBodyX = false
keyboardBodyXBase = 0
keyboardOwnsMouthForm = false
keyboardMouthFormBase = 0
keyboardOwnsMouthOpen = false
keyboardMouthOpenBase = 0
keyboardOwnsEyeSquint = false
keyboardEyeSquintBase = 0
setPositionFromPointer(event)
}
function handlePointerMove(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
if (!target.hasPointerCapture(event.pointerId))
return
setPositionFromPointer(event)
}
function handlePointerEnd(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId))
target.releasePointerCapture(event.pointerId)
releaseImmediately()
}
function keyboardPosition(): Live2DMotionControlPose {
const left = pressedKeys.has('ArrowLeft')
const right = pressedKeys.has('ArrowRight')
const down = pressedKeys.has('ArrowDown')
const up = pressedKeys.has('ArrowUp')
const x = Number(right) - Number(left)
const y = Number(up) - Number(down)
const magnitude = Math.hypot(x, y)
const scale = magnitude > 1 ? 1 / magnitude : 1
const pose: Live2DMotionControlPose = {
...neutralLive2DMotionControlPose,
eyeX: x * scale,
eyeY: y * scale,
headX: x * scale,
headY: y * scale,
bodyX: x * scale,
bodyY: y * scale,
offsetX: x * scale,
offsetY: y * scale,
headZ: Number(pressedKeys.has('e')) - Number(pressedKeys.has('q')),
}
if ([...pressedKeys].some(key => bodyXKeys.has(key)))
pose.bodyX = Number(pressedKeys.has('d')) - Number(pressedKeys.has('a'))
if ([...pressedKeys].some(key => mouthFormKeys.has(key)))
pose.mouthForm = Number(pressedKeys.has('w')) - Number(pressedKeys.has('s'))
if (pressedKeys.has(' '))
pose.mouthOpen = 1
if (pressedKeys.has('c'))
pose.eyeSquint = 1
else if (pressedKeys.has('f'))
pose.eyeSquint = 2 / 3
else if (pressedKeys.has('r'))
pose.eyeSquint = 1 / 3
return pose
}
function poseIsNeutral(pose: Live2DMotionControlPose): boolean {
return Object.values(pose).every(value => value === 0)
}
function emitKeyboardTarget() {
const target = keyboardPosition()
const bodyXKeyActive = [...pressedKeys].some(key => bodyXKeys.has(key))
const mouthFormKeyActive = [...pressedKeys].some(key => mouthFormKeys.has(key))
const mouthOpenKeyActive = pressedKeys.has(' ')
const eyeSquintKeyActive = [...pressedKeys].some(key => eyeSquintKeys.has(key))
const nextPose = {
eyeX: keyboardOwnsPosition ? target.eyeX : props.pose.eyeX,
eyeY: keyboardOwnsPosition ? target.eyeY : props.pose.eyeY,
headX: keyboardOwnsPosition ? target.headX : props.pose.headX,
headY: keyboardOwnsPosition ? target.headY : props.pose.headY,
headZ: keyboardOwnsHeadRoll ? target.headZ : props.pose.headZ,
bodyX: keyboardOwnsBodyX
? (bodyXKeyActive ? target.bodyX : keyboardBodyXBase)
: (keyboardOwnsPosition ? target.bodyX : props.pose.bodyX),
bodyY: keyboardOwnsPosition ? target.bodyY : props.pose.bodyY,
bodyZ: props.pose.bodyZ,
mouthForm: keyboardOwnsMouthForm
? (mouthFormKeyActive ? target.mouthForm : keyboardMouthFormBase)
: props.pose.mouthForm,
mouthOpen: keyboardOwnsMouthOpen
? (mouthOpenKeyActive ? target.mouthOpen : keyboardMouthOpenBase)
: props.pose.mouthOpen,
eyeSquint: keyboardOwnsEyeSquint
? (eyeSquintKeyActive ? target.eyeSquint : keyboardEyeSquintBase)
: props.pose.eyeSquint,
offsetX: keyboardOwnsPosition ? target.offsetX : props.pose.offsetX,
offsetY: keyboardOwnsPosition ? target.offsetY : props.pose.offsetY,
}
inputActive.value = true
emit('move', nextPose)
if (![...pressedKeys].some(key => positionKeys.has(key)))
keyboardOwnsPosition = false
if (![...pressedKeys].some(key => headRollKeys.has(key)))
keyboardOwnsHeadRoll = false
if (!bodyXKeyActive)
keyboardOwnsBodyX = false
if (!mouthFormKeyActive)
keyboardOwnsMouthForm = false
if (!mouthOpenKeyActive)
keyboardOwnsMouthOpen = false
if (!eyeSquintKeyActive)
keyboardOwnsEyeSquint = false
if (pressedKeys.size > 0)
return
keyboardOwnsInput = false
if (!poseIsNeutral(nextPose))
return
inputActive.value = false
emit('release')
}
function handleKeyDown(event: KeyboardEvent) {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key
if (!movementKeys.has(key))
return
event.preventDefault()
if (pressedKeys.has(key))
return
gamepadOwnsInput = false
keyboardOwnsInput = true
if (positionKeys.has(key))
keyboardOwnsPosition = true
if (headRollKeys.has(key))
keyboardOwnsHeadRoll = true
if (bodyXKeys.has(key) && !keyboardOwnsBodyX) {
keyboardBodyXBase = props.pose.bodyX
keyboardOwnsBodyX = true
}
if (mouthFormKeys.has(key) && !keyboardOwnsMouthForm) {
keyboardMouthFormBase = props.pose.mouthForm
keyboardOwnsMouthForm = true
}
if (mouthOpenKeys.has(key) && !keyboardOwnsMouthOpen) {
keyboardMouthOpenBase = props.pose.mouthOpen
keyboardOwnsMouthOpen = true
}
if (eyeSquintKeys.has(key) && !keyboardOwnsEyeSquint) {
keyboardEyeSquintBase = props.pose.eyeSquint
keyboardOwnsEyeSquint = true
}
pressedKeys.add(key)
emitKeyboardTarget()
}
function handleKeyUp(event: KeyboardEvent) {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key
if (!movementKeys.has(key))
return
event.preventDefault()
if (!pressedKeys.delete(key))
return
emitKeyboardTarget()
}
function handleBlur() {
if (!keyboardOwnsInput) {
releaseImmediately()
return
}
pressedKeys.clear()
emitKeyboardTarget()
}
function handleGamepad(snapshot: StandardGamepadSnapshot | undefined) {
if (props.disabled)
return
if (!snapshot) {
if (!gamepadOwnsInput)
return
gamepadOwnsInput = false
inputActive.value = false
emit('release')
return
}
const leftX = snapshot.leftStick.x
const leftY = -snapshot.leftStick.y
const headRoll = snapshot.rightStick.x
const eyeSquint = snapshot.buttons.leftTrigger.value
const mouthOpen = snapshot.buttons.rightTrigger.value
const active = Math.abs(leftX) > 0
|| Math.abs(leftY) > 0
|| Math.abs(headRoll) > 0
|| eyeSquint > 0
|| mouthOpen > 0
if (!active) {
if (!gamepadOwnsInput)
return
gamepadOwnsInput = false
inputActive.value = false
emit('release')
return
}
gamepadOwnsInput = true
inputActive.value = true
emit('move', {
...props.pose,
bodyX: leftX,
bodyY: leftY,
eyeSquint,
headX: leftX,
headY: leftY,
headZ: headRoll,
mouthOpen,
offsetX: leftX,
offsetY: leftY,
})
}
watch(() => props.disabled, (disabled) => {
if (!disabled)
return
waveEnabled.value = false
pressedKeys.clear()
gamepadOwnsInput = false
keyboardOwnsInput = false
keyboardOwnsPosition = false
keyboardOwnsHeadRoll = false
keyboardOwnsBodyX = false
keyboardBodyXBase = 0
keyboardOwnsMouthForm = false
keyboardMouthFormBase = 0
keyboardOwnsMouthOpen = false
keyboardMouthOpenBase = 0
keyboardOwnsEyeSquint = false
keyboardEyeSquintBase = 0
inputActive.value = false
})
watch(() => props.gamepad, handleGamepad, { flush: 'sync' })
</script>
<template>
<div :class="['flex flex-col gap-5']">
<div :class="['flex flex-col items-center gap-4']">
<BasicButton
type="button"
size="unset"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.joystick-label')"
:disabled="props.disabled"
:class="[
'relative aspect-square w-full max-w-64 touch-none select-none overflow-hidden rounded-full',
'border border-neutral-300/80 dark:border-neutral-700/80',
'bg-neutral-100/70 dark:bg-neutral-900/70',
'shadow-inner active:scale-100!',
'focus-visible:outline-2 focus-visible:outline-primary-400 focus-visible:outline-offset-4',
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerEnd"
@pointercancel="handlePointerEnd"
@keydown="handleKeyDown"
@keyup="handleKeyUp"
@blur="handleBlur"
@contextmenu.prevent
>
<span :class="['pointer-events-none absolute inset-6 rounded-full', 'border border-neutral-300/60 dark:border-neutral-700/60']" />
<span :class="['pointer-events-none absolute inset-x-4 top-1/2 h-px', 'bg-neutral-300/80 dark:bg-neutral-700/80']" />
<span :class="['pointer-events-none absolute inset-y-4 left-1/2 w-px', 'bg-neutral-300/80 dark:bg-neutral-700/80']" />
<span
:style="knobStyle"
:class="[
'pointer-events-none absolute left-1/2 top-1/2 size-16 rounded-full',
'border border-primary-300/70 bg-primary-400/90 dark:border-primary-500/70 dark:bg-primary-500/85',
'shadow-lg shadow-primary-500/20',
'transition-transform duration-75 ease-out',
]"
>
<span :class="['i-mingcute:game-2-fill', 'absolute inset-4 text-white']" />
</span>
</BasicButton>
<InputHints
:hints="inputHints"
:keyboard-label="t('tamagotchi.settings.devtools.pages.live2d-motion.input.keyboard')"
:controller-label="t('tamagotchi.settings.devtools.pages.live2d-motion.input.controller')"
:class="['w-full']"
/>
</div>
<div :class="['grid gap-4 rounded-xl bg-neutral-100/70 p-4 dark:bg-neutral-900/50']">
<Button
type="button"
color="cyan"
:variant="waveEnabled ? 'primary' : 'secondary'"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.wave.toggle')"
:aria-pressed="waveEnabled"
:disabled="props.disabled"
@click="toggleWave"
>
<span :class="[waveEnabled ? 'i-mingcute:wave-fill' : 'i-mingcute:wave-line', 'size-4']" />
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.wave.toggle') }}
</Button>
<FieldRange
v-model="follow"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.spring.follow.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.spring.follow.description')"
:min="0"
:max="2"
:step="0.01"
:default-value="defaultLive2DMotionControlDynamics.follow"
:format-value="formatPercent"
as="div"
/>
<FieldRange
v-model="inertia"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.spring.inertia.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.spring.inertia.description')"
:min="0"
:max="1"
:step="0.01"
:default-value="defaultLive2DMotionControlDynamics.inertia"
:format-value="formatPercent"
as="div"
/>
</div>
</div>
</template>
@@ -0,0 +1,309 @@
// @vitest-environment jsdom
import type { StandardGamepadButtonName, StandardGamepadButtonState, StandardGamepadSnapshot } from '@proj-airi/input-gamepad'
import type { Live2DMotionEditorFrame } from '../composables/keyframes'
import type { Live2DMotionRecording, ReadonlyLive2DMotionRecording } from '../composables/recording'
import { neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, shallowRef } from 'vue'
import KeyframeEditor from './keyframe-editor.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
describe('keyframe editor', () => {
afterEach(() => {
document.body.replaceChildren()
})
function mountEditor(options: {
recording?: ReadonlyLive2DMotionRecording
recordingActive?: boolean
gamepad?: StandardGamepadSnapshot
onFrame?: (frame: Live2DMotionEditorFrame) => void
onRecording?: (recording: Live2DMotionRecording) => void
onRestartRecording?: () => void
onToggleRecording?: () => void
} = {}) {
const host = document.createElement('div')
document.body.appendChild(host)
const recording = shallowRef(options.recording)
const recordingActive = shallowRef(options.recordingActive ?? false)
const gamepad = shallowRef(options.gamepad)
const app = createApp({
render: () => h(KeyframeEditor, {
recording: recording.value,
recordingActive: recordingActive.value,
gamepad: gamepad.value,
onFrame: options.onFrame,
onRecording: options.onRecording,
onRestartRecording: options.onRestartRecording,
onToggleRecording: options.onToggleRecording,
}),
})
app.mount(host)
return { app, gamepad, host, recording, recordingActive }
}
function findButton(host: HTMLElement, text: string) {
return Array.from(host.querySelectorAll('button')).find(button => button.textContent?.trim() === text)!
}
it('shows all tracks on one shared timeline and expands the active track', () => {
const mounted = mountEditor()
const rulerRow = mounted.host.querySelector<HTMLElement>('[data-testid="motion-timeline-ruler-row"]')!
const trackList = mounted.host.querySelector<HTMLElement>('[data-testid="motion-timeline-track-list"]')!
expect(mounted.host.querySelectorAll('article')).toHaveLength(13)
expect(mounted.host.querySelectorAll('svg')).toHaveLength(14)
expect(mounted.host.querySelectorAll('article[data-active="true"]')).toHaveLength(1)
expect(rulerRow.classList).toContain('grid-cols-[calc(12rem+0.75rem)_minmax(0,1fr)]')
expect(rulerRow.classList).toContain('pr-3')
expect(trackList.classList).toContain('p-3')
expect(mounted.host.textContent).toContain('.tracks.eyeOpen')
expect(mounted.host.textContent).toContain('.tracks.mouthForm')
expect(mounted.host.textContent).toContain('.tracks.mouthOpen')
expect(mounted.host.textContent).toContain('.tracks.viewTargetX')
expect(mounted.host.textContent).toContain('.tracks.viewTargetY')
mounted.app.unmount()
})
it('owns recording and streams captured samples into the source timeline', async () => {
const onToggleRecording = vi.fn()
const mounted = mountEditor({
recordingActive: true,
onToggleRecording,
recording: {
format: 'airi-live2d-motion/v6',
durationMs: 25,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: 25, ...neutralLive2DMotionControlPose, headX: 0.25 },
],
},
})
expect(mounted.host.textContent).toContain('0.03s / 0.03s')
findButton(mounted.host, 'tamagotchi.settings.devtools.pages.live2d-motion.recording.actions.stop-recording').click()
expect(onToggleRecording).toHaveBeenCalledOnce()
mounted.recording.value = {
format: 'airi-live2d-motion/v6',
durationMs: 100,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: 25, ...neutralLive2DMotionControlPose, headX: 0.25 },
{ atMs: 100, ...neutralLive2DMotionControlPose, headX: 0.75 },
],
}
await nextTick()
expect(mounted.host.textContent).toContain('0.10s / 0.10s')
mounted.recordingActive.value = false
mounted.recording.value = {
...mounted.recording.value,
durationMs: 110,
}
await nextTick()
expect(mounted.host.querySelector<HTMLButtonElement>('[title="tamagotchi.settings.devtools.pages.live2d-motion.editor.undo"]')?.disabled).toBe(true)
mounted.app.unmount()
})
it('adds a sparse overlay, edits its points, and emits a baked recording', async () => {
const onRecording = vi.fn()
const mounted = mountEditor({ onRecording })
findButton(mounted.host, 'tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.add').click()
await nextTick()
const activeGraph = mounted.host.querySelector<SVGSVGElement>('article[data-active="true"] svg')!
vi.spyOn(activeGraph, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 1000,
height: 220,
right: 1000,
bottom: 220,
x: 0,
y: 0,
toJSON: () => ({}),
})
expect(activeGraph.querySelectorAll('.motion-overlay-point')).toHaveLength(2)
activeGraph.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 200, clientY: 50 }))
await nextTick()
expect(activeGraph.querySelectorAll('.motion-overlay-point')).toHaveLength(3)
activeGraph.querySelector<SVGCircleElement>('.motion-overlay-point')!
.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
await nextTick()
expect(activeGraph.querySelectorAll('.motion-overlay-point')).toHaveLength(2)
expect(onRecording).toHaveBeenCalled()
expect(onRecording.mock.lastCall?.[0]).toMatchObject({ format: 'airi-live2d-motion/v6', durationMs: 4000 })
mounted.app.unmount()
})
it('creates sparse held view-target keyframes and emits them with the pose', async () => {
const onFrame = vi.fn<(frame: Live2DMotionEditorFrame) => void>()
const mounted = mountEditor({ onFrame })
findButton(mounted.host, 'tamagotchi.settings.devtools.pages.live2d-motion.editor.tracks.viewTargetX').click()
await nextTick()
findButton(mounted.host, 'tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.create-keyframes').click()
await nextTick()
const activeGraph = mounted.host.querySelector<SVGSVGElement>('article[data-active="true"] svg')!
vi.spyOn(activeGraph, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 1000,
height: 300,
right: 1000,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
})
const heldCurve = activeGraph.querySelector<SVGPathElement>('g path')!
heldCurve.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 400, clientY: 50 }))
await nextTick()
expect(activeGraph.querySelectorAll('.motion-overlay-point')).toHaveLength(3)
activeGraph.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0, clientX: 500, clientY: 150 }))
await nextTick()
expect(onFrame.mock.lastCall?.[0].eyeView?.x).toBeGreaterThan(0)
expect(onFrame.mock.lastCall?.[0].pose).toEqual(neutralLive2DMotionControlPose)
mounted.app.unmount()
})
it('keeps a loaded dense recording locked beneath overlays', async () => {
const mounted = mountEditor({
recording: {
format: 'airi-live2d-motion/v6',
durationMs: 2000,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: 1000, ...neutralLive2DMotionControlPose, headX: 0.5 },
{ atMs: 2000, ...neutralLive2DMotionControlPose },
],
},
})
await nextTick()
expect(mounted.host.querySelectorAll('.motion-overlay-point')).toHaveLength(0)
expect(mounted.host.textContent).toContain('0.00s / 2.00s')
expect(mounted.host.querySelectorAll('article path')).toHaveLength(26)
mounted.app.unmount()
})
it('crops the visible timeline range and keeps the edit in history', async () => {
const onRecording = vi.fn()
const mounted = mountEditor({
onRecording,
recording: {
format: 'airi-live2d-motion/v6',
durationMs: 1000,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: 500, ...neutralLive2DMotionControlPose, headX: 0.5 },
{ atMs: 1000, ...neutralLive2DMotionControlPose, headX: 1 },
],
},
})
await nextTick()
const cropButton = findButton(mounted.host, 'tamagotchi.settings.devtools.pages.live2d-motion.editor.crop-to-view')
expect(cropButton.disabled).toBe(true)
const ruler = mounted.host.querySelector<SVGSVGElement>('svg[aria-label="tamagotchi.settings.devtools.pages.live2d-motion.editor.timeline-label"]')!
ruler.dispatchEvent(new WheelEvent('wheel', { bubbles: true, clientX: 500, deltaY: -500 }))
await nextTick()
expect(cropButton.disabled).toBe(false)
cropButton.click()
await nextTick()
expect(onRecording.mock.lastCall?.[0].durationMs).toBeLessThan(1000)
expect(mounted.host.querySelector<HTMLButtonElement>('[title="tamagotchi.settings.devtools.pages.live2d-motion.editor.undo"]')?.disabled).toBe(false)
mounted.host.querySelector<HTMLButtonElement>('[title="tamagotchi.settings.devtools.pages.live2d-motion.editor.undo"]')?.click()
await nextTick()
await nextTick()
expect(onRecording.mock.lastCall?.[0].durationMs).toBe(1000)
mounted.app.unmount()
})
it('connects controller track, recording, and clear actions to the editor', async () => {
const onRestartRecording = vi.fn()
const mounted = mountEditor({ onRestartRecording })
mounted.gamepad.value = createGamepadSnapshot({ rightShoulder: 1, dpadDown: 1 })
await nextTick()
expect(mounted.host.querySelector('article[data-active="true"]')?.textContent).toContain('.tracks.headY')
mounted.gamepad.value = createGamepadSnapshot()
await nextTick()
mounted.gamepad.value = createGamepadSnapshot({ leftShoulder: 1, dpadUp: 1 })
await nextTick()
expect(onRestartRecording).toHaveBeenCalledOnce()
findButton(mounted.host, 'tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.add').click()
await nextTick()
expect(mounted.host.querySelectorAll('.motion-overlay-point')).toHaveLength(2)
mounted.gamepad.value = createGamepadSnapshot()
await nextTick()
mounted.gamepad.value = createGamepadSnapshot({ leftShoulder: 1, dpadDown: 1 })
await nextTick()
expect(mounted.host.querySelectorAll('.motion-overlay-point')).toHaveLength(0)
mounted.app.unmount()
})
})
const standardButtonNames: readonly StandardGamepadButtonName[] = [
'dpadDown',
'dpadLeft',
'dpadRight',
'dpadUp',
'faceBottom',
'faceLeft',
'faceRight',
'faceTop',
'leftShoulder',
'leftStick',
'leftTrigger',
'rightShoulder',
'rightStick',
'rightTrigger',
'select',
'start',
]
function createGamepadSnapshot(pressed: Partial<Record<StandardGamepadButtonName, number>> = {}): StandardGamepadSnapshot {
const buttons = Object.fromEntries(standardButtonNames.map((name): [StandardGamepadButtonName, StandardGamepadButtonState] => {
const value = pressed[name] ?? 0
return [name, { pressed: value > 0, touched: value > 0, value }]
})) as Record<StandardGamepadButtonName, StandardGamepadButtonState>
return {
buttons,
family: 'playstation',
id: 'DualSense Wireless Controller',
index: 0,
leftStick: { x: 0, y: 0 },
rightStick: { x: 0, y: 0 },
timestamp: 0,
}
}
@@ -0,0 +1,532 @@
<script setup lang="ts">
import type { StandardGamepadSnapshot } from '@proj-airi/input-gamepad'
import type {
Live2DMotionEditableTrackId,
Live2DMotionEditorFrame,
Live2DMotionKeyframe,
Live2DMotionOverlay,
Live2DMotionOverlayBlendMode,
Live2DMotionProject,
} from '../composables/keyframes'
import type { Live2DMotionRecording, ReadonlyLive2DMotionRecording } from '../composables/recording'
import { errorMessageFrom } from '@moeru/std'
import { getGamepadButtonLabel } from '@proj-airi/input-gamepad'
import { BasicButton } from '@proj-airi/ui'
import { useManualRefHistory } from '@vueuse/core'
import { computed, nextTick, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import InputHints from './input-hints.vue'
import PlaybackControls from './playback-controls.vue'
import TimelineRuler from './timeline-ruler.vue'
import TrackRow from './track-row.vue'
import {
createDefaultLive2DMotionProject,
createLive2DMotionOverlay,
createLive2DMotionProject,
createLive2DMotionRecordingFromProject,
cropLive2DMotionProject,
evaluateLive2DMotionEditorFrame,
insertLive2DMotionKeyframe,
live2dMotionEditableTrackIds,
moveLive2DMotionKeyframe,
parseLive2DMotionProject,
stringifyLive2DMotionProject,
} from '../composables/keyframes'
import { useLive2DMotionGamepadActions } from '../composables/use-gamepad-actions'
const props = defineProps<{
disabled?: boolean
gamepad?: StandardGamepadSnapshot
recording?: ReadonlyLive2DMotionRecording | null
recordingActive?: boolean
}>()
const emit = defineEmits<{
frame: [frame: Live2DMotionEditorFrame]
playback: [playing: boolean]
recording: [recording: Live2DMotionRecording]
restartRecording: []
toggleRecording: []
}>()
const { t } = useI18n()
const project = shallowRef<Live2DMotionProject>(createDefaultLive2DMotionProject())
const activeTrack = shallowRef<Live2DMotionEditableTrackId>('headX')
const selectedOverlayId = shallowRef<string>()
const playheadMs = shallowRef(0)
const viewport = shallowRef<readonly [number, number]>([0, project.value.durationMs])
const rulerKey = shallowRef(0)
const playing = shallowRef(false)
const importError = shallowRef('')
const importFileInput = useTemplateRef<HTMLInputElement>('importFileInput')
const { canRedo, canUndo, clear, commit, redo, undo } = useManualRefHistory(project, {
clone: structuredClone,
capacity: 100,
})
let animationFrame: number | undefined
let playbackStartedAt = 0
let playbackStartMs = 0
let lastEmittedRecordingJson = ''
const currentTime = computed(() => `${(playheadMs.value / 1000).toFixed(2)}s`)
const duration = computed(() => `${(project.value.durationMs / 1000).toFixed(2)}s`)
const editingDisabled = computed(() => props.disabled || props.recordingActive)
const gamepadFamily = computed(() => props.gamepad?.family ?? 'unknown')
const timelineInputHints = computed(() => {
const leftShoulder = getGamepadButtonLabel(gamepadFamily.value, 'leftShoulder')
const rightShoulder = getGamepadButtonLabel(gamepadFamily.value, 'rightShoulder')
return [
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.play'),
controller: getGamepadButtonLabel(gamepadFamily.value, 'faceBottom'),
keyboard: 'Shift+Space',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.stop'),
controller: getGamepadButtonLabel(gamepadFamily.value, 'faceRight'),
keyboard: 'Shift+Space',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.start'),
controller: `${leftShoulder} + D-pad ←`,
keyboard: 'Alt+A',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.end'),
controller: `${leftShoulder} + D-pad →`,
keyboard: 'Alt+D',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.step-backward'),
controller: `${rightShoulder} + D-pad ←`,
keyboard: 'Shift+A',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.step-forward'),
controller: `${rightShoulder} + D-pad →`,
keyboard: 'Shift+D',
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.restart-recording'),
controller: `${leftShoulder} + D-pad ↑`,
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.clear-timeline'),
controller: `${leftShoulder} + D-pad ↓`,
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.previous-track'),
controller: `${rightShoulder} + D-pad ↑`,
},
{
action: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.input-actions.next-track'),
controller: `${rightShoulder} + D-pad ↓`,
},
]
})
const canCrop = computed(() => {
const [startMs, endMs] = viewport.value
return endMs - startMs >= 1 && (startMs > 0.5 || endMs < project.value.durationMs - 0.5)
})
function publish(atMs = playheadMs.value) {
playheadMs.value = Math.min(project.value.durationMs, Math.max(0, atMs))
emit('frame', evaluateLive2DMotionEditorFrame(project.value, playheadMs.value))
}
function emitBakedRecording() {
const recording = createLive2DMotionRecordingFromProject(project.value)
lastEmittedRecordingJson = JSON.stringify(recording)
emit('recording', recording)
}
function replaceProject(nextProject: Live2DMotionProject, saveHistory: boolean) {
project.value = nextProject
publish()
if (saveHistory) {
commit()
emitBakedRecording()
}
}
function updateProject(change: (draft: Live2DMotionProject) => void, saveHistory = true) {
const nextProject = structuredClone(project.value)
change(nextProject)
replaceProject(nextProject, saveHistory)
}
function addOverlay(trackId: Live2DMotionEditableTrackId, blendMode: Live2DMotionOverlayBlendMode) {
const overlay = createLive2DMotionOverlay(trackId, project.value.durationMs, playheadMs.value, blendMode)
updateProject(draft => draft.overlays.push(overlay))
activeTrack.value = trackId
selectedOverlayId.value = overlay.id
}
function selectTrack(trackId: Live2DMotionEditableTrackId) {
activeTrack.value = trackId
const selectedBelongsToTrack = project.value.overlays.some(overlay => overlay.id === selectedOverlayId.value && overlay.trackId === trackId)
if (!selectedBelongsToTrack)
selectedOverlayId.value = project.value.overlays.find(overlay => overlay.trackId === trackId)?.id
}
function removeOverlay(overlayId: string) {
updateProject((draft) => {
draft.overlays = draft.overlays.filter(overlay => overlay.id !== overlayId)
})
selectedOverlayId.value = project.value.overlays.find(overlay => overlay.trackId === activeTrack.value)?.id
}
function updateOverlay(overlayId: string, patch: Partial<Live2DMotionOverlay>, saveHistory: boolean) {
updateProject((draft) => {
const overlay = draft.overlays.find(item => item.id === overlayId)
if (!overlay)
return
Object.assign(overlay, patch)
overlay.points = overlay.points
.map(point => ({
...point,
atMs: Math.min(overlay.endMs, Math.max(overlay.startMs, point.atMs)),
}))
.sort((left, right) => left.atMs - right.atMs)
}, saveHistory)
}
function addPoint(overlayId: string, point: Live2DMotionKeyframe) {
updateProject((draft) => {
const overlay = draft.overlays.find(item => item.id === overlayId)
if (overlay)
overlay.points = insertLive2DMotionKeyframe(overlay.points, point)
})
}
function movePoint(overlayId: string, pointId: string, atMs: number, value: number, saveHistory: boolean) {
updateProject((draft) => {
const overlay = draft.overlays.find(item => item.id === overlayId)
if (overlay)
overlay.points = moveLive2DMotionKeyframe(overlay.points, pointId, atMs, value)
}, saveHistory)
}
function removePoint(overlayId: string, pointId: string) {
updateProject((draft) => {
const overlay = draft.overlays.find(item => item.id === overlayId)
if (overlay && overlay.points.length > 2)
overlay.points = overlay.points.filter(point => point.id !== pointId)
})
}
function stopPlayback() {
if (!playing.value)
return
playing.value = false
emit('playback', false)
if (animationFrame !== undefined)
cancelAnimationFrame(animationFrame)
animationFrame = undefined
}
function playbackFrame(now: number) {
const next = playbackStartMs + now - playbackStartedAt
if (next >= project.value.durationMs) {
stopPlayback()
publish(project.value.durationMs)
return
}
publish(next)
animationFrame = requestAnimationFrame(playbackFrame)
}
function startPlayback() {
if (playing.value || editingDisabled.value)
return
playbackStartMs = playheadMs.value >= project.value.durationMs ? 0 : playheadMs.value
playbackStartedAt = performance.now()
playing.value = true
emit('playback', true)
animationFrame = requestAnimationFrame(playbackFrame)
}
function seekTo(atMs: number) {
stopPlayback()
publish(atMs)
}
function stepPlayback(direction: -1 | 1, steps = 1) {
seekTo(playheadMs.value + direction * steps * 1000 / 30)
}
function restartRecording() {
if (props.disabled || playing.value)
return
emit('restartRecording')
}
function clearTimeline() {
if (editingDisabled.value)
return
stopPlayback()
activeTrack.value = 'headX'
selectedOverlayId.value = undefined
playheadMs.value = 0
replaceProject(createDefaultLive2DMotionProject(project.value.durationMs), true)
resetViewport()
}
function selectTrackByOffset(offset: -1 | 1) {
if (editingDisabled.value)
return
const currentIndex = live2dMotionEditableTrackIds.indexOf(activeTrack.value)
const nextIndex = Math.min(
live2dMotionEditableTrackIds.length - 1,
Math.max(0, currentIndex + offset),
)
selectTrack(live2dMotionEditableTrackIds[nextIndex])
}
function runHistoryAction(action: () => void) {
const previousDurationMs = project.value.durationMs
action()
nextTick(() => {
if (project.value.durationMs !== previousDurationMs)
resetViewport()
publish()
emitBakedRecording()
})
}
function resetViewport() {
viewport.value = [0, project.value.durationMs]
rulerKey.value++
}
useLive2DMotionGamepadActions({
clearTimeline,
disabled: () => props.disabled ?? false,
goToEnd: () => {
if (!editingDisabled.value)
seekTo(project.value.durationMs)
},
goToStart: () => {
if (!editingDisabled.value)
seekTo(0)
},
play: startPlayback,
restartRecording,
selectTrack: selectTrackByOffset,
snapshot: () => props.gamepad,
stepBackward: (steps) => {
if (!editingDisabled.value)
stepPlayback(-1, steps)
},
stepForward: (steps) => {
if (!editingDisabled.value)
stepPlayback(1, steps)
},
stop: stopPlayback,
})
function cropToViewport() {
const [startMs, endMs] = viewport.value
if (!canCrop.value)
return
project.value = cropLive2DMotionProject(project.value, startMs, endMs)
playheadMs.value = Math.min(project.value.durationMs, Math.max(0, playheadMs.value - startMs))
if (!project.value.overlays.some(overlay => overlay.id === selectedOverlayId.value))
selectedOverlayId.value = project.value.overlays.find(overlay => overlay.trackId === activeTrack.value)?.id
resetViewport()
publish()
commit()
emitBakedRecording()
}
function exportProject() {
const json = stringifyLive2DMotionProject(project.value)
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `airi-live2d-motion-project-${Date.now()}.json`
anchor.click()
URL.revokeObjectURL(url)
}
function openImportPicker() {
importFileInput.value?.click()
}
async function importProject(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
target.value = ''
if (!file)
return
try {
const nextProject = parseLive2DMotionProject(await file.text())
project.value = nextProject
playheadMs.value = 0
selectedOverlayId.value = nextProject.overlays[0]?.id
activeTrack.value = nextProject.overlays[0]?.trackId ?? 'headX'
resetViewport()
commit()
clear()
publish()
emitBakedRecording()
importError.value = ''
}
catch (error) {
console.error('[Live2D motion editor] Failed to import project', errorMessageFrom(error))
importError.value = t('tamagotchi.settings.devtools.pages.live2d-motion.editor.import-error')
}
}
watch(() => props.recording, (recording) => {
if (!recording)
return
if (JSON.stringify(recording) === lastEmittedRecordingJson) {
lastEmittedRecordingJson = ''
return
}
project.value = createLive2DMotionProject(recording)
playheadMs.value = props.recordingActive ? recording.durationMs : 0
selectedOverlayId.value = undefined
resetViewport()
if (props.recordingActive)
return
commit()
clear()
}, { immediate: true })
onUnmounted(() => {
stopPlayback()
emit('playback', false)
})
</script>
<template>
<section :class="['overflow-hidden rounded-xl bg-white/55 dark:bg-neutral-950/35']">
<div :class="['flex flex-wrap items-center justify-between gap-3 p-4']">
<div :class="['min-w-64 flex-1']">
<h3 :class="['font-medium text-neutral-900 dark:text-neutral-100']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.title') }}
</h3>
<p :class="['mt-1 max-w-3xl text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.instructions') }}
</p>
</div>
<PlaybackControls
:current-time-ms="playheadMs"
:duration-ms="project.durationMs"
:playing="playing"
:disabled="editingDisabled"
@start="seekTo(0)"
@step-backward="stepPlayback(-1, $event)"
@play="startPlayback"
@pause="stopPlayback"
@step-forward="stepPlayback(1, $event)"
@end="seekTo(project.durationMs)"
/>
<div :class="['flex min-w-64 flex-1 flex-wrap items-center justify-end gap-1']">
<BasicButton
:disabled="props.disabled || playing"
@click="emit('toggleRecording')"
>
<span :class="props.recordingActive ? 'i-mingcute:stop-circle-fill' : 'i-mingcute:dot-circle-fill'" />
{{ props.recordingActive
? t('tamagotchi.settings.devtools.pages.live2d-motion.recording.actions.stop-recording')
: t('tamagotchi.settings.devtools.pages.live2d-motion.recording.actions.record') }}
</BasicButton>
<BasicButton size="sm" :disabled="editingDisabled || !canUndo" :title="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.undo')" @click="runHistoryAction(undo)">
<span :class="['i-mingcute:anticlockwise-line']" />
</BasicButton>
<BasicButton size="sm" :disabled="editingDisabled || !canRedo" :title="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.redo')" @click="runHistoryAction(redo)">
<span :class="['i-mingcute:clockwise-line']" />
</BasicButton>
<BasicButton size="sm" :disabled="editingDisabled || playing || !canCrop" @click="cropToViewport">
<span :class="['i-mingcute:scissors-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.crop-to-view') }}
</BasicButton>
<BasicButton size="sm" :disabled="editingDisabled" @click="resetViewport">
<span :class="['i-mingcute:zoom-out-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.reset-view') }}
</BasicButton>
<BasicButton size="sm" :disabled="editingDisabled" @click="exportProject">
<span :class="['i-mingcute:download-2-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.export-project') }}
</BasicButton>
<BasicButton size="sm" :disabled="editingDisabled" @click="openImportPicker">
<span :class="['i-mingcute:upload-2-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.import-project') }}
</BasicButton>
</div>
<InputHints
:hints="timelineInputHints"
:keyboard-label="t('tamagotchi.settings.devtools.pages.live2d-motion.input.keyboard')"
:controller-label="t('tamagotchi.settings.devtools.pages.live2d-motion.input.controller')"
:class="['w-full grid-cols-[repeat(auto-fit,minmax(16rem,1fr))]']"
/>
</div>
<div
data-testid="motion-timeline-ruler-row"
:class="[
'grid grid-cols-[calc(12rem+0.75rem)_minmax(0,1fr)] pr-3',
'border-y border-neutral-200/80 dark:border-neutral-800/80',
]"
>
<div :class="['flex items-center px-3 font-mono text-xs text-neutral-500']">
{{ currentTime }} / {{ duration }}
</div>
<TimelineRuler
:key="rulerKey"
:duration-ms="project.durationMs"
:playhead-ms="playheadMs"
:viewport="viewport"
:disabled="editingDisabled"
@scrub="publish"
@viewport="viewport = $event"
/>
</div>
<div data-testid="motion-timeline-track-list" :class="['max-h-[52rem] space-y-2 overflow-y-auto p-3']">
<TrackRow
v-for="trackId in live2dMotionEditableTrackIds"
:key="trackId"
:project="project"
:track-id="trackId"
:label="t(`tamagotchi.settings.devtools.pages.live2d-motion.editor.tracks.${trackId}`)"
:active="activeTrack === trackId"
:selected-overlay-id="selectedOverlayId"
:playhead-ms="playheadMs"
:viewport="viewport"
:disabled="editingDisabled"
@activate="selectTrack(trackId)"
@add-overlay="addOverlay(trackId, $event)"
@select-overlay="selectedOverlayId = $event"
@remove-overlay="removeOverlay"
@update-overlay="updateOverlay"
@add-point="addPoint"
@move-point="movePoint"
@remove-point="removePoint"
@scrub="publish"
/>
</div>
<div :class="['flex items-center justify-between gap-3 border-t border-neutral-200/80 px-4 py-2 text-xs text-neutral-500 dark:border-neutral-800/80']">
<span>{{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.timeline-help') }}</span>
<span>{{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.overlay-count', { count: project.overlays.length }) }}</span>
</div>
<input ref="importFileInput" type="file" accept=".json,application/json" :class="['hidden']" @change="importProject">
<p v-if="importError" :class="['px-4 pb-3 text-sm text-red-500 dark:text-red-400']">
{{ importError }}
</p>
</section>
</template>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type {
OutputFilterFrame,
OutputFilterOptions,
} from '@proj-airi/model-driver-magic-live2d'
import { defaultOutputFilterOptions } from '@proj-airi/model-driver-magic-live2d'
import { BasicButton, FieldRange } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
options: OutputFilterOptions
frame?: OutputFilterFrame
generatorActive: boolean
}>()
const emit = defineEmits<{
updateOptions: [options: OutputFilterOptions]
reset: []
}>()
const { t } = useI18n()
const smoothing = computed({
get: () => props.options.smoothing,
set: value => updateOptions({ smoothing: value }),
})
const cutoff = computed({
get: () => props.options.cutoff,
set: value => updateOptions({ cutoff: value }),
})
const status = computed(() => {
if (!props.options.enabled)
return t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.status.bypassed')
if (props.generatorActive)
return t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.status.filtering')
return t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.status.waiting')
})
function updateOptions(patch: Partial<OutputFilterOptions>) {
emit('updateOptions', { ...props.options, ...patch })
}
function formatSmoothing(value: number): string {
return `${Math.round(value * 100)}%`
}
function formatCutoff(value: number): string {
return value.toFixed(3)
}
function formatChange(value: number | undefined): string {
return value === undefined ? '—' : value.toFixed(4)
}
</script>
<template>
<section :class="['rounded-xl bg-primary-50/65 p-4 dark:bg-primary-950/25']">
<div :class="['flex flex-wrap items-start justify-between gap-3']">
<div>
<h3 :class="['mb-1 font-medium text-neutral-900 dark:text-neutral-100']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.title') }}
</h3>
<p :class="['max-w-3xl text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.description') }}
</p>
</div>
<div :class="['flex flex-wrap items-center gap-1']">
<BasicButton @click="updateOptions({ enabled: !props.options.enabled })">
<span :class="[props.options.enabled ? 'i-mingcute:filter-fill' : 'i-mingcute:filter-line']" />
{{ props.options.enabled
? t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.actions.disable')
: t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.actions.enable') }}
</BasicButton>
<BasicButton :disabled="!props.generatorActive" @click="emit('reset')">
<span :class="['i-mingcute:refresh-anticlockwise-1-line']" />
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.actions.reset') }}
</BasicButton>
</div>
</div>
<div :class="['output-filter-fields mt-4 grid gap-4']">
<FieldRange
v-model="smoothing"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.smoothing.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.smoothing.description')"
:min="0"
:max="0.99"
:step="0.01"
:default-value="defaultOutputFilterOptions.smoothing"
:format-value="formatSmoothing"
as="div"
/>
<FieldRange
v-model="cutoff"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.cutoff.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.cutoff.description')"
:min="0"
:max="0.2"
:step="0.0025"
:default-value="defaultOutputFilterOptions.cutoff"
:format-value="formatCutoff"
as="div"
/>
</div>
<div :class="['output-filter-diagnostics mt-4 grid gap-2 text-xs']">
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.diagnostics.status') }}
</div>
<div :class="['mt-1 font-mono text-neutral-700 dark:text-neutral-200']">
{{ status }}
</div>
</div>
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.diagnostics.input-change') }}
</div>
<div :class="['mt-1 font-mono tabular-nums text-neutral-700 dark:text-neutral-200']">
{{ formatChange(props.frame?.inputChangeMeanAbsolute) }}
</div>
</div>
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.diagnostics.output-change') }}
</div>
<div :class="['mt-1 font-mono tabular-nums text-neutral-700 dark:text-neutral-200']">
{{ formatChange(props.frame?.outputChangeMeanAbsolute) }}
</div>
</div>
<div :class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']">
<div :class="['text-neutral-400 dark:text-neutral-500']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.output-filter.diagnostics.cutoff-tracks') }}
</div>
<div :class="['mt-1 font-mono tabular-nums text-neutral-700 dark:text-neutral-200']">
{{ props.frame?.cutoffTrackCount ?? '—' }}
</div>
</div>
</div>
</section>
</template>
<style scoped>
.output-filter-fields {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}
.output-filter-diagnostics {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
}
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { BasicButton } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useLive2DMotionPlaybackKeys } from '../composables/use-playback-keys'
const props = defineProps<{
currentTimeMs: number
disabled?: boolean
durationMs: number
playing: boolean
}>()
const emit = defineEmits<{
end: []
pause: []
play: []
start: []
stepBackward: [steps: number]
stepForward: [steps: number]
}>()
const { t } = useI18n()
const currentTimecode = computed(() => formatTimecode(props.currentTimeMs))
const durationTimecode = computed(() => formatTimecode(props.durationMs))
useLive2DMotionPlaybackKeys({
disabled: () => props.disabled ?? false,
goToEnd: () => emit('end'),
goToStart: () => emit('start'),
isPlaying: () => props.playing,
pause: () => emit('pause'),
play: () => emit('play'),
stepBackward: steps => emit('stepBackward', steps),
stepForward: steps => emit('stepForward', steps),
})
function formatTimecode(timeMs: number): string {
const framesPerSecond = 30
const totalFrames = Math.max(0, Math.round(timeMs * framesPerSecond / 1000))
const frames = totalFrames % framesPerSecond
const totalSeconds = Math.floor(totalFrames / framesPerSecond)
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
return [hours, minutes, seconds, frames]
.map(value => String(value).padStart(2, '0'))
.join(':')
}
function togglePlayback() {
if (props.playing) {
emit('pause')
return
}
emit('play')
}
</script>
<template>
<section
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.label')"
:class="['flex shrink-0 items-center justify-center gap-0.5']"
>
<BasicButton
size="sm"
:disabled="props.disabled"
:title="`${t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.start')} · Alt+A`"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.start')"
@click="emit('start')"
>
<span :class="['i-mingcute:skip-previous-fill size-4']" />
</BasicButton>
<BasicButton
size="sm"
:disabled="props.disabled"
:title="`${t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.step-backward')} · Shift+A`"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.step-backward')"
@click="emit('stepBackward', 1)"
>
<span :class="['i-mingcute:rewind-backward-5-fill size-4']" />
</BasicButton>
<BasicButton
size="sm"
:disabled="props.disabled"
:title="`${props.playing
? t('tamagotchi.settings.devtools.pages.live2d-motion.editor.pause')
: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.play')} · Shift+Space`"
:aria-label="props.playing
? t('tamagotchi.settings.devtools.pages.live2d-motion.editor.pause')
: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.play')"
@click="togglePlayback"
>
<span :class="[props.playing ? 'i-mingcute:pause-fill' : 'i-mingcute:play-fill', 'size-4']" />
</BasicButton>
<output
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.timecode')"
:class="['mx-1 min-w-38 text-center font-mono text-[10px] tabular-nums text-neutral-500 dark:text-neutral-400']"
>
{{ currentTimecode }} / {{ durationTimecode }}
</output>
<BasicButton
size="sm"
:disabled="props.disabled"
:title="`${t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.step-forward')} · Shift+D`"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.step-forward')"
@click="emit('stepForward', 1)"
>
<span :class="['i-mingcute:rewind-forward-5-fill size-4']" />
</BasicButton>
<BasicButton
size="sm"
:disabled="props.disabled"
:title="`${t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.end')} · Alt+D`"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.playback.end')"
@click="emit('end')"
>
<span :class="['i-mingcute:skip-forward-fill size-4']" />
</BasicButton>
</section>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
pose: Live2DMotionControlPose
}>()
const { t } = useI18n()
const parameterGroups = computed(() => [
{
icon: 'i-mingcute:eye-fill',
label: t('tamagotchi.settings.devtools.pages.live2d-motion.groups.eyes'),
values: [
{ axis: 'X', value: props.pose.eyeX },
{ axis: 'Y', value: props.pose.eyeY },
{ axis: t('tamagotchi.settings.devtools.pages.live2d-motion.preview.values.squint'), value: props.pose.eyeSquint },
],
},
{
icon: 'i-mingcute:faceid-fill',
label: t('tamagotchi.settings.devtools.pages.live2d-motion.groups.head'),
values: [
{ axis: 'X', value: props.pose.headX * 30 },
{ axis: 'Y', value: props.pose.headY * 30 },
{ axis: 'Z', value: props.pose.headZ * 30 },
],
},
{
icon: 'i-mingcute:body-fill',
label: t('tamagotchi.settings.devtools.pages.live2d-motion.groups.body'),
values: [
{ axis: 'X', value: props.pose.bodyX * 10 },
{ axis: 'Y', value: props.pose.bodyY * 10 },
{ axis: 'Z', value: props.pose.bodyZ * 10 },
],
},
{
icon: 'i-mingcute:happy-fill',
label: t('tamagotchi.settings.devtools.pages.live2d-motion.preview.groups.expression'),
values: [
{ axis: t('tamagotchi.settings.devtools.pages.live2d-motion.preview.values.mouth-form'), value: props.pose.mouthForm },
{ axis: t('tamagotchi.settings.devtools.pages.live2d-motion.preview.values.mouth-open'), value: props.pose.mouthOpen },
],
},
{
icon: 'i-mingcute:move-fill',
label: t('tamagotchi.settings.devtools.pages.live2d-motion.preview.groups.offset'),
values: [
{ axis: 'X', value: props.pose.offsetX },
{ axis: 'Y', value: props.pose.offsetY },
],
},
])
</script>
<template>
<section :class="['motion-values-grid grid min-h-full content-center gap-3']">
<article
v-for="group in parameterGroups"
:key="group.label"
:class="['min-w-0 rounded-xl bg-neutral-100/70 p-4 dark:bg-neutral-900/55']"
>
<div :class="['mb-3 flex items-center gap-2 text-sm font-semibold text-neutral-800 dark:text-neutral-100']">
<span :class="[group.icon, 'size-4 text-primary-500']" />
{{ group.label }}
</div>
<dl :class="['space-y-2 text-xs']">
<div v-for="item in group.values" :key="item.axis" :class="['flex items-center justify-between gap-3']">
<dt :class="['text-neutral-500 dark:text-neutral-400']">
{{ item.axis }}
</dt>
<dd :class="['font-mono tabular-nums text-neutral-800 dark:text-neutral-100']">
{{ item.value.toFixed(2) }}
</dd>
</div>
</dl>
</article>
</section>
</template>
<style scoped>
.motion-values-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
</style>
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from 'vitest-browser-vue'
import { createI18n } from 'vue-i18n'
import ProceduralMotion from './procedural-motion.vue'
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} },
})
}
describe('live2d procedural motion', () => {
afterEach(() => cleanup())
it('places the model selector above the model details', async () => {
const screen = await render(ProceduralMotion, {
global: { plugins: [createTestI18n()] },
})
const panel = screen.container.querySelector('section')
const modelSelector = panel?.querySelector('.select-tab')
expect(modelSelector).not.toBeNull()
expect(panel?.firstElementChild).toBe(modelSelector)
})
})
@@ -0,0 +1,326 @@
<script setup lang="ts">
import type { Pose } from '@proj-airi/model-driver-magic-live2d'
import type { SelectTabOption } from '@proj-airi/ui'
import type { Live2DMotionMagicMethod } from '../../../../motions/live2d'
import type { ReadonlyLive2DMotionRecording } from '../composables/recording'
import { BasicButton, FieldRange, SelectTab } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OutputFilter from './output-filter.vue'
import { useLive2DMotionMagic } from '../../../../motions/live2d'
const props = defineProps<{
recording?: ReadonlyLive2DMotionRecording | null
disabled?: boolean
}>()
const emit = defineEmits<{
pose: [pose: Pose]
release: []
playback: [playing: boolean]
}>()
const { t } = useI18n()
const motion = useLive2DMotionMagic({
dataset: () => props.recording,
disabled: () => props.disabled ?? false,
forceViewTarget: false,
publishPose: pose => emit('pose', pose),
releasePose: () => emit('release'),
setPlaying: playing => emit('playback', playing),
})
const modelOptions = computed<SelectTabOption<Live2DMotionMagicMethod>[]>(() => [
{
label: t('tamagotchi.settings.devtools.pages.live2d-motion.var.title'),
value: 'var',
icon: 'i-mingcute:chart-line-fill',
},
{
label: t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.title'),
value: 'ar-hmm',
icon: 'i-mingcute:git-branch-fill',
},
])
const translationPrefix = computed(() => motion.method.value === 'var'
? 'tamagotchi.settings.devtools.pages.live2d-motion.var'
: 'tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm')
const recordingSummary = computed(() => {
if (!props.recording)
return t(`${translationPrefix.value}.status.no-source`)
return t(`${translationPrefix.value}.status.source-ready`, {
count: props.recording.samples.length,
duration: (props.recording.durationMs / 1000).toFixed(1),
})
})
const diagnostics = computed(() => {
const model = motion.model.value
const items = [
{
id: 'source',
label: t(`${translationPrefix.value}.diagnostics.source`),
value: recordingSummary.value,
},
]
if (model?.method === 'var') {
items.push(
{
id: 'model',
label: t(`${translationPrefix.value}.diagnostics.model`),
value: t(`${translationPrefix.value}.values.model`, {
channels: model.diagnostics.channelCount,
features: model.diagnostics.featureCount,
}),
},
{
id: 'fit',
label: t(`${translationPrefix.value}.diagnostics.fit`),
value: t(`${translationPrefix.value}.values.fit`, {
frames: model.diagnostics.sourceFrameCount,
error: model.diagnostics.residualRootMeanSquare.toFixed(3),
duration: motion.fitDurationMs.value.toFixed(0),
}),
},
)
}
else if (model?.method === 'ar-hmm') {
const occupancy = model.diagnostics.stateOccupancy
.map(value => `${Math.round(value * 100)}%`)
.join(' / ')
items.push(
{
id: 'model',
label: t(`${translationPrefix.value}.diagnostics.model`),
value: t(`${translationPrefix.value}.values.model`, {
states: model.diagnostics.stateCount,
channels: model.diagnostics.channelCount,
features: model.diagnostics.featureCount,
}),
},
{
id: 'fit',
label: t(`${translationPrefix.value}.diagnostics.fit`),
value: t(`${translationPrefix.value}.values.fit`, {
likelihood: model.diagnostics.meanLogLikelihoodPerFrame.toFixed(2),
duration: motion.fitDurationMs.value.toFixed(0),
}),
},
{
id: 'states',
label: t(`${translationPrefix.value}.diagnostics.states`),
value: t(`${translationPrefix.value}.values.states`, {
occupancy,
dwell: model.diagnostics.meanDwellSeconds.toFixed(1),
}),
},
)
}
else {
items.push(
{
id: 'model',
label: t(`${translationPrefix.value}.diagnostics.model`),
value: '—',
},
{
id: 'fit',
label: t(`${translationPrefix.value}.diagnostics.fit`),
value: '—',
},
)
}
items.push({
id: 'run',
label: t(`${translationPrefix.value}.diagnostics.run`),
value: motion.method.value === 'var'
? t(`${translationPrefix.value}.values.run`, {
seed: motion.seed.value,
duration: `${motion.generatedDurationSeconds.value.toFixed(1)}s`,
})
: t(`${translationPrefix.value}.values.run`, {
state: motion.currentState.value === undefined ? '—' : motion.currentState.value + 1,
seed: motion.seed.value,
duration: `${motion.generatedDurationSeconds.value.toFixed(1)}s`,
}),
})
return items
})
function formatOrder(value: number): string {
return t(`${translationPrefix.value}.values.frames`, { count: value })
}
function formatStateCount(value: number): string {
return t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.values.count', { count: value })
}
function formatStrength(value: number): string {
return `${Math.round(value * 100)}%`
}
</script>
<template>
<section
:class="[
'rounded-xl bg-primary-50/65 p-4',
'dark:bg-primary-950/25',
]"
>
<SelectTab
v-model="motion.method.value"
:options="modelOptions"
:disabled="motion.playing.value || motion.status.value === 'initializing'"
size="sm"
:class="['mb-4 w-full']"
/>
<div :class="['flex flex-wrap items-start justify-between gap-3']">
<div :class="['min-w-0 flex-1']">
<h3 :class="['mb-1 font-medium text-neutral-900 dark:text-neutral-100']">
{{ t(`${translationPrefix}.title`) }}
</h3>
<p :class="['max-w-3xl text-xs text-neutral-500 dark:text-neutral-400']">
{{ t(`${translationPrefix}.description`) }}
</p>
</div>
<div :class="['flex flex-wrap items-center gap-1']">
<BasicButton
:disabled="props.disabled || !props.recording || motion.playing.value || motion.status.value === 'initializing'"
:loading="motion.status.value === 'initializing'"
@click="motion.initialize()"
>
<span :class="['i-mingcute:chart-line-line']" />
{{ motion.model.value
? t(`${translationPrefix}.actions.refit`)
: t(`${translationPrefix}.actions.fit`) }}
</BasicButton>
<BasicButton
v-if="!motion.playing.value"
:disabled="props.disabled || !motion.model.value"
@click="motion.start"
>
<span :class="['i-mingcute:play-line']" />
{{ t(`${translationPrefix}.actions.generate`) }}
</BasicButton>
<BasicButton v-else @click="motion.stop">
<span :class="['i-mingcute:stop-circle-line']" />
{{ t(`${translationPrefix}.actions.stop`) }}
</BasicButton>
<BasicButton :disabled="props.disabled" @click="motion.randomizeSeed">
<span :class="['i-mingcute:shuffle-line']" />
{{ t(`${translationPrefix}.actions.new-seed`) }}
</BasicButton>
</div>
</div>
<div :class="['procedural-motion-fields mt-4 grid gap-4']">
<FieldRange
v-if="motion.method.value === 'ar-hmm'"
v-model="motion.arHmmSettings.stateCount"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.states.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.states.description')"
:min="2"
:max="8"
:step="1"
:default-value="5"
:format-value="formatStateCount"
as="div"
/>
<FieldRange
v-if="motion.method.value === 'var'"
v-model="motion.varSettings.order"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.var.order.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.var.order.description')"
:min="1"
:max="48"
:step="1"
:default-value="20"
:format-value="formatOrder"
as="div"
/>
<FieldRange
v-else
v-model="motion.arHmmSettings.order"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.order.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.order.description')"
:min="1"
:max="18"
:step="1"
:default-value="12"
:format-value="formatOrder"
as="div"
/>
<FieldRange
v-if="motion.method.value === 'var'"
v-model="motion.varSettings.noiseScale"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.var.residual.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.var.residual.description')"
:min="0"
:max="3"
:step="0.025"
:default-value="1.15"
:format-value="formatStrength"
as="div"
/>
<FieldRange
v-else
v-model="motion.arHmmSettings.noiseScale"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.residual.label')"
:description="t('tamagotchi.settings.devtools.pages.live2d-motion.ar-hmm.residual.description')"
:min="0"
:max="3"
:step="0.025"
:default-value="0.8"
:format-value="formatStrength"
as="div"
/>
</div>
<dl :class="['procedural-motion-diagnostics mt-4 grid gap-2 text-xs']">
<div
v-for="item in diagnostics"
:key="item.id"
:class="['rounded-lg bg-white/70 px-3 py-2 dark:bg-neutral-950/40']"
>
<dt :class="['text-neutral-400 dark:text-neutral-500']">
{{ item.label }}
</dt>
<dd :class="['mt-1 break-words font-mono text-neutral-700 dark:text-neutral-200']">
{{ item.value }}
</dd>
</div>
</dl>
<p v-if="motion.error.value" :class="['mt-3 text-sm text-red-500 dark:text-red-400']">
{{ motion.error.value }}
</p>
</section>
<OutputFilter
:options="motion.outputFilterOptions.value"
:frame="motion.outputFilterFrame.value"
:generator-active="motion.playing.value"
@update-options="motion.setOutputFilterOptions"
@reset="motion.resetOutputFilter"
/>
</template>
<style scoped>
.procedural-motion-fields {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}
.procedural-motion-diagnostics {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { D3ZoomEvent, ZoomBehavior } from 'd3'
import { axisBottom, scaleLinear, select, zoom } from 'd3'
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
durationMs: number
playheadMs: number
viewport: readonly [number, number]
disabled?: boolean
}>()
const emit = defineEmits<{
scrub: [atMs: number]
viewport: [viewport: readonly [number, number]]
}>()
const { t } = useI18n()
const ruler = useTemplateRef<SVGSVGElement>('ruler')
const width = 1000
const scale = computed(() => scaleLinear().domain(props.viewport).range([0, width]))
const ticks = computed(() => scale.value.ticks(8).map(value => ({
value,
x: scale.value(value),
})))
const playheadX = computed(() => scale.value(props.playheadMs))
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | undefined
function formatTime(atMs: number) {
return `${(atMs / 1000).toFixed(atMs >= 10_000 ? 0 : 1)}s`
}
function clientToTime(event: PointerEvent) {
const bounds = ruler.value!.getBoundingClientRect()
const x = Math.min(width, Math.max(0, (event.clientX - bounds.left) / bounds.width * width))
return Math.round(scale.value.invert(x))
}
function scrub(event: PointerEvent) {
if (props.disabled || event.button !== 0)
return
const target = event.currentTarget as SVGSVGElement
target.setPointerCapture(event.pointerId)
const update = (nextEvent: PointerEvent) => emit('scrub', clientToTime(nextEvent))
update(event)
const stop = () => {
target.removeEventListener('pointermove', update)
target.removeEventListener('pointerup', stop)
target.removeEventListener('pointercancel', stop)
}
target.addEventListener('pointermove', update)
target.addEventListener('pointerup', stop)
target.addEventListener('pointercancel', stop)
}
onMounted(() => {
const baseScale = scaleLinear().domain([0, props.durationMs]).range([0, width])
zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([1, 20])
.translateExtent([[0, 0], [width, 44]])
.extent([[0, 0], [width, 44]])
.filter(event => !props.disabled && (event.type === 'wheel' || event.button === 1 || event.shiftKey))
.on('zoom', (event: D3ZoomEvent<SVGSVGElement, unknown>) => {
const domain = event.transform.rescaleX(baseScale).domain()
emit('viewport', [Math.max(0, domain[0]), Math.min(props.durationMs, domain[1])])
})
select(ruler.value!).call(zoomBehavior)
// Initialize D3's axis formatter and tick policy. Vue owns the rendered SVG nodes.
axisBottom(baseScale).ticks(8)
})
onUnmounted(() => {
if (ruler.value)
select(ruler.value).on('.zoom', null)
})
</script>
<template>
<svg
ref="ruler"
viewBox="0 0 1000 44"
preserveAspectRatio="none"
:class="[
'h-11 w-full touch-none select-none overflow-visible',
'cursor-ew-resize bg-neutral-100 dark:bg-neutral-900',
]"
:aria-label="t('tamagotchi.settings.devtools.pages.live2d-motion.editor.timeline-label')"
@pointerdown="scrub"
>
<line x1="0" y1="42" x2="1000" y2="42" :class="['stroke-neutral-300 dark:stroke-neutral-700']" />
<g v-for="tick in ticks" :key="tick.value">
<line :x1="tick.x" y1="27" :x2="tick.x" y2="43" :class="['stroke-neutral-300 dark:stroke-neutral-700']" />
<text :x="tick.x + 4" y="17" :class="['fill-neutral-500 text-[11px] font-mono']">
{{ formatTime(tick.value) }}
</text>
</g>
<line
:x1="playheadX"
y1="0"
:x2="playheadX"
y2="44"
:class="['stroke-amber-500']"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
</svg>
</template>
@@ -0,0 +1,321 @@
<script setup lang="ts">
import type { D3DragEvent } from 'd3'
import type {
Live2DMotionEditableTrackId,
Live2DMotionKeyframe,
Live2DMotionOverlay,
Live2DMotionOverlayBlendMode,
Live2DMotionProject,
} from '../composables/keyframes'
import { BasicButton, Range, Select } from '@proj-airi/ui'
import { curveLinear, curveStepAfter, drag, line, pointer, scaleLinear, select } from 'd3'
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
createLive2DMotionId,
getLive2DMotionCompositePoints,
getLive2DMotionSourcePoints,
getLive2DMotionTrackRange,
isLive2DMotionViewTargetTrackId,
} from '../composables/keyframes'
const props = defineProps<{
project: Live2DMotionProject
trackId: Live2DMotionEditableTrackId
label: string
active: boolean
selectedOverlayId?: string
playheadMs: number
viewport: readonly [number, number]
disabled?: boolean
}>()
const emit = defineEmits<{
activate: []
addOverlay: [blendMode: Live2DMotionOverlayBlendMode]
selectOverlay: [overlayId: string]
removeOverlay: [overlayId: string]
updateOverlay: [overlayId: string, patch: Partial<Live2DMotionOverlay>, commit: boolean]
addPoint: [overlayId: string, point: Live2DMotionKeyframe]
movePoint: [overlayId: string, pointId: string, atMs: number, value: number, commit: boolean]
removePoint: [overlayId: string, pointId: string]
scrub: [atMs: number]
}>()
const { t } = useI18n()
const graph = useTemplateRef<SVGSVGElement>('graph')
const width = 1000
const graphHeight = computed(() => props.active ? 300 : 62)
const xScale = computed(() => scaleLinear().domain(props.viewport).range([0, width]))
const yScale = computed(() => scaleLinear().domain(getLive2DMotionTrackRange(props.trackId)).range([graphHeight.value - 12, 12]))
const sourcePoints = computed(() => getLive2DMotionSourcePoints(props.project, props.trackId))
const compositePoints = computed(() => getLive2DMotionCompositePoints(props.project, props.trackId))
const overlays = computed(() => props.project.overlays.filter(overlay => overlay.trackId === props.trackId))
const selectedOverlay = computed(() => overlays.value.find(overlay => overlay.id === props.selectedOverlayId))
const viewTargetTrack = computed(() => isLive2DMotionViewTargetTrackId(props.trackId))
const zeroY = computed(() => yScale.value(0))
const playheadX = computed(() => xScale.value(props.playheadMs))
const blendOptions = [
{ label: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.add'), value: 'add' as const },
{ label: t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.replace'), value: 'replace' as const },
]
const overlayColors = ['#0ea5e9', '#f59e0b', '#a855f7', '#10b981', '#f43f5e']
function colorForOverlay(overlayId: string) {
const index = props.project.overlays.findIndex(overlay => overlay.id === overlayId)
return overlayColors[Math.max(0, index) % overlayColors.length]
}
function pathFor(points: readonly Live2DMotionKeyframe[]) {
return line<Live2DMotionKeyframe>()
.x(point => xScale.value(point.atMs))
.y(point => yScale.value(point.value))
.curve(viewTargetTrack.value ? curveStepAfter : curveLinear)(points) ?? ''
}
function clientToPoint(event: PointerEvent | MouseEvent) {
const [x, y] = pointer(event, graph.value)
const [minimum, maximum] = getLive2DMotionTrackRange(props.trackId)
return {
atMs: Math.round(Math.min(props.viewport[1], Math.max(props.viewport[0], xScale.value.invert(x)))),
value: Math.min(maximum, Math.max(minimum, yScale.value.invert(y))),
}
}
function addPoint(event: MouseEvent) {
if (!props.active || props.disabled || !selectedOverlay.value)
return
if (event.target instanceof SVGElement && event.target.closest('.motion-overlay-point, .motion-overlay-handle'))
return
const point = clientToPoint(event)
if (point.atMs < selectedOverlay.value.startMs || point.atMs > selectedOverlay.value.endMs)
return
emit('addPoint', selectedOverlay.value.id, { id: createLive2DMotionId(), ...point })
}
function scrub(event: PointerEvent) {
if (props.disabled || event.button !== 0 || event.target !== graph.value)
return
emit('scrub', clientToPoint(event).atMs)
}
function updateWeight(value: number) {
if (selectedOverlay.value)
emit('updateOverlay', selectedOverlay.value.id, { weight: value }, true)
}
function updateBlendMode(value: Live2DMotionOverlayBlendMode | undefined) {
if (selectedOverlay.value && value)
emit('updateOverlay', selectedOverlay.value.id, { blendMode: value }, true)
}
function installDragBehaviors() {
if (!graph.value)
return
select(graph.value).selectAll<SVGCircleElement, unknown>('.motion-overlay-point').call(
drag<SVGCircleElement, unknown>()
.on('drag', function (event: D3DragEvent<SVGCircleElement, unknown, unknown>) {
const overlay = props.project.overlays.find(item => item.id === this.dataset.overlayId)
if (!overlay)
return
const next = clientToPoint(event.sourceEvent)
emit(
'movePoint',
overlay.id,
this.dataset.pointId!,
Math.min(overlay.endMs, Math.max(overlay.startMs, next.atMs)),
next.value,
false,
)
})
.on('end', function () {
const overlay = props.project.overlays.find(item => item.id === this.dataset.overlayId)
const point = overlay?.points.find(item => item.id === this.dataset.pointId)
if (overlay && point)
emit('movePoint', overlay.id, point.id, point.atMs, point.value, true)
}),
)
select(graph.value).selectAll<SVGRectElement, unknown>('.motion-overlay-handle').call(
drag<SVGRectElement, unknown>()
.on('drag', function (event: D3DragEvent<SVGRectElement, unknown, unknown>) {
const overlay = props.project.overlays.find(item => item.id === this.dataset.overlayId)
if (!overlay)
return
const next = clientToPoint(event.sourceEvent).atMs
const patch = this.dataset.edge === 'start'
? { startMs: Math.min(overlay.endMs - 1, next) }
: { endMs: Math.max(overlay.startMs + 1, next) }
emit('updateOverlay', overlay.id, patch, false)
})
.on('end', function () {
const overlayId = this.dataset.overlayId
const overlay = props.project.overlays.find(item => item.id === overlayId)
if (overlay)
emit('updateOverlay', overlay.id, {}, true)
}),
)
}
watch(
() => [props.active, props.selectedOverlayId, selectedOverlay.value?.points, selectedOverlay.value?.startMs, selectedOverlay.value?.endMs],
() => nextTick(installDragBehaviors),
{ deep: true },
)
onMounted(installDragBehaviors)
</script>
<template>
<article
:data-active="active"
:style="{ height: `${graphHeight}px` }"
:class="[
'grid grid-cols-[12rem_minmax(0,1fr)] overflow-hidden rounded-lg border-l-2 transition-colors',
active
? 'border-primary-400/70 bg-primary-50/45 dark:border-primary-600/70 dark:bg-primary-950/20'
: 'border-transparent bg-neutral-100/65 dark:bg-neutral-900/45',
]"
@click="emit('activate')"
>
<header :class="['flex min-h-16 flex-col items-stretch gap-2 px-3 py-2']">
<button :class="['text-left text-sm font-semibold text-neutral-800 dark:text-neutral-100']" @click="emit('activate')">
{{ label }}
</button>
<div v-if="overlays.length" :class="['flex flex-col gap-1']">
<button
v-for="overlay in overlays"
:key="overlay.id"
:class="[
'truncate rounded-md border px-2 py-1 text-left text-xs font-medium',
overlay.id === selectedOverlayId ? 'bg-white shadow-sm dark:bg-neutral-800' : 'opacity-70',
]"
:style="{ borderColor: colorForOverlay(overlay.id), color: colorForOverlay(overlay.id) }"
@click.stop="emit('selectOverlay', overlay.id)"
>
{{ viewTargetTrack ? label : `${overlay.name} · ${overlay.blendMode}` }}
</button>
</div>
<div v-if="active" :class="['grid grid-cols-2 gap-1']">
<template v-if="viewTargetTrack">
<BasicButton size="sm" :disabled="disabled || overlays.length > 0" :class="['col-span-2 min-w-0 px-1!']" @click.stop="emit('addOverlay', 'replace')">
<span :class="['i-mingcute:add-circle-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.create-keyframes') }}
</BasicButton>
</template>
<template v-else>
<BasicButton size="sm" :disabled="disabled" :class="['min-w-0 px-1!']" @click.stop="emit('addOverlay', 'add')">
<span :class="['i-mingcute:add-circle-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.add') }}
</BasicButton>
<BasicButton size="sm" :disabled="disabled" :class="['min-w-0 px-1!']" @click.stop="emit('addOverlay', 'replace')">
<span :class="['i-mingcute:add-circle-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.replace') }}
</BasicButton>
</template>
</div>
<div v-if="active && selectedOverlay" :class="['flex flex-col gap-2 border-t border-neutral-200/70 pt-2 dark:border-neutral-800/70']" @click.stop>
<template v-if="!viewTargetTrack">
<Select
:model-value="selectedOverlay.blendMode"
:options="blendOptions"
:disabled="disabled"
:class="['w-full!']"
@update:model-value="updateBlendMode"
/>
<label :class="['flex flex-col gap-1 text-xs text-neutral-500']">
<span>{{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.weight') }} {{ selectedOverlay.weight.toFixed(2) }}</span>
<Range
:model-value="selectedOverlay.weight"
:min="0"
:max="1"
:step="0.05"
:disabled="disabled"
@update:model-value="updateWeight"
/>
</label>
<span :class="['font-mono text-xs text-neutral-500']">
{{ (selectedOverlay.startMs / 1000).toFixed(2) }}s{{ (selectedOverlay.endMs / 1000).toFixed(2) }}s
</span>
</template>
<BasicButton size="sm" :disabled="disabled" :class="['w-full']" @click="emit('removeOverlay', selectedOverlay.id)">
<span :class="['i-mingcute:delete-2-line']" /> {{ t('tamagotchi.settings.devtools.pages.live2d-motion.editor.controls.remove') }}
</BasicButton>
</div>
</header>
<svg
ref="graph"
:viewBox="`0 0 1000 ${graphHeight}`"
preserveAspectRatio="none"
:class="[
'h-full w-full touch-none select-none border-l border-neutral-200/70 bg-white dark:border-neutral-800/70 dark:bg-neutral-950',
active ? 'cursor-crosshair' : 'cursor-pointer',
]"
@pointerdown="scrub"
@dblclick="addPoint"
>
<line x1="0" :y1="zeroY" x2="1000" :y2="zeroY" :class="['stroke-neutral-200 dark:stroke-neutral-800']" />
<path :d="pathFor(sourcePoints)" fill="none" :class="['stroke-neutral-400 dark:stroke-neutral-600']" stroke-width="2" vector-effect="non-scaling-stroke" />
<path :d="pathFor(compositePoints)" fill="none" :class="['stroke-neutral-900 dark:stroke-neutral-100']" stroke-width="2" vector-effect="non-scaling-stroke" />
<g v-for="overlay in overlays" :key="overlay.id">
<path
:d="pathFor(overlay.points)"
fill="none"
:stroke="colorForOverlay(overlay.id)"
:stroke-width="overlay.id === selectedOverlayId ? 4 : 2"
vector-effect="non-scaling-stroke"
:class="['cursor-pointer']"
@click.stop="emit('selectOverlay', overlay.id)"
/>
</g>
<g v-if="active && selectedOverlay">
<rect
v-if="!viewTargetTrack"
class="motion-overlay-handle cursor-ew-resize"
:data-overlay-id="selectedOverlay.id"
data-edge="start"
:x="xScale(selectedOverlay.startMs) - 6"
y="0"
width="12"
:height="graphHeight"
:fill="colorForOverlay(selectedOverlay.id)"
opacity="0.22"
/>
<rect
v-if="!viewTargetTrack"
class="motion-overlay-handle cursor-ew-resize"
:data-overlay-id="selectedOverlay.id"
data-edge="end"
:x="xScale(selectedOverlay.endMs) - 6"
y="0"
width="12"
:height="graphHeight"
:fill="colorForOverlay(selectedOverlay.id)"
opacity="0.22"
/>
<circle
v-for="point in selectedOverlay.points"
:key="point.id"
class="motion-overlay-point cursor-grab active:cursor-grabbing"
:data-overlay-id="selectedOverlay.id"
:data-point-id="point.id"
:cx="xScale(point.atMs)"
:cy="yScale(point.value)"
r="7"
:fill="colorForOverlay(selectedOverlay.id)"
stroke="white"
stroke-width="2"
vector-effect="non-scaling-stroke"
@dblclick.stop="emit('removePoint', selectedOverlay.id, point.id)"
/>
</g>
<line :x1="playheadX" y1="0" :x2="playheadX" :y2="graphHeight" :class="['stroke-amber-500']" stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
</article>
</template>
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from 'vitest-browser-vue'
import { createI18n } from 'vue-i18n'
import Workbench from './workbench.vue'
import '@proj-airi/ui/main.css'
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} },
})
}
describe('live2d motion workbench', () => {
afterEach(() => cleanup())
it('renders borderless tab bars and visible drop feedback', async () => {
// ROOT CAUSE:
//
// The workbench used an undefined --color-primary-400 variable. Dockview
// created its drop target, but the browser resolved its background to transparent.
await render(Workbench, {
attrs: { style: 'width: 1200px; height: 800px' },
global: { plugins: [createTestI18n()] },
})
const workbench = document.querySelector<HTMLElement>('.dockview-theme-airi')
expect(workbench).not.toBeNull()
const tabBar = workbench?.querySelector<HTMLElement>('.dv-tabs-and-actions-container')
expect(tabBar).not.toBeNull()
expect(getComputedStyle(tabBar!).borderBottomWidth).toBe('0px')
const dropTarget = document.createElement('div')
dropTarget.className = 'dv-drop-target'
dropTarget.innerHTML = '<div class="dv-drop-target-dropzone"><div class="dv-drop-target-selection"></div></div>'
workbench!.append(dropTarget)
const dropSelection = dropTarget.querySelector<HTMLElement>('.dv-drop-target-selection')
expect(dropSelection).not.toBeNull()
expect(getComputedStyle(dropSelection!).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
expect(getComputedStyle(dropSelection!).borderStyle).toBe('none')
})
})
@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { DockviewReadyEvent, DockviewTheme, VueComponent } from 'dockview-vue'
import { useTheme } from '@proj-airi/ui'
import { DockviewVue } from 'dockview-vue'
import { computed, defineComponent, h, useSlots } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { isDark } = useTheme()
const slots = useSlots()
type PanelSlotName = 'direct-control' | 'inference' | 'preview' | 'timeline'
function createPanelComponent(slotName: PanelSlotName): VueComponent {
// NOTICE:
// Dockview declares registry entries as DefineComponent<any>.
// Vue's concrete defineComponent result is not assignable to that broad generic type.
// Source: dockview-vue/dist/types/utils.d.ts.
// Remove this cast when Dockview accepts Vue's Component type for registry entries.
return defineComponent({
setup() {
return () => h('div', { class: 'h-full overflow-auto p-3' }, slots[slotName]?.())
},
}) as VueComponent
}
const components: Record<string, VueComponent> = {
directControl: createPanelComponent('direct-control'),
inference: createPanelComponent('inference'),
preview: createPanelComponent('preview'),
timeline: createPanelComponent('timeline'),
}
const theme = computed<DockviewTheme>(() => ({
name: 'airi',
className: 'dockview-theme-airi',
colorScheme: isDark.value ? 'dark' : 'light',
gap: 8,
dndOverlayMounting: 'relative',
dndPanelOverlay: 'group',
dndTabIndicator: 'line',
}))
function handleReady({ api }: DockviewReadyEvent) {
const layoutWidth = api.width
const layoutHeight = api.height
const sideWidth = Math.max(200, Math.round(layoutWidth / 6))
const previewHeight = Math.max(180, Math.round(layoutHeight / 4))
const preview = api.addPanel({
id: 'preview',
component: 'preview',
title: t('tamagotchi.settings.devtools.pages.live2d-motion.panels.preview'),
initialWidth: Math.max(280, layoutWidth - sideWidth * 2),
minimumWidth: 280,
minimumHeight: 180,
})
const directControl = api.addPanel({
id: 'direct-control',
component: 'directControl',
title: t('tamagotchi.settings.devtools.pages.live2d-motion.panels.direct-control'),
position: { referencePanel: 'preview', direction: 'left' },
initialWidth: sideWidth,
minimumWidth: 200,
})
const inference = api.addPanel({
id: 'inference',
component: 'inference',
title: t('tamagotchi.settings.devtools.pages.live2d-motion.panels.inference'),
position: { referencePanel: 'preview', direction: 'right' },
initialWidth: sideWidth,
minimumWidth: 200,
})
const timeline = api.addPanel({
id: 'timeline',
component: 'timeline',
title: t('tamagotchi.settings.devtools.pages.live2d-motion.panels.timeline'),
position: { referencePanel: 'preview', direction: 'below' },
initialHeight: Math.max(240, layoutHeight - previewHeight),
minimumHeight: 240,
})
directControl.group.api.setSize({ width: sideWidth })
inference.group.api.setSize({ width: sideWidth })
preview.group.api.setSize({ height: previewHeight })
timeline.group.api.setSize({ height: Math.max(240, layoutHeight - previewHeight) })
}
</script>
<template>
<DockviewVue
:theme="theme"
:components="components"
:disable-floating-groups="true"
:class="['h-full w-full']"
@ready="handleReady"
/>
</template>
<style>
@import 'dockview-vue/dist/styles/dockview.css';
.dockview-theme-airi {
--motion-workbench-accent: oklch(clamp(0%, calc(74% * var(--chromatic-bri, 1)), 100%) calc(var(--chromatic-chroma-400) * var(--chromatic-sat, 1)) calc(var(--chromatic-hue) + 0));
--dv-tabs-and-actions-container-height: 2.25rem;
--dv-tabs-and-actions-container-background-color: rgb(250 250 250 / 0.92);
--dv-group-view-background-color: rgb(255 255 255 / 0.68);
--dv-activegroup-visiblepanel-tab-background-color: rgb(255 255 255 / 0.9);
--dv-activegroup-visiblepanel-tab-color: rgb(23 23 23);
--dv-activegroup-hiddenpanel-tab-background-color: transparent;
--dv-activegroup-hiddenpanel-tab-color: rgb(115 115 115);
--dv-inactivegroup-visiblepanel-tab-background-color: rgb(255 255 255 / 0.72);
--dv-inactivegroup-visiblepanel-tab-color: rgb(64 64 64);
--dv-inactivegroup-hiddenpanel-tab-background-color: transparent;
--dv-inactivegroup-hiddenpanel-tab-color: rgb(115 115 115);
--dv-tab-divider-color: rgb(229 229 229 / 0.8);
--dv-separator-border: rgb(229 229 229 / 0.72);
--dv-sash-color: transparent;
--dv-active-sash-color: color-mix(in oklch, var(--motion-workbench-accent) 60%, transparent);
--dv-icon-hover-background-color: rgb(229 229 229 / 0.8);
--dv-drag-over-background-color: color-mix(in oklch, var(--motion-workbench-accent) 12%, transparent);
--dv-drag-over-border-color: color-mix(in oklch, var(--motion-workbench-accent) 65%, transparent);
--dv-border-radius: 0.75rem;
--dv-tab-border-radius: 0.625rem;
--dv-spacing-padding: 0.5rem;
background: rgb(245 245 245 / 0.72);
}
.dark .dockview-theme-airi {
--dv-tabs-and-actions-container-background-color: rgb(23 23 23 / 0.9);
--dv-group-view-background-color: rgb(10 10 10 / 0.62);
--dv-activegroup-visiblepanel-tab-background-color: rgb(38 38 38 / 0.9);
--dv-activegroup-visiblepanel-tab-color: rgb(245 245 245);
--dv-activegroup-hiddenpanel-tab-color: rgb(163 163 163);
--dv-inactivegroup-visiblepanel-tab-background-color: rgb(38 38 38 / 0.66);
--dv-inactivegroup-visiblepanel-tab-color: rgb(229 229 229);
--dv-inactivegroup-hiddenpanel-tab-color: rgb(163 163 163);
--dv-tab-divider-color: rgb(64 64 64 / 0.74);
--dv-separator-border: rgb(64 64 64 / 0.64);
--dv-icon-hover-background-color: rgb(64 64 64 / 0.8);
background: rgb(23 23 23 / 0.52);
}
.dockview-theme-airi .dv-groupview {
overflow: hidden;
border-radius: 0.875rem;
}
.dockview-theme-airi .dv-tabs-and-actions-container {
backdrop-filter: blur(16px);
}
.dockview-theme-airi .dv-tab .dv-default-tab {
padding-inline: 0.75rem;
font-size: 0.75rem;
font-weight: 600;
}
.dockview-theme-airi .dv-tab .dv-default-tab .dv-default-tab-action {
display: none;
}
.dockview-theme-airi .dv-content-container {
background-image: radial-gradient(circle at 50% 0%, color-mix(in oklch, var(--motion-workbench-accent) 4%, transparent), transparent 38%);
}
.dockview-theme-airi .dv-split-view-container.dv-horizontal > .dv-view-container > .dv-view::before {
display: none;
}
</style>
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { defaultLive2DMotionRecording } from './default-recording'
describe('default Live2D motion recording', () => {
it('loads the speaking-excited trajectory', () => {
expect(defaultLive2DMotionRecording.format).toBe('airi-live2d-motion/v6')
expect(defaultLive2DMotionRecording.durationMs).toBe(60782)
expect(defaultLive2DMotionRecording.samples).toHaveLength(1358)
expect(defaultLive2DMotionRecording.samples[0].atMs).toBe(0)
})
})
@@ -0,0 +1,4 @@
import { defaultLive2DMotionMagicDataset } from '../../../../motions/live2d'
/** The bundled trajectory that initializes the Live2D motion devtool. */
export const defaultLive2DMotionRecording = defaultLive2DMotionMagicDataset
@@ -0,0 +1,134 @@
import { neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { describe, expect, it } from 'vitest'
import {
createLive2DMotionOverlay,
createLive2DMotionProject,
createLive2DMotionRecordingFromProject,
cropLive2DMotionProject,
evaluateLive2DMotionEyeView,
evaluateLive2DMotionProject,
insertLive2DMotionKeyframe,
moveLive2DMotionKeyframe,
parseLive2DMotionProject,
stringifyLive2DMotionProject,
} from './keyframes'
function createRecording() {
return {
format: 'airi-live2d-motion/v6' as const,
durationMs: 1000,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: 1000, ...neutralLive2DMotionControlPose, headY: 1, mouthOpen: 1 },
],
}
}
describe('live2D motion overlays', () => {
it('keeps the source intact and applies bounded overlays in order', () => {
const source = createRecording()
const project = createLive2DMotionProject(source)
project.overlays = [
{ id: 'add', name: 'Add', trackId: 'headY', blendMode: 'add', weight: 0.5, startMs: 200, endMs: 800, points: [{ id: 'a', atMs: 200, value: 0.4 }, { id: 'b', atMs: 800, value: 0.4 }] },
{ id: 'replace', name: 'Replace', trackId: 'headY', blendMode: 'replace', weight: 0.5, startMs: 400, endMs: 600, points: [{ id: 'c', atMs: 400, value: -1 }, { id: 'd', atMs: 600, value: -1 }] },
]
expect(project.source).toEqual(source)
expect(evaluateLive2DMotionProject(project, 100).headY).toBeCloseTo(0.1)
expect(evaluateLive2DMotionProject(project, 300).headY).toBeCloseTo(0.5)
expect(evaluateLive2DMotionProject(project, 500).headY).toBeCloseTo(-0.15)
expect(evaluateLive2DMotionProject(project, 900).headY).toBeCloseTo(0.9)
})
it('clamps the final composite to the target track range', () => {
const project = createLive2DMotionProject(createRecording())
project.overlays.push({ id: 'open', name: 'Open', trackId: 'mouthOpen', blendMode: 'add', weight: 1, startMs: 0, endMs: 1000, points: [{ id: 'a', atMs: 0, value: 1 }, { id: 'b', atMs: 1000, value: 1 }] })
expect(evaluateLive2DMotionProject(project, 500).mouthOpen).toBe(1)
})
it('bakes source and overlay breakpoints into a v6 recording', () => {
const project = createLive2DMotionProject(createRecording())
project.overlays.push({ id: 'layer', name: 'Layer', trackId: 'headX', blendMode: 'add', weight: 1, startMs: 250, endMs: 750, points: [{ id: 'a', atMs: 250, value: 0 }, { id: 'b', atMs: 500, value: 1 }, { id: 'c', atMs: 750, value: 0 }] })
const baked = createLive2DMotionRecordingFromProject(project)
expect(baked.samples.map(sample => sample.atMs)).toEqual([0, 250, 500, 750, 1000])
expect(baked.samples[2].headX).toBe(1)
})
it('holds sparse view targets without baking them into the pose recording', () => {
const project = createLive2DMotionProject(createRecording())
const overlay = createLive2DMotionOverlay('viewTargetX', 1000, 400, 'replace')
overlay.points = [
{ id: 'a', atMs: 0, value: -0.4 },
{ id: 'b', atMs: 400, value: 0.6 },
{ id: 'c', atMs: 1000, value: 0.2 },
]
project.overlays.push(overlay)
expect(overlay).toMatchObject({ startMs: 0, endMs: 1000, blendMode: 'replace', weight: 1 })
expect(evaluateLive2DMotionEyeView(project, 399)).toEqual({ x: -0.4 })
expect(evaluateLive2DMotionEyeView(project, 400)).toEqual({ x: 0.6 })
expect(evaluateLive2DMotionEyeView(project, 999)).toEqual({ x: 0.6 })
expect(evaluateLive2DMotionEyeView(project, 1000)).toEqual({ x: 0.2 })
expect(createLive2DMotionRecordingFromProject(project).samples.map(sample => sample.atMs)).toEqual([0, 1000])
})
it('crops source and overlays to interpolated boundary poses', () => {
const project = createLive2DMotionProject({
format: 'airi-live2d-motion/v6',
durationMs: 1000,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: 500, ...neutralLive2DMotionControlPose, headY: 0.5 },
{ atMs: 1000, ...neutralLive2DMotionControlPose, headY: 1 },
],
})
project.overlays = [
{ id: 'inside', name: 'Inside', trackId: 'headX', blendMode: 'add', weight: 1, startMs: 200, endMs: 800, points: [{ id: 'a', atMs: 200, value: 0.4 }, { id: 'b', atMs: 800, value: 0.8 }] },
{ id: 'outside', name: 'Outside', trackId: 'headZ', blendMode: 'add', weight: 1, startMs: 0, endMs: 100, points: [{ id: 'c', atMs: 0, value: 1 }, { id: 'd', atMs: 100, value: 1 }] },
]
const cropped = cropLive2DMotionProject(project, 250, 750)
expect(cropped.durationMs).toBe(500)
expect(cropped.source.durationMs).toBe(500)
expect(cropped.source.samples.map(sample => sample.atMs)).toEqual([0, 250, 500])
expect(cropped.source.samples.map(sample => sample.headY)).toEqual([0.25, 0.5, 0.75])
expect(cropped.overlays).toHaveLength(1)
expect(cropped.overlays[0]).toMatchObject({ id: 'inside', startMs: 0, endMs: 500 })
expect(cropped.overlays[0].points.map(point => point.atMs)).toEqual([0, 500])
expect(evaluateLive2DMotionProject(cropped, 0)).toEqual(evaluateLive2DMotionProject(project, 250))
expect(evaluateLive2DMotionProject(cropped, 250)).toEqual(evaluateLive2DMotionProject(project, 500))
expect(evaluateLive2DMotionProject(cropped, 500)).toEqual(evaluateLive2DMotionProject(project, 750))
expect(project.durationMs).toBe(1000)
})
it('rejects an empty or out-of-bounds crop range', () => {
const project = createLive2DMotionProject(createRecording())
expect(() => cropLive2DMotionProject(project, 500, 500)).toThrow('outside the motion timeline')
expect(() => cropLive2DMotionProject(project, -1, 500)).toThrow('outside the motion timeline')
expect(() => cropLive2DMotionProject(project, 500, 1001)).toThrow('outside the motion timeline')
})
it('round-trips a project file with overlays', () => {
const project = createLive2DMotionProject(createRecording())
project.overlays.push({ id: 'layer', name: 'Layer', trackId: 'eyeOpen', blendMode: 'replace', weight: 0.75, startMs: 100, endMs: 900, points: [{ id: 'a', atMs: 100, value: 1 }, { id: 'b', atMs: 900, value: 0 }] })
expect(parseLive2DMotionProject(stringifyLive2DMotionProject(project))).toEqual(project)
expect(() => parseLive2DMotionProject('{"format":"wrong"}')).toThrow('not an AIRI Live2D motion project')
})
it('keeps inserted and dragged points in timeline order', () => {
const initial = [{ id: 'a', atMs: 0, value: 0 }, { id: 'b', atMs: 1000, value: 0 }]
const inserted = insertLive2DMotionKeyframe(initial, { id: 'c', atMs: 500, value: 0.5 })
const dragged = moveLive2DMotionKeyframe(inserted, 'c', 750, -0.5)
expect(dragged).toEqual([
{ id: 'a', atMs: 0, value: 0 },
{ id: 'c', atMs: 750, value: -0.5 },
{ id: 'b', atMs: 1000, value: 0 },
])
})
})
@@ -0,0 +1,476 @@
import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import type { Live2DMotionRecording, ReadonlyLive2DMotionRecording } from './recording'
import { neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { array, finite, literal, maxValue, minLength, minValue, number, object, picklist, pipe, safeParse, string } from 'valibot'
export const live2dMotionPoseEditableTrackIds = [
'eyeX',
'eyeY',
'eyeOpen',
'headX',
'headY',
'headZ',
'bodyX',
'bodyY',
'bodyZ',
'mouthForm',
'mouthOpen',
] as const
export const live2dMotionViewTargetTrackIds = ['viewTargetX', 'viewTargetY'] as const
export const live2dMotionEditableTrackIds = [...live2dMotionViewTargetTrackIds, ...live2dMotionPoseEditableTrackIds] as const
export const live2dMotionTrackIds = [...live2dMotionPoseEditableTrackIds, 'offsetX', 'offsetY'] as const
export type Live2DMotionTrackId = typeof live2dMotionTrackIds[number]
export type Live2DMotionEditableTrackId = typeof live2dMotionEditableTrackIds[number]
export type Live2DMotionViewTargetTrackId = typeof live2dMotionViewTargetTrackIds[number]
export type Live2DMotionOverlayBlendMode = 'add' | 'replace'
type DirectLive2DMotionTrackId = Exclude<Live2DMotionTrackId, 'eyeOpen'>
export interface Live2DMotionEyeViewTarget {
x?: number
y?: number
}
export interface Live2DMotionEditorFrame {
pose: Live2DMotionControlPose
eyeView?: Live2DMotionEyeViewTarget
}
export interface Live2DMotionKeyframe {
id: string
atMs: number
value: number
}
/** A sparse curve that modifies one recorded motion track during a bounded time span. */
export interface Live2DMotionOverlay {
id: string
name: string
trackId: Live2DMotionEditableTrackId
blendMode: Live2DMotionOverlayBlendMode
weight: number
startMs: number
endMs: number
points: Live2DMotionKeyframe[]
}
/** A motion recording plus non-destructive track overlays. */
export interface Live2DMotionProject {
format: 'airi-live2d-motion-project/v1'
durationMs: number
source: Live2DMotionRecording
overlays: Live2DMotionOverlay[]
}
const motionProjectSampleSchema = object({
atMs: pipe(number(), finite(), minValue(0)),
eyeX: pipe(number(), finite(), minValue(-1), maxValue(1)),
eyeY: pipe(number(), finite(), minValue(-1), maxValue(1)),
eyeSquint: pipe(number(), finite(), minValue(0), maxValue(1)),
headX: pipe(number(), finite(), minValue(-1), maxValue(1)),
headY: pipe(number(), finite(), minValue(-1), maxValue(1)),
headZ: pipe(number(), finite(), minValue(-1), maxValue(1)),
bodyX: pipe(number(), finite(), minValue(-1), maxValue(1)),
bodyY: pipe(number(), finite(), minValue(-1), maxValue(1)),
bodyZ: pipe(number(), finite(), minValue(-1), maxValue(1)),
mouthForm: pipe(number(), finite(), minValue(-1), maxValue(1)),
mouthOpen: pipe(number(), finite(), minValue(0), maxValue(1)),
offsetX: pipe(number(), finite(), minValue(-1), maxValue(1)),
offsetY: pipe(number(), finite(), minValue(-1), maxValue(1)),
})
const motionProjectKeyframeSchema = object({
id: string(),
atMs: pipe(number(), finite(), minValue(0)),
value: pipe(number(), finite()),
})
const motionProjectSchema = object({
format: literal('airi-live2d-motion-project/v1'),
durationMs: pipe(number(), finite(), minValue(0)),
source: object({
format: literal('airi-live2d-motion/v6'),
durationMs: pipe(number(), finite(), minValue(0)),
samples: pipe(array(motionProjectSampleSchema), minLength(1)),
}),
overlays: array(object({
id: string(),
name: string(),
trackId: picklist(live2dMotionEditableTrackIds),
blendMode: picklist(['add', 'replace']),
weight: pipe(number(), finite(), minValue(0), maxValue(1)),
startMs: pipe(number(), finite(), minValue(0)),
endMs: pipe(number(), finite(), minValue(0)),
points: array(motionProjectKeyframeSchema),
})),
})
const unitValueTracks = new Set<Live2DMotionEditableTrackId | Live2DMotionTrackId>(['eyeOpen', 'mouthOpen'])
const viewTargetTracks = new Set<Live2DMotionEditableTrackId>(live2dMotionViewTargetTrackIds)
let motionIdSequence = 0
/** Creates a motion-editor ID without requiring a secure browser context. */
export function createLive2DMotionId(): string {
motionIdSequence++
return `motion-${Date.now().toString(36)}-${motionIdSequence.toString(36)}-${Math.random().toString(36).slice(2)}`
}
export function isLive2DMotionViewTargetTrackId(trackId: Live2DMotionEditableTrackId): trackId is Live2DMotionViewTargetTrackId {
return viewTargetTracks.has(trackId)
}
export function getLive2DMotionTrackRange(trackId: Live2DMotionEditableTrackId | Live2DMotionTrackId): readonly [number, number] {
return unitValueTracks.has(trackId) ? [0, 1] : [-1, 1]
}
export function getLive2DMotionTrackValue(pose: Live2DMotionControlPose, trackId: Live2DMotionTrackId): number {
if (trackId === 'eyeOpen')
return 1 - pose.eyeSquint
return pose[trackId]
}
function setLive2DMotionTrackValue(pose: Live2DMotionControlPose, trackId: Live2DMotionTrackId, value: number) {
if (trackId === 'eyeOpen') {
pose.eyeSquint = 1 - value
return
}
pose[trackId as DirectLive2DMotionTrackId] = value
}
function clampTrackValue(trackId: Live2DMotionTrackId, value: number): number {
const [minimum, maximum] = getLive2DMotionTrackRange(trackId)
return Math.min(maximum, Math.max(minimum, value))
}
function clampViewTarget(value: number): number {
return Math.min(1, Math.max(-1, value))
}
export function evaluateLive2DMotionKeyframes(points: readonly Live2DMotionKeyframe[], atMs: number): number {
if (points.length === 0)
return 0
if (atMs <= points[0].atMs)
return points[0].value
const rightIndex = points.findIndex(point => point.atMs >= atMs)
if (rightIndex < 0)
return points.at(-1)!.value
const left = points[rightIndex - 1]
const right = points[rightIndex]
if (left.atMs === right.atMs)
return right.value
const progress = (atMs - left.atMs) / (right.atMs - left.atMs)
return left.value + (right.value - left.value) * progress
}
function evaluateHeldLive2DMotionKeyframes(points: readonly Live2DMotionKeyframe[], atMs: number): number {
if (points.length === 0)
return 0
if (atMs < points[0].atMs)
return points[0].value
const rightIndex = points.findIndex(point => point.atMs > atMs)
return rightIndex < 0 ? points.at(-1)!.value : points[rightIndex - 1].value
}
function evaluateOverlay(overlay: Live2DMotionOverlay, atMs: number): number {
return isLive2DMotionViewTargetTrackId(overlay.trackId)
? evaluateHeldLive2DMotionKeyframes(overlay.points, atMs)
: evaluateLive2DMotionKeyframes(overlay.points, atMs)
}
export function evaluateLive2DMotionRecording(recording: ReadonlyLive2DMotionRecording, atMs: number): Live2DMotionControlPose {
const samples = recording.samples
const time = Math.min(recording.durationMs, Math.max(0, atMs))
const rightIndex = samples.findIndex(sample => sample.atMs >= time)
if (rightIndex <= 0) {
const { atMs: _atMs, ...pose } = samples[rightIndex < 0 ? samples.length - 1 : 0]
return { ...pose }
}
const left = samples[rightIndex - 1]
const right = samples[rightIndex]
const progress = left.atMs === right.atMs ? 1 : (time - left.atMs) / (right.atMs - left.atMs)
const pose = { ...neutralLive2DMotionControlPose }
for (const trackId of live2dMotionTrackIds) {
const leftValue = getLive2DMotionTrackValue(left, trackId)
const value = leftValue + (getLive2DMotionTrackValue(right, trackId) - leftValue) * progress
setLive2DMotionTrackValue(pose, trackId, value)
}
return pose
}
export function createLive2DMotionProject(recording: ReadonlyLive2DMotionRecording): Live2DMotionProject {
return {
format: 'airi-live2d-motion-project/v1',
durationMs: recording.durationMs,
source: {
format: recording.format,
durationMs: recording.durationMs,
samples: recording.samples.map(sample => ({ ...sample })),
},
overlays: [],
}
}
export function createDefaultLive2DMotionProject(durationMs = 4000): Live2DMotionProject {
return createLive2DMotionProject({
format: 'airi-live2d-motion/v6',
durationMs,
samples: [
{ atMs: 0, ...neutralLive2DMotionControlPose },
{ atMs: durationMs, ...neutralLive2DMotionControlPose },
],
})
}
/**
* Crops a project to a timeline range and moves the retained range to time zero.
* The function interpolates source and overlay values at both crop boundaries.
*/
export function cropLive2DMotionProject(
project: Live2DMotionProject,
startMs: number,
endMs: number,
): Live2DMotionProject {
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs < 0 || endMs > project.durationMs || startMs >= endMs)
throw new Error('The crop range is outside the motion timeline.')
const durationMs = endMs - startMs
const sourceTimes = [...new Set([
startMs,
...project.source.samples
.map(sample => sample.atMs)
.filter(atMs => atMs > startMs && atMs < endMs),
endMs,
])]
const source: Live2DMotionRecording = {
format: 'airi-live2d-motion/v6',
durationMs,
samples: sourceTimes.map(atMs => ({
atMs: atMs - startMs,
...evaluateLive2DMotionRecording(project.source, atMs),
})),
}
const overlays = project.overlays
.filter(overlay => overlay.endMs >= startMs && overlay.startMs <= endMs)
.map((overlay) => {
const croppedStartMs = Math.max(startMs, overlay.startMs)
const croppedEndMs = Math.min(endMs, overlay.endMs)
const pointTimes = overlay.points.length === 0
? []
: [...new Set([
croppedStartMs,
...overlay.points
.map(point => point.atMs)
.filter(atMs => atMs > croppedStartMs && atMs < croppedEndMs),
croppedEndMs,
])]
return {
...overlay,
startMs: croppedStartMs - startMs,
endMs: croppedEndMs - startMs,
points: pointTimes.map(atMs => ({
id: overlay.points.find(point => point.atMs === atMs)?.id ?? createLive2DMotionId(),
atMs: atMs - startMs,
value: evaluateOverlay(overlay, atMs),
})),
}
})
return {
...project,
durationMs,
source,
overlays,
}
}
export function createLive2DMotionOverlay(
trackId: Live2DMotionEditableTrackId,
durationMs: number,
atMs: number,
blendMode: Live2DMotionOverlayBlendMode = 'add',
): Live2DMotionOverlay {
if (isLive2DMotionViewTargetTrackId(trackId)) {
return {
id: createLive2DMotionId(),
name: `L${Date.now().toString(36).slice(-4).toUpperCase()}`,
trackId,
blendMode: 'replace',
weight: 1,
startMs: 0,
endMs: durationMs,
points: [
{ id: createLive2DMotionId(), atMs: 0, value: 0 },
{ id: createLive2DMotionId(), atMs: durationMs, value: 0 },
],
}
}
const defaultSpan = Math.max(250, Math.min(durationMs, durationMs / 3))
const startMs = Math.max(0, Math.min(durationMs - defaultSpan, atMs - defaultSpan / 2))
const endMs = Math.min(durationMs, startMs + defaultSpan)
const defaultValue = blendMode === 'add' ? 0 : getLive2DMotionTrackValue(neutralLive2DMotionControlPose, trackId)
return {
id: createLive2DMotionId(),
name: `L${Date.now().toString(36).slice(-4).toUpperCase()}`,
trackId,
blendMode,
weight: 1,
startMs,
endMs,
points: [
{ id: createLive2DMotionId(), atMs: startMs, value: defaultValue },
{ id: createLive2DMotionId(), atMs: endMs, value: defaultValue },
],
}
}
/** Evaluates the source, then applies active overlays in list order. */
export function evaluateLive2DMotionProject(project: Live2DMotionProject, atMs: number): Live2DMotionControlPose {
const pose = evaluateLive2DMotionRecording(project.source, atMs)
for (const overlay of project.overlays) {
if (isLive2DMotionViewTargetTrackId(overlay.trackId) || atMs < overlay.startMs || atMs > overlay.endMs || overlay.points.length === 0)
continue
const sourceValue = getLive2DMotionTrackValue(pose, overlay.trackId)
const overlayValue = evaluateOverlay(overlay, atMs)
const value = overlay.blendMode === 'add'
? sourceValue + overlayValue * overlay.weight
: sourceValue + (overlayValue - sourceValue) * overlay.weight
setLive2DMotionTrackValue(pose, overlay.trackId, clampTrackValue(overlay.trackId, value))
}
return pose
}
/** Evaluates sparse fixation keys without adding them to the dense pose recording. */
export function evaluateLive2DMotionEyeView(project: Live2DMotionProject, atMs: number): Live2DMotionEyeViewTarget | undefined {
const target: Live2DMotionEyeViewTarget = {}
for (const overlay of project.overlays) {
if (!isLive2DMotionViewTargetTrackId(overlay.trackId) || atMs < overlay.startMs || atMs > overlay.endMs || overlay.points.length === 0)
continue
const value = clampViewTarget(evaluateOverlay(overlay, atMs) * overlay.weight)
if (overlay.trackId === 'viewTargetX')
target.x = value
else
target.y = value
}
return target.x === undefined && target.y === undefined ? undefined : target
}
export function evaluateLive2DMotionEditorFrame(project: Live2DMotionProject, atMs: number): Live2DMotionEditorFrame {
return {
pose: evaluateLive2DMotionProject(project, atMs),
eyeView: evaluateLive2DMotionEyeView(project, atMs),
}
}
/** Bakes the non-destructive project into the portable dense recording format. */
export function createLive2DMotionRecordingFromProject(project: Live2DMotionProject): Live2DMotionRecording {
const sampleTimes = [...new Set([
0,
project.durationMs,
...project.source.samples.map(sample => sample.atMs),
...project.overlays
.filter(overlay => !isLive2DMotionViewTargetTrackId(overlay.trackId))
.flatMap(overlay => [overlay.startMs, overlay.endMs, ...overlay.points.map(point => point.atMs)]),
])].sort((left, right) => left - right)
return {
format: 'airi-live2d-motion/v6',
durationMs: project.durationMs,
samples: sampleTimes.map(atMs => ({ atMs, ...evaluateLive2DMotionProject(project, atMs) })),
}
}
export function getLive2DMotionSourcePoints(project: Live2DMotionProject, trackId: Live2DMotionEditableTrackId): Live2DMotionKeyframe[] {
return project.source.samples.map((sample, index) => ({
id: `source-${trackId}-${index}`,
atMs: sample.atMs,
value: isLive2DMotionViewTargetTrackId(trackId) ? 0 : getLive2DMotionTrackValue(sample, trackId),
}))
}
export function getLive2DMotionCompositePoints(project: Live2DMotionProject, trackId: Live2DMotionEditableTrackId): Live2DMotionKeyframe[] {
const times = [...new Set([
...project.source.samples.map(sample => sample.atMs),
...project.overlays
.filter(overlay => overlay.trackId === trackId)
.flatMap(overlay => [overlay.startMs, overlay.endMs, ...overlay.points.map(point => point.atMs)]),
])].sort((left, right) => left - right)
return times.map(atMs => ({
id: `composite-${trackId}-${atMs}`,
atMs,
value: isLive2DMotionViewTargetTrackId(trackId)
? (evaluateLive2DMotionEyeView(project, atMs)?.[trackId === 'viewTargetX' ? 'x' : 'y'] ?? 0)
: getLive2DMotionTrackValue(evaluateLive2DMotionProject(project, atMs), trackId),
}))
}
export function insertLive2DMotionKeyframe(points: readonly Live2DMotionKeyframe[], point: Live2DMotionKeyframe): Live2DMotionKeyframe[] {
return [...points, point].sort((left, right) => left.atMs - right.atMs)
}
export function moveLive2DMotionKeyframe(
points: readonly Live2DMotionKeyframe[],
id: string,
atMs: number,
value: number,
): Live2DMotionKeyframe[] {
return points
.map(point => point.id === id ? { ...point, atMs, value } : point)
.sort((left, right) => left.atMs - right.atMs)
}
/** Serializes a motion project with its source recording and overlays. */
export function stringifyLive2DMotionProject(project: Live2DMotionProject): string {
return `${JSON.stringify(project, null, 2)}\n`
}
/** Parses a motion project file and checks its structural and timeline invariants. */
export function parseLive2DMotionProject(raw: string): Live2DMotionProject {
let input: unknown
try {
input = JSON.parse(raw)
}
catch {
throw new Error('The file does not contain valid JSON.')
}
const result = safeParse(motionProjectSchema, input)
if (!result.success)
throw new Error('The file is not an AIRI Live2D motion project.')
const project = result.output
if (project.source.durationMs !== project.durationMs)
throw new Error('The motion project source is invalid.')
if (project.source.samples[0].atMs !== 0 || project.source.samples.at(-1)!.atMs > project.durationMs)
throw new Error('The motion project source timeline is invalid.')
for (let index = 1; index < project.source.samples.length; index++) {
if (project.source.samples[index].atMs < project.source.samples[index - 1].atMs)
throw new Error('The motion project source samples are not in time order.')
}
for (const overlay of project.overlays) {
if (overlay.endMs > project.durationMs || overlay.startMs > overlay.endMs)
throw new Error('The motion project contains an invalid overlay span.')
if (overlay.points.some(point => point.atMs < overlay.startMs || point.atMs > overlay.endMs))
throw new Error('The motion project contains an invalid overlay point.')
for (let index = 1; index < overlay.points.length; index++) {
if (overlay.points[index].atMs < overlay.points[index - 1].atMs)
throw new Error('The motion project overlay points are not in time order.')
}
}
return structuredClone(project)
}
@@ -0,0 +1,178 @@
import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { describe, expect, it, vi } from 'vitest'
import {
parseLive2DMotionRecording,
stringifyLive2DMotionRecording,
useLive2DMotionRecording,
} from './recording'
function pose(overrides: Partial<Live2DMotionControlPose> = {}): Live2DMotionControlPose {
return { ...neutralLive2DMotionControlPose, ...overrides }
}
describe('live2D motion recording', () => {
it('starts with the supplied recording', () => {
const initialRecording = parseLive2DMotionRecording(JSON.stringify({
format: 'airi-live2d-motion/v6',
durationMs: 50,
samples: [{ atMs: 0, ...pose({ headX: 0.5 }) }],
}))
const controller = useLive2DMotionRecording({
applyPose: vi.fn(),
releasePose: vi.fn(),
initialRecording,
})
expect(controller.recording.value).toEqual(initialRecording)
})
it('records changed poses with elapsed timestamps', () => {
let now = 100
const controller = useLive2DMotionRecording({
applyPose: vi.fn(),
releasePose: vi.fn(),
now: () => now,
})
controller.startRecording()
expect(controller.status.value).toEqual({ type: 'armed' })
expect(controller.recording.value).toBeNull()
now = 125
controller.recordPose(pose({ headX: 0.5, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }))
expect(controller.recording.value).toEqual({
format: 'airi-live2d-motion/v6',
durationMs: 0,
samples: [
{ atMs: 0, ...pose({ headX: 0.5, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }) },
],
})
now = 150
controller.recordPose(pose({ headX: 0.75, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }))
expect(controller.recording.value).toEqual({
format: 'airi-live2d-motion/v6',
durationMs: 25,
samples: [
{ atMs: 0, ...pose({ headX: 0.5, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }) },
{ atMs: 25, ...pose({ headX: 0.75, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }) },
],
})
now = 175
controller.recordPose(pose({ headX: 0.75, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }))
now = 200
controller.recordPose(pose())
controller.stopRecording()
expect(controller.recording.value).toEqual({
format: 'airi-live2d-motion/v6',
durationMs: 75,
samples: [
{ atMs: 0, ...pose({ headX: 0.5, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }) },
{ atMs: 25, ...pose({ headX: 0.75, headY: -0.25, headZ: -0.5, bodyZ: 0.75 }) },
{ atMs: 75, ...pose() },
],
})
})
it('keeps the existing recording when armed recording stops before input', () => {
const controller = useLive2DMotionRecording({
applyPose: vi.fn(),
releasePose: vi.fn(),
})
const existing = parseLive2DMotionRecording(JSON.stringify({
format: 'airi-live2d-motion/v6',
durationMs: 50,
samples: [{ atMs: 0, ...pose({ headX: 0.5 }) }],
}))
controller.loadRecording(existing)
controller.startRecording()
expect(controller.status.value).toEqual({ type: 'armed' })
expect(controller.recording.value).toEqual(existing)
controller.stopRecording()
expect(controller.status.value).toEqual({ type: 'idle' })
expect(controller.recording.value).toEqual(existing)
})
it('plays each due sample and releases control at the recording end', () => {
let now = 1000
let nextFrame: FrameRequestCallback | undefined
const appliedPoses: Live2DMotionControlPose[] = []
const releasePose = vi.fn()
const controller = useLive2DMotionRecording({
applyPose: pose => appliedPoses.push({ ...pose }),
releasePose,
now: () => now,
requestFrame: (callback) => {
nextFrame = callback
return 1
},
cancelFrame: vi.fn(),
})
controller.loadRecording(parseLive2DMotionRecording(JSON.stringify({
format: 'airi-live2d-motion/v6',
durationMs: 200,
samples: [
{ atMs: 0, ...pose() },
{ atMs: 50, ...pose({ headX: 0.5, headY: 0.25, headZ: -0.5, bodyZ: 0.5 }) },
{ atMs: 150, ...pose({ headX: -1, headY: 1, headZ: 1, bodyZ: -1 }) },
],
})))
controller.startPlayback()
expect(appliedPoses).toEqual([pose()])
now = 1150
nextFrame?.(now)
expect(appliedPoses).toEqual([
pose(),
pose({ headX: 0.5, headY: 0.25, headZ: -0.5, bodyZ: 0.5 }),
pose({ headX: -1, headY: 1, headZ: 1, bodyZ: -1 }),
])
now = 1200
nextFrame?.(now)
expect(releasePose).toHaveBeenCalledOnce()
expect(controller.status.value).toEqual({ type: 'idle' })
})
it('round-trips the versioned JSON format', () => {
const recording = parseLive2DMotionRecording(JSON.stringify({
format: 'airi-live2d-motion/v6',
durationMs: 50,
samples: [
{ atMs: 0, ...pose() },
{ atMs: 50, ...pose({ headX: 1, headY: -1, headZ: -1, bodyZ: 1 }) },
],
}))
expect(parseLive2DMotionRecording(stringifyLive2DMotionRecording(recording))).toEqual(recording)
})
it('rejects samples outside the normalized joystick range', () => {
expect(() => parseLive2DMotionRecording(JSON.stringify({
format: 'airi-live2d-motion/v6',
durationMs: 10,
samples: [{ atMs: 0, ...pose({ headZ: 1.1 }) }],
}))).toThrow('The file is not an AIRI Live2D motion recording.')
})
it('rejects samples that are not in time order', () => {
expect(() => parseLive2DMotionRecording(JSON.stringify({
format: 'airi-live2d-motion/v6',
durationMs: 20,
samples: [
{ atMs: 0, ...pose() },
{ atMs: 20, ...pose({ headX: 1 }) },
{ atMs: 10, ...pose() },
],
}))).toThrow('The motion samples must be in time order.')
})
})
@@ -0,0 +1,285 @@
import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import type { InferOutput } from 'valibot'
import type { DeepReadonly, ShallowRef } from 'vue'
import { array, finite, literal, maxValue, minLength, minValue, number, object, pipe, safeParse } from 'valibot'
import { readonly, shallowRef } from 'vue'
const live2dMotionSampleSchema = object({
atMs: pipe(number(), finite(), minValue(0)),
eyeX: pipe(number(), finite(), minValue(-1), maxValue(1)),
eyeY: pipe(number(), finite(), minValue(-1), maxValue(1)),
eyeSquint: pipe(number(), finite(), minValue(0), maxValue(1)),
headX: pipe(number(), finite(), minValue(-1), maxValue(1)),
headY: pipe(number(), finite(), minValue(-1), maxValue(1)),
headZ: pipe(number(), finite(), minValue(-1), maxValue(1)),
bodyX: pipe(number(), finite(), minValue(-1), maxValue(1)),
bodyY: pipe(number(), finite(), minValue(-1), maxValue(1)),
bodyZ: pipe(number(), finite(), minValue(-1), maxValue(1)),
mouthForm: pipe(number(), finite(), minValue(-1), maxValue(1)),
mouthOpen: pipe(number(), finite(), minValue(0), maxValue(1)),
offsetX: pipe(number(), finite(), minValue(-1), maxValue(1)),
offsetY: pipe(number(), finite(), minValue(-1), maxValue(1)),
})
const live2dMotionRecordingSchema = object({
format: literal('airi-live2d-motion/v6'),
durationMs: pipe(number(), finite(), minValue(0)),
samples: pipe(array(live2dMotionSampleSchema), minLength(1)),
})
/** One normalized joystick pose at an elapsed time in a motion recording. */
export type Live2DMotionSample = InferOutput<typeof live2dMotionSampleSchema>
/** A portable, versioned Live2D joystick recording. */
export type Live2DMotionRecording = InferOutput<typeof live2dMotionRecordingSchema>
/** A Live2D joystick recording exposed as immutable application state. */
export type ReadonlyLive2DMotionRecording = DeepReadonly<Live2DMotionRecording>
/** The active lifecycle state of the motion recorder. */
export type Live2DMotionRecordingStatus
= | { type: 'idle' }
| { type: 'armed' }
| { type: 'recording', startedAt: number }
| { type: 'playing', startedAt: number }
interface UseLive2DMotionRecordingOptions {
/** Applies one recorded pose to the cross-window Live2D controller. */
applyPose: (pose: Live2DMotionControlPose) => void
/** Releases the cross-window Live2D controller after playback. */
releasePose: () => void
/** Supplies the recording that is available before the first user action. @default null */
initialRecording?: Live2DMotionRecording
/** Supplies a monotonic timestamp in milliseconds. @default performance.now */
now?: () => number
/** Schedules the next playback update. @default requestAnimationFrame */
requestFrame?: (callback: FrameRequestCallback) => number
/** Cancels a scheduled playback update. @default cancelAnimationFrame */
cancelFrame?: (handle: number) => void
}
interface Live2DMotionRecordingController {
status: DeepReadonly<ShallowRef<Live2DMotionRecordingStatus>>
recording: DeepReadonly<ShallowRef<Live2DMotionRecording | null>>
startRecording: () => void
recordPose: (pose: Live2DMotionControlPose) => void
stopRecording: () => void
startPlayback: () => void
stopPlayback: () => void
loadRecording: (nextRecording: Live2DMotionRecording) => void
dispose: () => void
}
/**
* Parses and validates a Live2D joystick recording at the file boundary.
*
* @example
* parseLive2DMotionRecording('{"format":"airi-live2d-motion/v6", ...}')
* // => a validated recording
*/
export function parseLive2DMotionRecording(raw: string): Live2DMotionRecording {
let input: unknown
try {
input = JSON.parse(raw)
}
catch {
throw new Error('The file does not contain valid JSON.')
}
const result = safeParse(live2dMotionRecordingSchema, input)
if (!result.success)
throw new Error('The file is not an AIRI Live2D motion recording.')
const { durationMs, samples } = result.output
if (samples[0].atMs !== 0)
throw new Error('The first motion sample must start at 0 ms.')
for (let index = 1; index < samples.length; index++) {
if (samples[index].atMs < samples[index - 1].atMs)
throw new Error('The motion samples must be in time order.')
}
if (samples.at(-1)!.atMs > durationMs)
throw new Error('A motion sample occurs after the recording duration.')
return result.output
}
/**
* Serializes a Live2D joystick recording as a readable JSON file.
*
* @example
* stringifyLive2DMotionRecording({ format: 'airi-live2d-motion/v6', ... })
* // => readable JSON ending with a newline
*/
export function stringifyLive2DMotionRecording(recording: ReadonlyLive2DMotionRecording): string {
return `${JSON.stringify(recording, null, 2)}\n`
}
/** Owns one in-memory Live2D motion recording and its playback lifecycle. */
export function useLive2DMotionRecording(
options: UseLive2DMotionRecordingOptions,
): Live2DMotionRecordingController {
const now = options.now ?? (() => performance.now())
const requestFrame = options.requestFrame ?? (callback => requestAnimationFrame(callback))
const cancelFrame = options.cancelFrame ?? (handle => cancelAnimationFrame(handle))
const status = shallowRef<Live2DMotionRecordingStatus>({ type: 'idle' })
const recording = shallowRef<Live2DMotionRecording | null>(options.initialRecording ?? null)
let capturedSamples: Live2DMotionSample[] = []
let playbackFrame: number | undefined
let playbackSampleIndex = 0
function publishCapturedRecording(durationMs: number) {
recording.value = {
format: 'airi-live2d-motion/v6',
durationMs,
samples: [...capturedSamples],
}
}
function stopPlayback() {
if (status.value.type !== 'playing')
return
if (playbackFrame !== undefined)
cancelFrame(playbackFrame)
playbackFrame = undefined
playbackSampleIndex = 0
status.value = { type: 'idle' }
options.releasePose()
}
function startRecording() {
if (status.value.type !== 'idle')
return
capturedSamples = []
status.value = { type: 'armed' }
}
function recordPose(pose: Live2DMotionControlPose) {
if (status.value.type === 'armed') {
capturedSamples = [{
atMs: 0,
...pose,
}]
status.value = { type: 'recording', startedAt: now() }
publishCapturedRecording(0)
return
}
if (status.value.type !== 'recording')
return
const atMs = Math.max(0, Math.round(now() - status.value.startedAt))
const nextSample: Live2DMotionSample = {
atMs,
...pose,
}
const previousSample = capturedSamples.at(-1)
if (previousSample && Object.entries(pose).every(([axis, value]) => previousSample[axis as keyof Live2DMotionControlPose] === value)) {
publishCapturedRecording(atMs)
return
}
if (previousSample?.atMs === atMs) {
capturedSamples[capturedSamples.length - 1] = nextSample
publishCapturedRecording(atMs)
return
}
capturedSamples.push(nextSample)
publishCapturedRecording(atMs)
}
function stopRecording() {
if (status.value.type === 'armed') {
capturedSamples = []
status.value = { type: 'idle' }
return
}
if (status.value.type !== 'recording')
return
const durationMs = Math.max(
Math.round(now() - status.value.startedAt),
capturedSamples.at(-1)?.atMs ?? 0,
)
publishCapturedRecording(durationMs)
capturedSamples = []
status.value = { type: 'idle' }
}
function finishPlayback() {
playbackFrame = undefined
playbackSampleIndex = 0
status.value = { type: 'idle' }
options.releasePose()
}
function updatePlayback() {
if (status.value.type !== 'playing' || !recording.value)
return
playbackFrame = undefined
const elapsedMs = Math.max(0, now() - status.value.startedAt)
while (
playbackSampleIndex < recording.value.samples.length
&& recording.value.samples[playbackSampleIndex].atMs <= elapsedMs
) {
const sample = recording.value.samples[playbackSampleIndex]
const { atMs: _atMs, ...pose } = sample
options.applyPose({
...pose,
})
playbackSampleIndex++
}
if (elapsedMs >= recording.value.durationMs) {
finishPlayback()
return
}
playbackFrame = requestFrame(updatePlayback)
}
function startPlayback() {
if (status.value.type !== 'idle' || !recording.value)
return
playbackSampleIndex = 0
status.value = { type: 'playing', startedAt: now() }
updatePlayback()
}
function loadRecording(nextRecording: Live2DMotionRecording) {
if (status.value.type !== 'idle')
return
recording.value = nextRecording
}
function dispose() {
if (status.value.type === 'playing')
stopPlayback()
capturedSamples = []
status.value = { type: 'idle' }
}
return {
status: readonly(status),
recording: readonly(recording),
startRecording,
recordPose,
stopRecording,
startPlayback,
stopPlayback,
loadRecording,
dispose,
}
}
@@ -0,0 +1,138 @@
import type {
StandardGamepadButtonName,
StandardGamepadButtonState,
StandardGamepadSnapshot,
} from '@proj-airi/input-gamepad'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { effectScope, shallowRef } from 'vue'
import { useLive2DMotionGamepadActions } from './use-gamepad-actions'
const buttonNames: readonly StandardGamepadButtonName[] = [
'dpadDown',
'dpadLeft',
'dpadRight',
'dpadUp',
'faceBottom',
'faceLeft',
'faceRight',
'faceTop',
'leftShoulder',
'leftStick',
'leftTrigger',
'rightShoulder',
'rightStick',
'rightTrigger',
'select',
'start',
]
describe('useLive2DMotionGamepadActions', () => {
afterEach(() => {
vi.useRealTimers()
})
function setup() {
const snapshot = shallowRef<StandardGamepadSnapshot>()
const actions = {
clearTimeline: vi.fn(),
disabled: () => false,
goToEnd: vi.fn(),
goToStart: vi.fn(),
play: vi.fn(),
restartRecording: vi.fn(),
selectTrack: vi.fn<(offset: -1 | 1) => void>(),
snapshot: () => snapshot.value,
stepBackward: vi.fn<(steps: number) => void>(),
stepForward: vi.fn<(steps: number) => void>(),
stop: vi.fn(),
}
const scope = effectScope()
scope.run(() => useLive2DMotionGamepadActions(actions))
return { actions, scope, snapshot }
}
it('runs face-button actions only on the pressed edge', () => {
const { actions, scope, snapshot } = setup()
snapshot.value = createSnapshot({ faceBottom: 1 })
snapshot.value = createSnapshot({ faceBottom: 1 })
expect(actions.play).toHaveBeenCalledOnce()
snapshot.value = createSnapshot()
snapshot.value = createSnapshot({ faceBottom: 1 })
expect(actions.play).toHaveBeenCalledTimes(2)
snapshot.value = createSnapshot({ faceRight: 1 })
expect(actions.stop).toHaveBeenCalledOnce()
scope.stop()
})
it('maps shoulder and directional-pad combinations', () => {
const { actions, scope, snapshot } = setup()
snapshot.value = createSnapshot({ leftShoulder: 1, dpadLeft: 1 })
expect(actions.goToStart).toHaveBeenCalledOnce()
snapshot.value = createSnapshot()
snapshot.value = createSnapshot({ leftShoulder: 1, dpadRight: 1 })
expect(actions.goToEnd).toHaveBeenCalledOnce()
snapshot.value = createSnapshot()
snapshot.value = createSnapshot({ leftShoulder: 1, dpadUp: 1 })
expect(actions.restartRecording).toHaveBeenCalledOnce()
snapshot.value = createSnapshot()
snapshot.value = createSnapshot({ leftShoulder: 1, dpadDown: 1 })
expect(actions.clearTimeline).toHaveBeenCalledOnce()
snapshot.value = createSnapshot()
snapshot.value = createSnapshot({ rightShoulder: 1, dpadUp: 1 })
snapshot.value = createSnapshot()
snapshot.value = createSnapshot({ rightShoulder: 1, dpadDown: 1 })
expect(actions.selectTrack).toHaveBeenNthCalledWith(1, -1)
expect(actions.selectTrack).toHaveBeenNthCalledWith(2, 1)
scope.stop()
})
it('steps once on a tap and accelerates after the hold delay', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const { actions, scope, snapshot } = setup()
snapshot.value = createSnapshot({ rightShoulder: 1, dpadLeft: 1 })
vi.advanceTimersByTime(200)
snapshot.value = createSnapshot()
expect(actions.stepBackward).toHaveBeenCalledOnce()
expect(actions.stepBackward).toHaveBeenLastCalledWith(1)
snapshot.value = createSnapshot({ rightShoulder: 1, dpadRight: 1 })
vi.advanceTimersByTime(300)
expect(actions.stepForward).toHaveBeenCalledWith(6)
vi.advanceTimersByTime(800)
expect(actions.stepForward.mock.calls.at(-1)?.[0]).toBeGreaterThan(5)
const callCountBeforeRelease = actions.stepForward.mock.calls.length
snapshot.value = createSnapshot()
expect(actions.stepForward).toHaveBeenCalledTimes(callCountBeforeRelease)
scope.stop()
})
})
function createSnapshot(pressed: Partial<Record<StandardGamepadButtonName, number>> = {}): StandardGamepadSnapshot {
const buttons = Object.fromEntries(buttonNames.map((name): [StandardGamepadButtonName, StandardGamepadButtonState] => {
const value = pressed[name] ?? 0
return [name, { pressed: value > 0, touched: value > 0, value }]
})) as Record<StandardGamepadButtonName, StandardGamepadButtonState>
return {
buttons,
family: 'playstation',
id: 'DualSense Wireless Controller',
index: 0,
leftStick: { x: 0, y: 0 },
rightStick: { x: 0, y: 0 },
timestamp: 0,
}
}
@@ -0,0 +1,162 @@
import type { StandardGamepadSnapshot } from '@proj-airi/input-gamepad'
import { tryOnBeforeUnmount, useIntervalFn, useTimeoutFn } from '@vueuse/core'
import { watch } from 'vue'
interface UseLive2DMotionGamepadActionsOptions {
clearTimeline: () => void
disabled: () => boolean
goToEnd: () => void
goToStart: () => void
play: () => void
restartRecording: () => void
selectTrack: (offset: -1 | 1) => void
snapshot: () => StandardGamepadSnapshot | undefined
stepBackward: (steps: number) => void
stepForward: (steps: number) => void
stop: () => void
}
const holdSeekPolicy = Object.freeze({
accelerationWindowMs: 600,
baseSteps: 5,
delayMs: 300,
intervalMs: 80,
})
interface DigitalActions {
clearTimeline: boolean
goToEnd: boolean
goToStart: boolean
play: boolean
restartRecording: boolean
selectNextTrack: boolean
selectPreviousTrack: boolean
stop: boolean
}
const releasedActions: Readonly<DigitalActions> = Object.freeze({
clearTimeline: false,
goToEnd: false,
goToStart: false,
play: false,
restartRecording: false,
selectNextTrack: false,
selectPreviousTrack: false,
stop: false,
})
/**
* Maps standard controller buttons to Live2D editor actions.
* Edge actions run once. Held frame-step actions repeat and accelerate.
*/
export function useLive2DMotionGamepadActions(options: UseLive2DMotionGamepadActionsOptions): void {
let previousActions = releasedActions
let activeDirection: -1 | 0 | 1 = 0
let holdStarted = false
let pressedAt = 0
const holdInterval = useIntervalFn(() => {
if (activeDirection !== 0)
step(activeDirection, heldStepCount())
}, holdSeekPolicy.intervalMs, { immediate: false })
const holdDelay = useTimeoutFn(() => {
if (activeDirection === 0)
return
holdStarted = true
step(activeDirection, heldStepCount())
holdInterval.resume()
}, holdSeekPolicy.delayMs, { immediate: false })
function heldStepCount(): number {
const elapsedMs = Date.now() - pressedAt
const elapsedWindows = elapsedMs / holdSeekPolicy.accelerationWindowMs
return Math.max(
holdSeekPolicy.baseSteps,
Math.round(holdSeekPolicy.baseSteps * (1 + elapsedWindows ** 2)),
)
}
function step(direction: -1 | 1, steps: number): void {
if (direction === -1) {
options.stepBackward(steps)
return
}
options.stepForward(steps)
}
function clearDirection(commitTap: boolean): void {
const previousDirection = activeDirection
const shouldStepOnce = commitTap && previousDirection !== 0 && !holdStarted
activeDirection = 0
holdStarted = false
holdDelay.stop()
holdInterval.pause()
if (shouldStepOnce)
step(previousDirection, 1)
}
function updateDirection(nextDirection: -1 | 0 | 1): void {
if (activeDirection === nextDirection)
return
clearDirection(true)
if (nextDirection === 0)
return
activeDirection = nextDirection
pressedAt = Date.now()
holdDelay.start()
}
function runEdgeActions(actions: DigitalActions): void {
if (actions.play && !previousActions.play)
options.play()
if (actions.stop && !previousActions.stop)
options.stop()
if (actions.goToStart && !previousActions.goToStart)
options.goToStart()
if (actions.goToEnd && !previousActions.goToEnd)
options.goToEnd()
if (actions.restartRecording && !previousActions.restartRecording)
options.restartRecording()
if (actions.clearTimeline && !previousActions.clearTimeline)
options.clearTimeline()
if (actions.selectPreviousTrack && !previousActions.selectPreviousTrack)
options.selectTrack(-1)
if (actions.selectNextTrack && !previousActions.selectNextTrack)
options.selectTrack(1)
previousActions = actions
}
function handleSnapshot(snapshot: StandardGamepadSnapshot | undefined): void {
if (!snapshot || options.disabled()) {
previousActions = releasedActions
clearDirection(false)
return
}
const buttons = snapshot.buttons
const leftShoulder = buttons.leftShoulder.pressed && !buttons.rightShoulder.pressed
const rightShoulder = buttons.rightShoulder.pressed && !buttons.leftShoulder.pressed
const actions: DigitalActions = {
clearTimeline: leftShoulder && buttons.dpadDown.pressed,
goToEnd: leftShoulder && buttons.dpadRight.pressed,
goToStart: leftShoulder && buttons.dpadLeft.pressed,
play: buttons.faceBottom.pressed,
restartRecording: leftShoulder && buttons.dpadUp.pressed,
selectNextTrack: rightShoulder && buttons.dpadDown.pressed,
selectPreviousTrack: rightShoulder && buttons.dpadUp.pressed,
stop: buttons.faceRight.pressed,
}
runEdgeActions(actions)
const backward = rightShoulder && buttons.dpadLeft.pressed
const forward = rightShoulder && buttons.dpadRight.pressed
updateDirection(backward === forward ? 0 : backward ? -1 : 1)
}
watch(options.snapshot, handleSnapshot, { flush: 'sync' })
tryOnBeforeUnmount(() => clearDirection(false))
}
@@ -0,0 +1,175 @@
import type { Mock } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from 'vitest-browser-vue'
import { defineComponent, h, nextTick, shallowRef } from 'vue'
import { useLive2DMotionPlaybackKeys } from './use-playback-keys'
interface KeyboardCallbacks {
goToEnd: Mock<() => void>
goToStart: Mock<() => void>
pause: Mock<() => void>
play: Mock<() => void>
stepBackward: Mock<(steps: number) => void>
stepForward: Mock<(steps: number) => void>
}
function createKeyboardCallbacks(): KeyboardCallbacks {
return {
goToEnd: vi.fn<() => void>(),
goToStart: vi.fn<() => void>(),
pause: vi.fn<() => void>(),
play: vi.fn<() => void>(),
stepBackward: vi.fn<(steps: number) => void>(),
stepForward: vi.fn<(steps: number) => void>(),
}
}
async function renderKeyboardHarness(callbacks: KeyboardCallbacks) {
const disabled = shallowRef(false)
const playing = shallowRef(false)
const Harness = defineComponent({
setup() {
useLive2DMotionPlaybackKeys({
disabled: () => disabled.value,
goToEnd: callbacks.goToEnd,
goToStart: callbacks.goToStart,
isPlaying: () => playing.value,
pause: callbacks.pause,
play: callbacks.play,
stepBackward: callbacks.stepBackward,
stepForward: callbacks.stepForward,
})
return () => h('input', { 'aria-label': 'Motion value' })
},
})
const screen = await render(Harness)
return { disabled, playing, screen }
}
async function dispatchKey(
type: 'keydown' | 'keyup',
key: string,
code: string,
modifiers: Pick<KeyboardEventInit, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'> = {},
target: EventTarget = window,
): Promise<KeyboardEvent> {
const event = new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
code,
key,
...modifiers,
})
target.dispatchEvent(event)
await nextTick()
return event
}
async function pressModifier(key: 'Alt' | 'Shift'): Promise<void> {
await dispatchKey('keydown', key, `${key}Left`, {
altKey: key === 'Alt',
shiftKey: key === 'Shift',
})
}
async function releaseModifier(key: 'Alt' | 'Shift'): Promise<void> {
await dispatchKey('keyup', key, `${key}Left`)
}
describe('useLive2DMotionPlaybackKeys', () => {
afterEach(() => {
// ROOT CAUSE:
//
// Each harness owns window listeners. Without cleanup, earlier harnesses
// can prevent a later test's keyboard event after that test disables shortcuts.
cleanup()
vi.useRealTimers()
})
it('maps playback and jump shortcuts to their commands', async () => {
const callbacks = createKeyboardCallbacks()
const { playing } = await renderKeyboardHarness(callbacks)
await pressModifier('Shift')
const playEvent = await dispatchKey('keydown', ' ', 'Space', { shiftKey: true })
expect(playEvent.defaultPrevented).toBe(true)
expect(callbacks.play).toHaveBeenCalledOnce()
await dispatchKey('keyup', ' ', 'Space', { shiftKey: true })
await releaseModifier('Shift')
playing.value = true
await pressModifier('Shift')
await dispatchKey('keydown', ' ', 'Space', { shiftKey: true })
expect(callbacks.pause).toHaveBeenCalledOnce()
await dispatchKey('keyup', ' ', 'Space', { shiftKey: true })
await releaseModifier('Shift')
await pressModifier('Alt')
await dispatchKey('keydown', 'a', 'KeyA', { altKey: true })
expect(callbacks.goToStart).toHaveBeenCalledOnce()
await dispatchKey('keyup', 'a', 'KeyA', { altKey: true })
await dispatchKey('keydown', 'd', 'KeyD', { altKey: true })
expect(callbacks.goToEnd).toHaveBeenCalledOnce()
await dispatchKey('keyup', 'd', 'KeyD', { altKey: true })
await releaseModifier('Alt')
})
it('moves one frame on a tap and accelerates a held seek', async () => {
vi.useFakeTimers()
const callbacks = createKeyboardCallbacks()
await renderKeyboardHarness(callbacks)
await pressModifier('Shift')
await dispatchKey('keydown', 'a', 'KeyA', { shiftKey: true })
await dispatchKey('keyup', 'a', 'KeyA', { shiftKey: true })
expect(callbacks.stepBackward).toHaveBeenCalledWith(1)
await dispatchKey('keydown', 'd', 'KeyD', { shiftKey: true })
await vi.advanceTimersByTimeAsync(300)
expect(callbacks.stepForward).toHaveBeenCalledTimes(1)
const firstHeldStep = callbacks.stepForward.mock.calls[0]?.[0]
expect(firstHeldStep).toBeGreaterThanOrEqual(5)
await vi.advanceTimersByTimeAsync(600)
const lastHeldStep = callbacks.stepForward.mock.calls.at(-1)?.[0]
expect(lastHeldStep).toBeGreaterThan(firstHeldStep)
await dispatchKey('keyup', 'd', 'KeyD', { shiftKey: true })
const callsAfterRelease = callbacks.stepForward.mock.calls.length
await vi.advanceTimersByTimeAsync(300)
expect(callbacks.stepForward).toHaveBeenCalledTimes(callsAfterRelease)
await releaseModifier('Shift')
})
it('leaves shortcuts available to text entry and ignores them when disabled', async () => {
const callbacks = createKeyboardCallbacks()
const { disabled, screen } = await renderKeyboardHarness(callbacks)
const input = screen.getByRole('textbox')
await input.click()
const inputElement = document.activeElement
expect(inputElement).toBeInstanceOf(HTMLInputElement)
await dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }, inputElement ?? window)
const inputEvent = await dispatchKey('keydown', ' ', 'Space', { shiftKey: true }, inputElement ?? window)
expect(inputEvent.defaultPrevented).toBe(false)
expect(callbacks.play).not.toHaveBeenCalled()
await dispatchKey('keyup', ' ', 'Space', { shiftKey: true }, inputElement ?? window)
await dispatchKey('keyup', 'Shift', 'ShiftLeft', {}, inputElement ?? window)
if (inputElement instanceof HTMLElement)
inputElement.blur()
await nextTick()
disabled.value = true
await nextTick()
await pressModifier('Shift')
const disabledEvent = await dispatchKey('keydown', ' ', 'Space', { shiftKey: true })
expect(disabledEvent.defaultPrevented).toBe(false)
expect(callbacks.play).not.toHaveBeenCalled()
await dispatchKey('keyup', ' ', 'Space', { shiftKey: true })
await releaseModifier('Shift')
})
})
@@ -0,0 +1,177 @@
import {
tryOnBeforeUnmount,
useActiveElement,
useEventListener,
useIntervalFn,
useMagicKeys,
useTimeoutFn,
whenever,
} from '@vueuse/core'
import { computed, watch } from 'vue'
interface Live2DMotionPlaybackKeysOptions {
disabled: () => boolean
goToEnd: () => void
goToStart: () => void
isPlaying: () => boolean
pause: () => void
play: () => void
stepBackward: (steps: number) => void
stepForward: (steps: number) => void
}
const holdSeekPolicy = Object.freeze({
accelerationWindowMs: 600,
baseSteps: 5,
delayMs: 300,
intervalMs: 80,
})
/**
* Binds the Live2D motion playback controls to window-level keyboard shortcuts.
* Tap commands move one frame. Held seek commands start after a short delay and
* accelerate until the user releases the shortcut. Text entry keeps ownership
* of matching key combinations.
*/
export function useLive2DMotionPlaybackKeys(options: Live2DMotionPlaybackKeysOptions): void {
const activeElement = useActiveElement()
// NOTICE:
// useMagicKeys updates its key refs before it calls onEventFired.
// The shortcut refs and browser default handling therefore share one event.
// Source: https://github.com/vueuse/vueuse/blob/main/packages/core/useMagicKeys/index.ts
// Remove this handler when useMagicKeys supports exact declarative shortcuts.
const keys = useMagicKeys({
passive: false,
onEventFired(event) {
if (!options.disabled() && !isTextEntry(event.target) && isPlaybackShortcut(event))
event.preventDefault()
},
})
const shortcutsEnabled = computed(() => !options.disabled() && !isTextEntry(activeElement.value))
const hasNoCommandModifier = computed(() => !keys.ctrl.value && !keys.meta.value)
const playbackPressed = computed(() => shortcutsEnabled.value
&& hasNoCommandModifier.value
&& keys.shift.value
&& keys.space.value
&& !keys.alt.value)
const backwardPressed = computed(() => shortcutsEnabled.value
&& hasNoCommandModifier.value
&& keys.shift.value
&& keys.a.value
&& !keys.alt.value)
const forwardPressed = computed(() => shortcutsEnabled.value
&& hasNoCommandModifier.value
&& keys.shift.value
&& keys.d.value
&& !keys.alt.value)
const startPressed = computed(() => shortcutsEnabled.value
&& hasNoCommandModifier.value
&& keys.alt.value
&& keys.a.value
&& !keys.shift.value)
const endPressed = computed(() => shortcutsEnabled.value
&& hasNoCommandModifier.value
&& keys.alt.value
&& keys.d.value
&& !keys.shift.value)
let activeDirection: -1 | 0 | 1 = 0
let holdStarted = false
let pressedAt = 0
const holdInterval = useIntervalFn(() => {
if (activeDirection !== 0)
step(activeDirection, heldStepCount())
}, holdSeekPolicy.intervalMs, { immediate: false })
const holdDelay = useTimeoutFn(() => {
if (activeDirection === 0)
return
holdStarted = true
step(activeDirection, heldStepCount())
holdInterval.resume()
}, holdSeekPolicy.delayMs, { immediate: false })
function heldStepCount(): number {
const elapsedMs = Date.now() - pressedAt
const elapsedWindows = elapsedMs / holdSeekPolicy.accelerationWindowMs
return Math.max(
holdSeekPolicy.baseSteps,
Math.round(holdSeekPolicy.baseSteps * (1 + elapsedWindows ** 2)),
)
}
function step(direction: -1 | 1, steps: number): void {
if (direction === -1) {
options.stepBackward(steps)
return
}
options.stepForward(steps)
}
function clearDirection(): void {
activeDirection = 0
holdStarted = false
holdDelay.stop()
holdInterval.pause()
}
function updateDirection(direction: -1 | 1, pressed: boolean): void {
if (pressed) {
if (activeDirection !== 0)
return
activeDirection = direction
holdStarted = false
pressedAt = Date.now()
holdDelay.start()
return
}
if (activeDirection !== direction)
return
const shouldStepOnce = !holdStarted && shortcutsEnabled.value
clearDirection()
if (shouldStepOnce)
step(direction, 1)
}
whenever(playbackPressed, () => {
if (options.isPlaying()) {
options.pause()
return
}
options.play()
})
whenever(startPressed, options.goToStart)
whenever(endPressed, options.goToEnd)
watch(backwardPressed, pressed => updateDirection(-1, pressed))
watch(forwardPressed, pressed => updateDirection(1, pressed))
watch(shortcutsEnabled, (enabled) => {
if (!enabled)
clearDirection()
})
useEventListener(window, 'blur', clearDirection)
tryOnBeforeUnmount(clearDirection)
}
function isPlaybackShortcut(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.metaKey)
return false
const key = event.key.toLowerCase()
const isPlaybackToggle = event.shiftKey && !event.altKey && event.code === 'Space'
const isStep = event.shiftKey && !event.altKey && (key === 'a' || key === 'd')
const isJump = event.altKey && !event.shiftKey && (key === 'a' || key === 'd')
return isPlaybackToggle || isStep || isJump
}
function isTextEntry(target: EventTarget | null | undefined): boolean {
return target instanceof HTMLElement
&& (target.isContentEditable || target.matches('input, textarea, select'))
}
@@ -0,0 +1,413 @@
<script setup lang="ts">
import type { StandardGamepadSnapshot } from '@proj-airi/input-gamepad'
import type { Live2DBreathControlOptions, Live2DMotionControlDynamics, Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import type { Live2DMotionEditorFrame } from './composables/keyframes'
import { defaultLive2DBreathControlOptions, defaultLive2DMotionControlDynamics, neutralLive2DMotionControlPose, useLive2DMotionControl } from '@proj-airi/stage-ui-live2d/stores'
import { BasicButton, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BreathControl from './components/breath-control.vue'
import EyeViewControl from './components/eye-view-control.vue'
import Joystick from './components/joystick.vue'
import KeyframeEditor from './components/keyframe-editor.vue'
import Preview from './components/preview.vue'
import ProceduralMotion from './components/procedural-motion.vue'
import Workbench from './components/workbench.vue'
import { useSystemAudioLipSyncStore } from '../../../../stores/system-audio-lipsync'
import { applyLive2DMotionViewTarget, defaultLive2DMotionViewTargetState } from '../../../motions/live2d'
import { defaultLive2DMotionRecording } from './composables/default-recording'
import { createLive2DMotionId } from './composables/keyframes'
import { useLive2DMotionRecording } from './composables/recording'
interface Props {
gamepad?: StandardGamepadSnapshot
}
const props = defineProps<Props>()
const { t } = useI18n()
const motionControl = useLive2DMotionControl()
const systemAudio = useSystemAudioLipSyncStore()
const {
available: systemAudioAvailable,
isRequested: systemAudioRequested,
isStarting: systemAudioStarting,
error: systemAudioError,
inputLevel: systemAudioLevel,
inputVolumeThreshold: systemAudioVolumeThreshold,
mouthOpen: systemAudioMouthOpen,
randomCloseDelayMs: systemAudioRandomCloseDelayMs,
randomCloseProbability: systemAudioRandomCloseProbability,
} = storeToRefs(systemAudio)
const ownerId = createLive2DMotionId()
const neutralPose = neutralLive2DMotionControlPose
const sourcePose = shallowRef<Live2DMotionControlPose>(neutralPose)
const pose = shallowRef<Live2DMotionControlPose>(neutralPose)
const dynamics = shallowRef<Live2DMotionControlDynamics>(defaultLive2DMotionControlDynamics)
const eyeView = shallowRef(defaultLive2DMotionViewTargetState)
const sourceActive = shallowRef(false)
const active = shallowRef(false)
const editorPlaying = shallowRef(false)
const proceduralMotionPlaying = shallowRef(false)
const breathEnabled = shallowRef(true)
const breathOptions = shallowRef<Live2DBreathControlOptions>({ ...defaultLive2DBreathControlOptions })
const breathStartedAtMs = shallowRef(Date.now())
function publishComposedPose(nextPose: Live2DMotionControlPose, nextEyeView = eyeView.value) {
const resolvedPose = systemAudioRequested.value
? { ...nextPose, mouthOpen: systemAudioMouthOpen.value }
: nextPose
sourcePose.value = resolvedPose
sourceActive.value = true
pose.value = applyLive2DMotionViewTarget(resolvedPose, nextEyeView)
active.value = true
motionControl.setPose(ownerId, pose.value, dynamics.value)
}
function publishPose(nextPose: Live2DMotionControlPose) {
publishComposedPose(nextPose)
}
function publishSystemAudioMouth(mouthOpen: number) {
const nextPose = { ...sourcePose.value, mouthOpen }
pose.value = applyLive2DMotionViewTarget(nextPose, eyeView.value)
active.value = true
motionControl.setPose(ownerId, pose.value, dynamics.value)
}
async function startSystemAudioLipSync() {
await systemAudio.start()
}
async function stopSystemAudioLipSync() {
await systemAudio.stop()
publishRelease()
}
function formatSystemAudioThreshold(value: number): string {
return value.toFixed(2)
}
function formatSystemAudioDelay(value: number): string {
return `${value} ms`
}
function formatSystemAudioProbability(value: number): string {
return `${Math.round(value * 100)}%`
}
async function toggleSystemAudioLipSync() {
if (systemAudioRequested.value) {
await stopSystemAudioLipSync()
return
}
await startSystemAudioLipSync()
}
watch(systemAudioMouthOpen, (value) => {
if (systemAudioRequested.value)
publishSystemAudioMouth(value)
})
watch(
[systemAudioVolumeThreshold, systemAudioRandomCloseDelayMs, systemAudioRandomCloseProbability],
() => systemAudio.updateOptions(),
{ immediate: true },
)
function setDynamics(nextDynamics: Live2DMotionControlDynamics) {
dynamics.value = nextDynamics
if (active.value)
motionControl.setPose(ownerId, pose.value, nextDynamics)
}
function publishRelease() {
sourcePose.value = neutralPose
sourceActive.value = false
if (eyeView.value.enabled) {
pose.value = applyLive2DMotionViewTarget(neutralPose, eyeView.value)
active.value = true
motionControl.setPose(ownerId, pose.value, dynamics.value)
return
}
pose.value = neutralPose
active.value = false
motionControl.release(ownerId)
}
function updateBreathEnabled(enabled: boolean) {
breathEnabled.value = enabled
if (!enabled) {
motionControl.releaseBreath(ownerId)
return
}
breathStartedAtMs.value = Date.now()
motionControl.setBreath(ownerId, breathOptions.value, breathStartedAtMs.value)
}
function updateBreathOptions(nextOptions: Live2DBreathControlOptions) {
const options = { ...nextOptions }
if (options.minimum > options.maximum)
options.maximum = options.minimum
breathOptions.value = options
if (breathEnabled.value)
motionControl.setBreath(ownerId, options, breathStartedAtMs.value)
}
function resetBreath() {
breathStartedAtMs.value = Date.now()
if (breathEnabled.value)
motionControl.setBreath(ownerId, breathOptions.value, breathStartedAtMs.value)
}
function updateProceduralMotionPlayback(playing: boolean) {
proceduralMotionPlaying.value = playing
}
function updateEyeView(nextView: typeof eyeView.value) {
eyeView.value = nextView
if (!sourceActive.value && !nextView.enabled) {
pose.value = neutralPose
active.value = false
motionControl.release(ownerId)
return
}
pose.value = applyLive2DMotionViewTarget(sourcePose.value, nextView)
active.value = true
motionControl.setPose(ownerId, pose.value, dynamics.value)
}
const recordingController = useLive2DMotionRecording({
applyPose: publishPose,
releasePose: publishRelease,
initialRecording: defaultLive2DMotionRecording,
})
/**
* Releases the exclusive claim before the Electron renderer exits.
*
* Triggering workflow:
*
* Electron BrowserWindow
* -> window.addEventListener
* -> `pagehide`
* -> handlePageHide
*
* Upstream:
* - The Electron renderer page lifecycle.
*
* Downstream:
* - The `releaseExclusiveControl` action from {@link useLive2DMotionControl}.
*/
function handlePageHide() {
motionControl.releaseExclusiveControl(ownerId)
}
function setPose(nextPose: Live2DMotionControlPose) {
publishPose(nextPose)
recordingController.recordPose(nextPose)
}
function publishEditorFrame(frame: Live2DMotionEditorFrame) {
const frameEyeView = frame.eyeView
? { ...eyeView.value, ...frame.eyeView }
: eyeView.value
publishComposedPose(frame.pose, frameEyeView)
}
function release() {
publishRelease()
recordingController.recordPose(neutralPose)
}
function toggleRecording() {
if (recordingController.status.value.type === 'armed' || recordingController.status.value.type === 'recording') {
recordingController.stopRecording()
return
}
recordingController.startRecording()
}
onMounted(() => {
window.addEventListener('pagehide', handlePageHide)
motionControl.claimExclusiveControl(ownerId)
motionControl.setBreath(ownerId, breathOptions.value, breathStartedAtMs.value)
})
function restartRecording() {
if (recordingController.status.value.type === 'armed' || recordingController.status.value.type === 'recording')
recordingController.stopRecording()
recordingController.startRecording()
}
onUnmounted(() => {
window.removeEventListener('pagehide', handlePageHide)
void systemAudio.stop()
recordingController.dispose()
motionControl.releaseBreath(ownerId)
motionControl.release(ownerId)
motionControl.releaseExclusiveControl(ownerId)
})
</script>
<template>
<div :class="['min-h-0 flex-1 p-2']">
<Workbench>
<template #direct-control>
<div :class="['flex flex-col gap-4']">
<section
v-if="systemAudioAvailable"
:class="[
'rounded-xl bg-neutral-100/70 p-3',
'dark:bg-neutral-900/55',
]"
>
<div :class="['flex items-start justify-between gap-3']">
<div :class="['min-w-0']">
<div :class="['text-xs font-semibold text-neutral-900 dark:text-neutral-100']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.title') }}
</div>
<p :class="['mt-1 text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.description') }}
</p>
</div>
<BasicButton
size="sm"
:disabled="systemAudioStarting"
@click="toggleSystemAudioLipSync"
>
{{ systemAudioRequested
? t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.actions.stop')
: t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.actions.start') }}
</BasicButton>
</div>
<div :class="['mt-3 grid gap-3 rounded-lg bg-neutral-100/80 p-3 text-xs dark:bg-neutral-800/70']">
<FieldRange
v-model="systemAudioVolumeThreshold"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.input-volume-threshold')"
:min="0"
:max="1"
:step="0.01"
:format-value="formatSystemAudioThreshold"
/>
<FieldRange
v-model="systemAudioRandomCloseDelayMs"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.random-close-delay')"
:min="100"
:max="1000"
:step="10"
:format-value="formatSystemAudioDelay"
/>
<FieldRange
v-model="systemAudioRandomCloseProbability"
:label="t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.random-close-probability')"
:min="0"
:max="1"
:step="0.01"
:format-value="formatSystemAudioProbability"
/>
</div>
<div :class="['mt-3 grid grid-cols-2 gap-2 text-xs']">
<div :class="['rounded-lg bg-neutral-100/80 p-2 dark:bg-neutral-800/70']">
<div :class="['text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.signal') }}
</div>
<div :class="['mt-1 h-1.5 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700']">
<div
:class="['h-full rounded-full bg-primary-500 transition-[width] duration-75']"
:style="{ width: `${systemAudioLevel * 100}%` }"
/>
</div>
<div :class="['mt-1 font-mono text-neutral-700 tabular-nums dark:text-neutral-200']">
{{ systemAudioLevel.toFixed(3) }}
</div>
</div>
<div :class="['rounded-lg bg-neutral-100/80 p-2 dark:bg-neutral-800/70']">
<div :class="['text-neutral-500 dark:text-neutral-400']">
{{ t('tamagotchi.settings.devtools.pages.live2d-motion.system-audio.mouth-open') }}
</div>
<div :class="['mt-2 font-mono text-neutral-700 tabular-nums dark:text-neutral-200']">
{{ systemAudioMouthOpen.toFixed(3) }}
</div>
</div>
</div>
<p v-if="systemAudioError" :class="['mt-2 text-xs text-red-600 dark:text-red-400']">
{{ systemAudioError }}
</p>
</section>
<Joystick
:pose="pose"
:dynamics="dynamics"
:disabled="editorPlaying || proceduralMotionPlaying"
:gamepad="props.gamepad"
@move="setPose"
@release="release"
@update-dynamics="setDynamics"
/>
<EyeViewControl
:pose="pose"
:view="eyeView"
@update-view="updateEyeView"
/>
<BreathControl
:enabled="breathEnabled"
:options="breathOptions"
:started-at-ms="breathStartedAtMs"
@update-enabled="updateBreathEnabled"
@update-options="updateBreathOptions"
@reset="resetBreath"
/>
</div>
</template>
<template #preview>
<Preview :pose="pose" />
</template>
<template #timeline>
<KeyframeEditor
:recording="recordingController.recording.value"
:recording-active="recordingController.status.value.type === 'armed' || recordingController.status.value.type === 'recording'"
:disabled="proceduralMotionPlaying"
:gamepad="props.gamepad"
@frame="publishEditorFrame"
@playback="editorPlaying = $event"
@recording="recordingController.loadRecording"
@restart-recording="restartRecording"
@toggle-recording="toggleRecording"
/>
</template>
<template #inference>
<div :class="['flex flex-col gap-4']">
<ProceduralMotion
:recording="recordingController.recording.value"
:disabled="editorPlaying || recordingController.status.value.type === 'armed' || recordingController.status.value.type === 'recording'"
@pose="publishPose"
@release="publishRelease"
@playback="updateProceduralMotionPlayback"
/>
</div>
</template>
</Workbench>
</div>
</template>
@@ -0,0 +1,2 @@
/** Interactive Live2D motion workbench for shared stage devtools. */
export { default as Live2DMotionDevtools } from './devtools.vue'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { Live2DMotionMagicProfileId } from '../profiles'
import { FieldCheckbox, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { live2dMotionMagicProfiles } from '../profiles'
import { useLive2DMotionMagicSettings } from '../settings'
const { t } = useI18n()
const { forceViewTarget, profileId, skipMouthOpen } = storeToRefs(useLive2DMotionMagicSettings())
const profileCopyKeys = {
'idle-calm': {
title: 'settings.live2d.animation.motion-driver.magic.profile.options.idle-calm.title',
description: 'settings.live2d.animation.motion-driver.magic.profile.options.idle-calm.description',
},
'speaking-excited': {
title: 'settings.live2d.animation.motion-driver.magic.profile.options.speaking-excited.title',
description: 'settings.live2d.animation.motion-driver.magic.profile.options.speaking-excited.description',
},
} as const satisfies Record<Live2DMotionMagicProfileId, { title: string, description: string }>
const profileOptions = computed(() => Object.values(live2dMotionMagicProfiles).map((profile) => {
const copy = profileCopyKeys[profile.id]
return {
value: profile.id,
label: t(copy.title),
description: t(copy.description),
}
}))
</script>
<template>
<div :class="['grid gap-3']">
<FieldSelect
v-model="profileId"
:label="t('settings.live2d.animation.motion-driver.magic.profile.title')"
:description="t('settings.live2d.animation.motion-driver.magic.profile.description')"
:options="profileOptions"
layout="horizontal"
/>
<FieldCheckbox
v-model="skipMouthOpen"
:label="t('settings.live2d.animation.motion-driver.magic.skip-mouth-open.title')"
:description="t('settings.live2d.animation.motion-driver.magic.skip-mouth-open.description')"
placement="right"
/>
<FieldCheckbox
v-model="forceViewTarget"
:label="t('settings.live2d.animation.motion-driver.magic.force-view-target.title')"
:description="t('settings.live2d.animation.motion-driver.magic.force-view-target.description')"
placement="right"
/>
</div>
</template>
@@ -0,0 +1,4 @@
export * from './profiles'
export * from './settings'
export * from './use-live2d-motion-magic'
export * from './view-target'
@@ -0,0 +1,56 @@
import type { Pose } from '@proj-airi/model-driver-magic-live2d'
import idleCalmProject from './assets/idle-calm.json'
import speakingExcitedProject from './assets/speaking-excited.json'
/** One normalized Live2D pose in a MAGIC dataset. */
export interface Live2DMotionMagicSample extends Pose {
/** Elapsed time from the start of the dataset, in milliseconds. */
atMs: number
}
/** Timestamped Live2D poses that MAGIC can sample and fit. */
export interface Live2DMotionMagicDataset {
/** Recording schema when the dataset comes from the Live2D motion recorder. */
format?: 'airi-live2d-motion/v6'
/** Duration of the source dataset, in milliseconds. */
durationMs: number
/** Source poses in ascending timestamp order. */
samples: readonly Live2DMotionMagicSample[]
}
/** One bundled reference dataset that can fit a MAGIC motion model. */
export interface Live2DMotionMagicProfile {
/** Stable value stored in Live2D settings. */
id: string
/** Dataset used to fit VAR or AR-HMM. */
dataset: Live2DMotionMagicDataset
}
/** Bundled MAGIC profiles available to Live2D settings. */
export const live2dMotionMagicProfiles = {
'idle-calm': {
id: 'idle-calm',
dataset: {
format: idleCalmProject.source.format as 'airi-live2d-motion/v6',
durationMs: idleCalmProject.source.durationMs,
samples: idleCalmProject.source.samples,
},
},
'speaking-excited': {
id: 'speaking-excited',
dataset: {
format: speakingExcitedProject.source.format as 'airi-live2d-motion/v6',
durationMs: speakingExcitedProject.source.durationMs,
samples: speakingExcitedProject.source.samples,
},
},
} as const satisfies Record<string, Live2DMotionMagicProfile>
export type Live2DMotionMagicProfileId = keyof typeof live2dMotionMagicProfiles
/** Profile selected when the user has not chosen another bundled dataset. */
export const defaultLive2DMotionMagicProfileId: Live2DMotionMagicProfileId = 'speaking-excited'
/** Dataset selected when initialize receives no dataset. */
export const defaultLive2DMotionMagicDataset = live2dMotionMagicProfiles[defaultLive2DMotionMagicProfileId].dataset
@@ -0,0 +1,74 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const persistedValues = vi.hoisted(() => new Map<string, unknown>())
vi.mock('@proj-airi/stage-shared/composables', () => ({
useLocalStorageManualReset<T>(key: string, initialValue: T) {
const storedValue = persistedValues.has(key) ? persistedValues.get(key) as T : initialValue
const state = ref(storedValue)
return Object.assign(state, {
reset: () => {
state.value = initialValue
},
})
},
}))
describe('live2d MAGIC settings', () => {
beforeEach(() => {
persistedValues.clear()
vi.resetModules()
setActivePinia(createPinia())
})
it('defaults to the bundled profile and output overrides', async () => {
const { useLive2DMotionMagicSettings } = await import('./settings')
const settings = useLive2DMotionMagicSettings()
expect(settings.profileId).toBe('speaking-excited')
expect(settings.skipMouthOpen).toBe(true)
expect(settings.forceViewTarget).toBe(true)
settings.skipMouthOpen = false
settings.forceViewTarget = false
settings.resetState()
expect(settings.profileId).toBe('speaking-excited')
expect(settings.skipMouthOpen).toBe(true)
expect(settings.forceViewTarget).toBe(true)
})
it('loads the bundled idle-calm profile', async () => {
persistedValues.set('settings/live2d/magic/profile', 'idle-calm')
const { live2dMotionMagicProfiles, useLive2DMotionMagicSettings } = await import('./index')
const settings = useLive2DMotionMagicSettings()
const dataset = live2dMotionMagicProfiles[settings.profileId].dataset
expect(settings.profileId).toBe('idle-calm')
expect(dataset.format).toBe('airi-live2d-motion/v6')
expect(dataset.durationMs).toBe(64501)
expect(dataset.samples).toHaveLength(1871)
expect(dataset.samples[0].atMs).toBe(0)
})
// ROOT CAUSE:
//
// The settings store trusted the TypeScript type of a value loaded from localStorage.
// The old `idle-excited` value remained after the profile was renamed, so Stage indexed the
// profile registry with an unknown key and read `dataset` from `undefined`.
//
// Before the fix, the store returned `idle-excited` unchanged.
// We fixed this by resetting unknown persisted profile IDs at the storage boundary.
it('resets a persisted profile ID that is not in the profile registry', async () => {
persistedValues.set('settings/live2d/magic/profile', 'idle-excited')
const { live2dMotionMagicProfiles, useLive2DMotionMagicSettings } = await import('./index')
const settings = useLive2DMotionMagicSettings()
expect(() => live2dMotionMagicProfiles[settings.profileId].dataset).not.toThrow()
expect(settings.profileId).toBe('speaking-excited')
})
})
@@ -0,0 +1,38 @@
import type { Live2DMotionMagicProfileId } from './profiles'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { defaultLive2DMotionMagicProfileId, live2dMotionMagicProfiles } from './profiles'
const profileId = useLocalStorageManualReset('settings/live2d/magic/profile', defaultLive2DMotionMagicProfileId)
const skipMouthOpen = useLocalStorageManualReset('settings/live2d/magic/skip-mouth-open', true)
const forceViewTarget = useLocalStorageManualReset('settings/live2d/magic/force-view-target', true)
function isKnownProfileId(value: unknown): value is Live2DMotionMagicProfileId {
return typeof value === 'string' && Object.hasOwn(live2dMotionMagicProfiles, value)
}
watch(profileId, (value) => {
// Persisted settings can outlive bundled profiles. Restore the default before runtime consumers
// use the ID to access the profile registry.
if (!isKnownProfileId(value))
profileId.value = defaultLive2DMotionMagicProfileId
}, { flush: 'sync', immediate: true })
/** Persists production settings for the MAGIC Live2D motion driver. */
export const useLive2DMotionMagicSettings = defineStore('settings-live2d-motion-magic', () => {
function resetState() {
profileId.value = defaultLive2DMotionMagicProfileId
skipMouthOpen.value = true
forceViewTarget.value = true
}
return {
profileId,
skipMouthOpen,
forceViewTarget,
resetState,
}
})
@@ -0,0 +1,49 @@
import { neutralPose } from '@proj-airi/model-driver-magic-live2d'
import { describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import { useLive2DMotionMagic } from './index'
function createDataset() {
const samples = Array.from({ length: 180 }, (_, index) => {
const phase = index / 30 * Math.PI * 2
return {
...neutralPose,
atMs: index * 34,
headX: Math.sin(phase) * 0.5,
headY: Math.cos(phase) * 0.25,
}
})
return {
durationMs: samples.at(-1)!.atMs,
samples,
}
}
describe('live2d MAGIC motion', () => {
it('initializes from a dataset and changes seeds without Web Crypto', async () => {
const scope = effectScope()
const publishPose = vi.fn()
const releasePose = vi.fn()
let now = 0
const motion = scope.run(() => useLive2DMotionMagic({
dataset: createDataset(),
publishPose,
releasePose,
now: () => ++now,
random: () => 0.5,
}))!
await motion.initialize()
motion.randomizeSeed()
expect(motion.status.value).toBe('ready')
expect(motion.model.value?.method).toBe('var')
expect(motion.fitDurationMs.value).toBe(1)
expect(motion.seed.value).toBe(2_147_483_648)
expect(publishPose).not.toHaveBeenCalled()
expect(releasePose).not.toHaveBeenCalled()
scope.stop()
})
})
@@ -0,0 +1,279 @@
import type {
OutputFilterFrame,
OutputFilterOptions,
Pose,
} from '@proj-airi/model-driver-magic-live2d'
import type { FitOptions, MagicModel } from '@proj-airi/motion-driver-magic'
import type { MaybeRefOrGetter } from 'vue'
import type { Live2DMotionMagicDataset } from './profiles'
import { errorMessageFrom } from '@moeru/std'
import {
createDriver,
createTrainingSequence,
defaultOutputFilterOptions,
neutralPose,
poseAxes,
} from '@proj-airi/model-driver-magic-live2d'
import { fit } from '@proj-airi/motion-driver-magic'
import { computed, onScopeDispose, reactive, readonly, shallowRef, toValue, watch } from 'vue'
import { defaultLive2DMotionMagicDataset } from './profiles'
import { applyLive2DMotionViewTarget, defaultLive2DMotionViewTargetState } from './view-target'
export type Live2DMotionMagicMethod = 'ar-hmm' | 'var'
export type Live2DMotionMagicStatus = 'idle' | 'initializing' | 'playing' | 'ready'
/** Runtime boundaries for one MAGIC Live2D motion controller. */
export interface UseLive2DMotionMagicOptions {
/** Supplies a dataset that replaces the bundled dataset. */
dataset?: MaybeRefOrGetter<Live2DMotionMagicDataset | null | undefined>
/** Prevents generated motion from controlling mouth opening. @default true */
skipMouthOpen?: MaybeRefOrGetter<boolean>
/** Keeps the generated eyes aimed at the forward view target. @default true */
forceViewTarget?: MaybeRefOrGetter<boolean>
/** Stops playback and initialization while another motion source owns the target. */
disabled?: MaybeRefOrGetter<boolean>
/** Receives the newest generated pose after filtering. */
publishPose: (pose: Pose) => void
/** Releases the generated pose when playback stops. */
releasePose: () => void
/** Reports playback ownership changes. */
setPlaying?: (playing: boolean) => void
/** Supplies fit timestamps in milliseconds. @default performance.now */
now?: () => number
/** Supplies values in the range [0, 1) for seed changes. @default Math.random */
random?: () => number
}
function evaluateDataset(dataset: Live2DMotionMagicDataset, atMs: number): Pose {
const time = Math.min(dataset.durationMs, Math.max(0, atMs))
const rightIndex = dataset.samples.findIndex(sample => sample.atMs >= time)
if (rightIndex <= 0) {
const { atMs: _atMs, ...pose } = dataset.samples[rightIndex < 0 ? dataset.samples.length - 1 : 0]
return pose
}
const left = dataset.samples[rightIndex - 1]
const right = dataset.samples[rightIndex]
const progress = left.atMs === right.atMs ? 1 : (time - left.atMs) / (right.atMs - left.atMs)
const pose = { ...neutralPose }
for (const axis of poseAxes)
pose[axis] = left[axis] + (right[axis] - left[axis]) * progress
return pose
}
function toTrainingSequence(dataset: Live2DMotionMagicDataset, sampleRateHz: number) {
const frameIntervalMs = 1000 / sampleRateHz
const frameCount = Math.floor(dataset.durationMs / frameIntervalMs) + 1
return createTrainingSequence({
sampleRateHz,
sourceDurationMs: dataset.durationMs,
poses: Array.from(
{ length: frameCount },
(_, index) => evaluateDataset(dataset, Math.min(dataset.durationMs, index * frameIntervalMs)),
),
})
}
/** Owns one MAGIC model, its Live2D driver, and its runtime diagnostics. */
export function useLive2DMotionMagic(options: UseLive2DMotionMagicOptions) {
const now = options.now ?? (() => performance.now())
const random = options.random ?? Math.random
const method = shallowRef<Live2DMotionMagicMethod>('var')
const status = shallowRef<Live2DMotionMagicStatus>('idle')
const model = shallowRef<MagicModel>()
const fitDurationMs = shallowRef(0)
const error = shallowRef('')
const generatedFrameCount = shallowRef(0)
const currentState = shallowRef<number>()
const seed = shallowRef(1)
const outputFilterOptions = shallowRef<OutputFilterOptions>({ ...defaultOutputFilterOptions })
const outputFilterFrame = shallowRef<OutputFilterFrame>()
const varSettings = reactive({
order: 20,
noiseScale: 1.15,
})
const arHmmSettings = reactive({
stateCount: 5,
order: 12,
noiseScale: 0.8,
})
let initializeRequest = 0
const driver = createDriver<number | undefined>({
target: {
apply: pose => options.publishPose(toValue(options.forceViewTarget ?? true)
? applyLive2DMotionViewTarget(pose, defaultLive2DMotionViewTargetState)
: pose),
release: options.releasePose,
},
generateOptions: () => ({ noiseScale: getNoiseScale() }),
onGenerate: (state) => {
generatedFrameCount.value++
currentState.value = state
},
onOutput: (frame) => {
outputFilterFrame.value = frame
},
skipMouthOpen: () => toValue(options.skipMouthOpen ?? true),
filterOptions: outputFilterOptions.value,
})
const playing = computed(() => status.value === 'playing')
const generatedDurationSeconds = computed(() => {
if (!model.value)
return 0
return generatedFrameCount.value / model.value.sampleRateHz
})
function getNoiseScale(): number {
return method.value === 'var' ? varSettings.noiseScale : arHmmSettings.noiseScale
}
function stop() {
if (status.value === 'initializing') {
initializeRequest++
status.value = model.value ? 'ready' : 'idle'
return
}
if (status.value !== 'playing')
return
driver.stop()
currentState.value = undefined
status.value = model.value ? 'ready' : 'idle'
options.setPlaying?.(false)
}
function invalidate() {
initializeRequest++
stop()
model.value = undefined
fitDurationMs.value = 0
error.value = ''
generatedFrameCount.value = 0
status.value = 'idle'
}
/** Fits the selected method with the supplied dataset or the bundled dataset. */
async function initialize(dataset = toValue(options.dataset) ?? defaultLive2DMotionMagicDataset): Promise<void> {
if (toValue(options.disabled ?? false))
return
stop()
const request = ++initializeRequest
const startedAt = now()
status.value = 'initializing'
error.value = ''
await Promise.resolve()
if (request !== initializeRequest)
return
try {
const fitOptions: FitOptions = method.value === 'var'
? {
method: 'var',
order: varSettings.order,
ridge: 0.001,
}
: {
method: 'ar-hmm',
stateCount: arHmmSettings.stateCount,
order: arHmmSettings.order,
ridge: 0.003,
iterations: 6,
}
const nextModel = fit(toTrainingSequence(dataset, 30), fitOptions)
if (request !== initializeRequest)
return
model.value = nextModel
fitDurationMs.value = now() - startedAt
generatedFrameCount.value = 0
currentState.value = undefined
status.value = 'ready'
}
catch (cause) {
if (request !== initializeRequest)
return
console.error(`[Live2D ${method.value}] Failed to initialize MAGIC motion`, errorMessageFrom(cause))
model.value = undefined
fitDurationMs.value = 0
error.value = errorMessageFrom(cause) ?? 'The MAGIC motion model failed to initialize.'
status.value = 'idle'
}
}
function start() {
if (!model.value || status.value === 'playing' || toValue(options.disabled ?? false))
return
generatedFrameCount.value = 0
currentState.value = undefined
status.value = 'playing'
options.setPlaying?.(true)
driver.start(model.value.toGenerator({ seed: seed.value }))
}
function randomizeSeed() {
seed.value = Math.floor(random() * 0x1_0000_0000) >>> 0
if (status.value !== 'playing' || !model.value)
return
generatedFrameCount.value = 0
currentState.value = undefined
driver.replace(model.value.toGenerator({ seed: seed.value }))
}
function setOutputFilterOptions(nextOptions: OutputFilterOptions) {
outputFilterOptions.value = nextOptions
driver.setFilterOptions(nextOptions)
}
function resetOutputFilter() {
driver.resetFilter()
}
watch(() => toValue(options.dataset), invalidate)
watch(() => toValue(options.disabled ?? false), (disabled) => {
if (disabled)
invalidate()
})
watch(method, invalidate)
watch(
[() => varSettings.order, () => arHmmSettings.stateCount, () => arHmmSettings.order],
invalidate,
)
onScopeDispose(() => {
initializeRequest++
stop()
})
return {
method,
status: readonly(status),
playing,
model: readonly(model),
fitDurationMs: readonly(fitDurationMs),
error: readonly(error),
generatedFrameCount: readonly(generatedFrameCount),
generatedDurationSeconds,
currentState: readonly(currentState),
seed: readonly(seed),
varSettings,
arHmmSettings,
outputFilterOptions: readonly(outputFilterOptions),
outputFilterFrame: readonly(outputFilterFrame),
initialize,
start,
stop,
randomizeSeed,
setOutputFilterOptions,
resetOutputFilter,
}
}
@@ -0,0 +1,30 @@
import { neutralPose } from '@proj-airi/model-driver-magic-live2d'
import { describe, expect, it } from 'vitest'
import { applyLive2DMotionViewTarget, defaultLive2DMotionViewTargetState } from './view-target'
describe('live2d motion view target', () => {
it('keeps the generated eyes aimed forward while the head moves', () => {
const pose = {
...neutralPose,
headX: 0.4,
headY: -0.25,
eyeSquint: 0.5,
}
const output = applyLive2DMotionViewTarget(pose, defaultLive2DMotionViewTargetState)
expect(output.eyeX).toBeCloseTo(-0.4)
expect(output.eyeY).toBeCloseTo(0.25)
expect(output.eyeSquint).toBe(0.5)
})
it('preserves the generated pose when the target is disabled', () => {
const pose = { ...neutralPose, eyeX: 0.3, eyeY: -0.2 }
expect(applyLive2DMotionViewTarget(pose, {
...defaultLive2DMotionViewTargetState,
enabled: false,
})).toBe(pose)
})
})

Some files were not shown because too many files have changed in this diff Show More