feat(stage-tamagotchi,stage-ui-live2d): procedural motion generation (#2376)
--------- Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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.
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
+189
@@ -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>
|
||||
+309
@@ -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>
|
||||
+127
@@ -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>
|
||||
+31
@@ -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)
|
||||
})
|
||||
})
|
||||
+326
@@ -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>
|
||||
+49
@@ -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>
|
||||
+12
@@ -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,
|
||||
}
|
||||
}
|
||||
+138
@@ -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,
|
||||
}
|
||||
}
|
||||
+162
@@ -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))
|
||||
}
|
||||
+175
@@ -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')
|
||||
})
|
||||
})
|
||||
+177
@@ -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
Reference in New Issue
Block a user