style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
@@ -5,77 +5,77 @@ import { computed, ref } from 'vue'
export interface BeatBaseAngles { x: number, y: number, z: number }
export interface BeatSyncController {
debugState: () => {
avgInterval: null | number
bpm: null | number
lastBeatTimestamp: null | number
lastInterval: null | number
patternStarted: boolean
primed: boolean
segments: BeatSegment[]
style: BeatSyncStyleName
}
getStyle: () => BeatSyncStyleName
scheduleBeat: (timestamp?: null | number) => void
setAutoStyleShift: (enabled: boolean) => void
setStyle: (style: BeatSyncStyleName) => void
targetX: Ref<number>
targetY: Ref<number>
targetZ: Ref<number>
updateTargets: (now: number) => void
velocityX: Ref<number>
velocityY: Ref<number>
velocityZ: Ref<number>
updateTargets: (now: number) => void
scheduleBeat: (timestamp?: number | null) => void
debugState: () => {
primed: boolean
patternStarted: boolean
lastBeatTimestamp: number | null
lastInterval: number | null
avgInterval: number | null
bpm: number | null
style: BeatSyncStyleName
segments: BeatSegment[]
}
setStyle: (style: BeatSyncStyleName) => void
getStyle: () => BeatSyncStyleName
setAutoStyleShift: (enabled: boolean) => void
}
export type BeatSyncStyleName = 'balanced-v' | 'punchy-v' | 'sway-sine' | 'swing-lr'
interface BeatSegment {
start: number
duration: number
fromX?: number
fromY: number
fromZ: number
start: number
toX?: number
toY: number
toZ: number
}
interface CreateBeatSyncControllerOptions {
baseAngles: () => BeatBaseAngles
releaseDelayMs?: number
defaultIntervalMs?: number
styles?: Partial<Record<BeatSyncStyleName, BeatStyleConfig>>
initialStyle?: BeatSyncStyleName
autoStyleShift?: boolean
}
type BeatStylePattern = 'v' | 'swing' | 'sway'
export type BeatSyncStyleName = 'punchy-v' | 'balanced-v' | 'swing-lr' | 'sway-sine'
interface BeatStyleConfig {
topYaw: number
topRoll: number
bottomDip: number
pattern: BeatStylePattern
swingLift?: number
topRoll: number
topYaw: number
}
type BeatStylePattern = 'sway' | 'swing' | 'v'
interface CreateBeatSyncControllerOptions {
autoStyleShift?: boolean
baseAngles: () => BeatBaseAngles
defaultIntervalMs?: number
initialStyle?: BeatSyncStyleName
releaseDelayMs?: number
styles?: Partial<Record<BeatSyncStyleName, BeatStyleConfig>>
}
const defaultStyles: Record<BeatSyncStyleName, BeatStyleConfig> = {
'punchy-v': { topYaw: 10, topRoll: 8, bottomDip: 4, pattern: 'v' },
'balanced-v': { topYaw: 6, topRoll: 0, bottomDip: 6, pattern: 'v' },
'swing-lr': { topYaw: 8, topRoll: 0, bottomDip: 6, swingLift: 8, pattern: 'swing' },
'balanced-v': { bottomDip: 6, pattern: 'v', topRoll: 0, topYaw: 6 },
'punchy-v': { bottomDip: 4, pattern: 'v', topRoll: 8, topYaw: 10 },
// sway uses a three-point path per beat: side A -> side B -> center (A-shape arcs)
'sway-sine': { topYaw: 10, topRoll: 0, bottomDip: 0, swingLift: 10, pattern: 'sway' },
'sway-sine': { bottomDip: 0, pattern: 'sway', swingLift: 10, topRoll: 0, topYaw: 10 },
'swing-lr': { bottomDip: 6, pattern: 'swing', swingLift: 8, topRoll: 0, topYaw: 8 },
}
export function createBeatSyncController(options: CreateBeatSyncControllerOptions): BeatSyncController {
const {
baseAngles: baseAnglesGetter,
releaseDelayMs = 1800,
defaultIntervalMs = 600,
styles = {},
initialStyle = 'punchy-v',
autoStyleShift = false,
baseAngles: baseAnglesGetter,
defaultIntervalMs = 600,
initialStyle = 'punchy-v',
releaseDelayMs = 1800,
styles = {},
} = options
const styleMap = { ...defaultStyles, ...styles }
@@ -90,9 +90,9 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
const currentTopSide = ref<'left' | 'right'>('left')
const primed = ref(false)
const patternStarted = ref(false)
const lastBeatTimestamp = ref<number | null>(null)
const lastInterval = ref<number | null>(null)
const avgInterval = ref<number | null>(null)
const lastBeatTimestamp = ref<null | number>(null)
const lastInterval = ref<null | number>(null)
const avgInterval = ref<null | number>(null)
const style = ref<BeatSyncStyleName>(initialStyle)
const autoShift = ref(autoStyleShift)
const baseAngles = computed(() => baseAnglesGetter())
@@ -110,7 +110,7 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
}
function getTopPose(side: 'left' | 'right') {
const { topYaw, topRoll, swingLift, pattern } = getStyleConfig()
const { pattern, swingLift, topRoll, topYaw } = getStyleConfig()
const direction = side === 'left' ? -1 : 1
const zOffset = (pattern === 'swing' || pattern === 'sway') ? (swingLift ?? topRoll) : topRoll
const z = baseAngles.value.z + (pattern === 'swing' || pattern === 'sway' ? zOffset : direction * zOffset)
@@ -182,7 +182,7 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
targetZ.value = currentZ
}
function scheduleBeat(timestamp?: number | null) {
function scheduleBeat(timestamp?: null | number) {
const now = timestamp != null && Number.isFinite(timestamp)
? Number(timestamp)
: (typeof performance !== 'undefined' ? performance.now() : Date.now())
@@ -216,10 +216,10 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
if (!patternStarted.value) {
const topPose = getTopPose('left')
segments.value.push({
start: now,
duration: halfDuration,
fromY: startPose.y,
fromZ: startPose.z,
start: now,
toY: topPose.y,
toZ: topPose.z,
})
@@ -232,18 +232,18 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
const nextTopPose = getTopPose(nextSide)
segments.value.push({
start: now,
duration: halfDuration,
fromY: startPose.y,
fromZ: startPose.z,
start: now,
toY: bottomPose.y,
toZ: bottomPose.z,
})
segments.value.push({
start: now + halfDuration,
duration: halfDuration,
fromY: bottomPose.y,
fromZ: bottomPose.z,
start: now + halfDuration,
toY: nextTopPose.y,
toZ: nextTopPose.z,
})
@@ -261,18 +261,18 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
const crossDuration = Math.max(60, interval - sideDuration)
segments.value.push({
start: now,
duration: sideDuration,
fromY: startPose.y,
fromZ: startPose.z,
start: now,
toY: sidePose.y,
toZ: sidePose.z,
})
segments.value.push({
start: now + sideDuration,
duration: crossDuration,
fromY: sidePose.y,
fromZ: sidePose.z,
start: now + sideDuration,
toY: oppositePose.y,
toZ: oppositePose.z,
})
@@ -291,10 +291,10 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
// First beat after prime: move to initial side anchor
if (!patternStarted.value) {
segments.value.push({
start: now,
duration: halfDuration,
fromY: startPose.y,
fromZ: startPose.z,
start: now,
toY: sidePose.y,
toZ: sidePose.z,
})
@@ -312,18 +312,18 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
const leg2 = Math.max(60, interval - leg1)
segments.value.push({
start: now,
duration: leg1,
fromY: startPose.y,
fromZ: startPose.z,
start: now,
toY: apexPose.y,
toZ: apexPose.z,
})
segments.value.push({
start: now + leg1,
duration: leg2,
fromY: apexPose.y,
fromZ: apexPose.z,
start: now + leg1,
toY: oppositePose.y,
toZ: oppositePose.z,
})
@@ -334,26 +334,26 @@ export function createBeatSyncController(options: CreateBeatSyncControllerOption
}
return {
debugState: () => ({
avgInterval: avgInterval.value,
bpm: avgInterval.value ? 60000 / avgInterval.value : null,
lastBeatTimestamp: lastBeatTimestamp.value,
lastInterval: lastInterval.value,
patternStarted: patternStarted.value,
primed: primed.value,
segments: [...segments.value],
style: style.value,
}),
getStyle: () => style.value,
scheduleBeat,
setAutoStyleShift: (enabled: boolean) => { autoShift.value = enabled },
setStyle: (s: BeatSyncStyleName) => { style.value = s },
targetX,
targetY,
targetZ,
updateTargets,
velocityX,
velocityY,
velocityZ,
updateTargets,
scheduleBeat,
setStyle: (s: BeatSyncStyleName) => { style.value = s },
getStyle: () => style.value,
setAutoStyleShift: (enabled: boolean) => { autoShift.value = enabled },
debugState: () => ({
primed: primed.value,
patternStarted: patternStarted.value,
lastBeatTimestamp: lastBeatTimestamp.value,
lastInterval: lastInterval.value,
avgInterval: avgInterval.value,
bpm: avgInterval.value ? 60000 / avgInterval.value : null,
style: style.value,
segments: [...segments.value],
}),
}
}
@@ -9,30 +9,6 @@ import { useExpressionStore } from '../../stores/expression-store'
// Types for model3.json / exp3.json data
// ---------------------------------------------------------------------------
/** A single expression reference inside model3.json FileReferences.Expressions[]. */
interface Model3ExpressionRef {
Name: string
File: string
}
/** Parameter entry inside an exp3.json file. */
interface Exp3Parameter {
Id: string
Value: number
Blend: 'Add' | 'Multiply' | 'Overwrite'
}
/** Root structure of an exp3.json file. */
interface Exp3Json {
Type: string
Parameters: Exp3Parameter[]
// FadeInTime / FadeOutTime are intentionally ignored (we do direct application).
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
export interface ExpressionControllerOptions {
/**
* The loaded Live2D internal model reference (reactive so it can be null
@@ -47,6 +23,30 @@ export interface ExpressionControllerOptions {
modelId?: string
}
/** Root structure of an exp3.json file. */
interface Exp3Json {
Parameters: Exp3Parameter[]
Type: string
// FadeInTime / FadeOutTime are intentionally ignored (we do direct application).
}
/** Parameter entry inside an exp3.json file. */
interface Exp3Parameter {
Blend: 'Add' | 'Multiply' | 'Overwrite'
Id: string
Value: number
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
/** A single expression reference inside model3.json FileReferences.Expressions[]. */
interface Model3ExpressionRef {
File: string
Name: string
}
/**
* Create an expression controller that:
* 1. Parses exp3 data from the model settings
@@ -89,8 +89,8 @@ export function useExpressionController(options: ExpressionControllerOptions) {
const blend = normaliseBlend(param.Blend)
groupParams.push({
parameterId: param.Id,
blend,
parameterId: param.Id,
value: param.Value,
})
@@ -99,12 +99,12 @@ export function useExpressionController(options: ExpressionControllerOptions) {
if (!entryMap.has(param.Id)) {
const modelDefault = getModelParameterDefault(param.Id)
entryMap.set(param.Id, {
name: param.Id,
parameterId: param.Id,
blend,
currentValue: modelDefault,
defaultValue: modelDefault,
modelDefault,
name: param.Id,
parameterId: param.Id,
targetValue: param.Value,
})
}
@@ -275,8 +275,8 @@ export function useExpressionController(options: ExpressionControllerOptions) {
}
return {
initialise,
applyExpressions,
dispose,
initialise,
}
}
@@ -47,7 +47,7 @@ describe('useLive2DEyeFocusFor', () => {
} as HTMLCanvasElement
const focus = useLive2DEyeFocusFor({
canvas: () => canvas,
model: () => ({ normalizedScale: 1, modelWidth: 1000, modelHeight: 1000 }),
model: () => ({ modelHeight: 1000, modelWidth: 1000, normalizedScale: 1 }),
source: () => ({ x: 110, y: 70 }),
})
@@ -68,7 +68,7 @@ describe('useLive2DEyeFocusFor', () => {
} as HTMLCanvasElement
const focus = useLive2DEyeFocusFor({
canvas: () => canvas,
model: () => ({ normalizedScale: 1, modelWidth: 1000, modelHeight: 1000 }),
model: () => ({ modelHeight: 1000, modelWidth: 1000, normalizedScale: 1 }),
source: () => null,
})
@@ -28,17 +28,17 @@ export interface Live2DEyeFocusSource {
export function useLive2DEyeFocusFor(options: {
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>
model: MaybeRefOrGetter<{
normalizedScale: number
modelWidth: number
modelHeight: number
modelWidth: number
normalizedScale: number
}>
source: MaybeRefOrGetter<Live2DEyeFocusSource | null | undefined>
}): ComputedRef<{ x: number, y: number }> {
const { live2dRenderScale, live2dModelEyeOffset } = storeToRefs(useSettingsLive2d())
const { live2dModelEyeOffset, live2dRenderScale } = storeToRefs(useSettingsLive2d())
const { scale } = useL2dViewControl()
const mouseFocus = computed(() => {
const { normalizedScale, modelWidth, modelHeight } = toValue(options.model)
const { modelHeight, modelWidth, normalizedScale } = toValue(options.model)
const renderScale = live2dRenderScale.value
const trackingSource = toValue(options.source)
const canvasRect = toValue(options.canvas)?.getBoundingClientRect()
@@ -17,8 +17,8 @@ const startingOffsetY = computed(() => {
* showing upper half of the body when `position.y == 0`
*/
export function useFitModel(
canvasDim: MaybeRefOrGetter<{ width: number, height: number }>,
modelDim: MaybeRefOrGetter<{ width: number, height: number }>,
canvasDim: MaybeRefOrGetter<{ height: number, width: number }>,
modelDim: MaybeRefOrGetter<{ height: number, width: number }>,
) {
const normalizedParam = computed(() => {
const canvas = toValue(canvasDim)
@@ -47,16 +47,16 @@ function resetState() {
export const useSettingsLive2d = defineStore('settings-live2d', () => {
return {
live2dEyeTracking,
live2dModelEyeOffset,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
live2dExpressionEnabled,
live2dShadowEnabled,
live2dEyeTracking,
live2dForceAutoBlinkEnabled,
live2dForceIdleEyeAnimation,
live2dIdleAnimationEnabled,
live2dMaxFps,
live2dModelEyeOffset,
live2dRenderScale,
live2dShadowEnabled,
resetState,
}
})
@@ -12,6 +12,49 @@ vi.mock('./animation', () => ({
useLive2DIdleEyeFocus: () => ({ update: vi.fn() }),
}))
function createContext(overrides: Partial<MotionManagerPluginContext> = {}): MotionManagerPluginContext {
const model = createModel({
ParamEyeLOpen: 1,
ParamEyeROpen: 1,
})
const context = {
handled: false as boolean,
internalModel: {
coreModel: model,
eyeBlink: {
updateParameters: vi.fn((targetModel: typeof model) => {
targetModel.setParameterValueById('ParamEyeLOpen', 0.5)
targetModel.setParameterValueById('ParamEyeROpen', 0.25)
}),
},
} as unknown as PixiLive2DInternalModel,
isIdleMotion: true,
live2dAutoBlinkEnabled: ref(true),
live2dEyeFocusSourceActive: ref(false),
live2dEyeTrackingEnabled: ref(false),
live2dForceAutoBlinkEnabled: ref(false),
live2dForceIdleEyeAnimation: ref(false),
live2dIdleAnimationEnabled: ref(true),
markHandled: vi.fn(() => {
context.handled = true
}),
model,
modelParameters: ref({
leftEyeOpen: 1,
rightEyeOpen: 1,
}),
motionManager: {
groups: { idle: 'Idle' },
state: { currentGroup: undefined },
stopAllMotions: vi.fn(),
} as unknown as PixiLive2DInternalModel['motionManager'],
now: 1000,
timeDelta: 16,
}
return Object.assign(context, overrides) as unknown as MotionManagerPluginContext
}
function createModel(initialValues: Record<string, number> = {}) {
const values = new Map(Object.entries(initialValues))
return {
@@ -23,49 +66,6 @@ function createModel(initialValues: Record<string, number> = {}) {
}
}
function createContext(overrides: Partial<MotionManagerPluginContext> = {}): MotionManagerPluginContext {
const model = createModel({
ParamEyeLOpen: 1,
ParamEyeROpen: 1,
})
const context = {
model,
now: 1000,
timeDelta: 16,
internalModel: {
eyeBlink: {
updateParameters: vi.fn((targetModel: typeof model) => {
targetModel.setParameterValueById('ParamEyeLOpen', 0.5)
targetModel.setParameterValueById('ParamEyeROpen', 0.25)
}),
},
coreModel: model,
} as unknown as PixiLive2DInternalModel,
motionManager: {
stopAllMotions: vi.fn(),
state: { currentGroup: undefined },
groups: { idle: 'Idle' },
} as unknown as PixiLive2DInternalModel['motionManager'],
modelParameters: ref({
leftEyeOpen: 1,
rightEyeOpen: 1,
}),
live2dEyeTrackingEnabled: ref(false),
live2dEyeFocusSourceActive: ref(false),
live2dIdleAnimationEnabled: ref(true),
live2dForceIdleEyeAnimation: ref(false),
live2dAutoBlinkEnabled: ref(true),
live2dForceAutoBlinkEnabled: ref(false),
isIdleMotion: true,
handled: false as boolean,
markHandled: vi.fn(() => {
context.handled = true
}),
}
return Object.assign(context, overrides) as unknown as MotionManagerPluginContext
}
describe('live2d motion manager plugins', () => {
/**
* @example
@@ -74,8 +74,8 @@ describe('live2d motion manager plugins', () => {
it('keeps idle eye focus alive when idle motion is disabled', () => {
const idleEyeFocus = { update: vi.fn() }
const context = createContext({
live2dIdleAnimationEnabled: ref(false),
live2dForceIdleEyeAnimation: ref(true),
live2dIdleAnimationEnabled: ref(false),
})
useMotionUpdatePluginIdleDisable(idleEyeFocus)(context)
@@ -90,10 +90,10 @@ describe('live2d motion manager plugins', () => {
it('lets mouse tracking own focus while a tracking source is active', () => {
const idleEyeFocus = { update: vi.fn() }
const context = createContext({
live2dEyeTrackingEnabled: ref(true),
live2dEyeFocusSourceActive: ref(true),
live2dIdleAnimationEnabled: ref(false),
live2dEyeTrackingEnabled: ref(true),
live2dForceIdleEyeAnimation: ref(true),
live2dIdleAnimationEnabled: ref(false),
})
useMotionUpdatePluginIdleDisable(idleEyeFocus)(context)
@@ -6,72 +6,72 @@ import type { useExpressionController } from './expression-controller'
import { useLive2DIdleEyeFocus } from './animation'
type CubismModel = Cubism4InternalModel['coreModel']
type CubismEyeBlink = Cubism4InternalModel['eyeBlink']
export type PixiLive2DInternalModel = InternalModel & {
eyeBlink?: CubismEyeBlink
coreModel: CubismModel
export type MotionManagerPlugin = (ctx: MotionManagerPluginContext) => void
export type MotionManagerPluginContext = MotionManagerUpdateContext & {
handled: boolean
internalModel: PixiLive2DInternalModel
isIdleMotion: boolean
live2dAutoBlinkEnabled: Ref<boolean>
live2dEyeFocusSourceActive: Ref<boolean>
live2dEyeTrackingEnabled: Ref<boolean>
live2dForceAutoBlinkEnabled: Ref<boolean>
live2dForceIdleEyeAnimation: Ref<boolean>
live2dIdleAnimationEnabled: Ref<boolean>
markHandled: () => void
modelParameters: Ref<any>
motionManager: PixiLive2DInternalModel['motionManager']
}
export interface MotionManagerUpdateContext {
hookedUpdate?: (model: CubismModel, now: number) => boolean
model: CubismModel
// in seconds
now: number
// in seconds
timeDelta: number
hookedUpdate?: (model: CubismModel, now: number) => boolean
}
export type MotionManagerPluginContext = MotionManagerUpdateContext & {
internalModel: PixiLive2DInternalModel
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dEyeTrackingEnabled: Ref<boolean>
live2dEyeFocusSourceActive: Ref<boolean>
live2dIdleAnimationEnabled: Ref<boolean>
live2dForceIdleEyeAnimation: Ref<boolean>
live2dAutoBlinkEnabled: Ref<boolean>
live2dForceAutoBlinkEnabled: Ref<boolean>
isIdleMotion: boolean
handled: boolean
markHandled: () => void
export type PixiLive2DInternalModel = InternalModel & {
coreModel: CubismModel
eyeBlink?: CubismEyeBlink
}
export type MotionManagerPlugin = (ctx: MotionManagerPluginContext) => void
export interface UseLive2DMotionManagerUpdateOptions {
internalModel: PixiLive2DInternalModel
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dEyeTrackingEnabled: Ref<boolean>
live2dEyeFocusSourceActive: Ref<boolean>
live2dIdleAnimationEnabled: Ref<boolean>
live2dForceIdleEyeAnimation: Ref<boolean>
live2dAutoBlinkEnabled: Ref<boolean>
live2dForceAutoBlinkEnabled: Ref<boolean>
lastUpdateTime: Ref<number>
live2dAutoBlinkEnabled: Ref<boolean>
live2dEyeFocusSourceActive: Ref<boolean>
live2dEyeTrackingEnabled: Ref<boolean>
live2dForceAutoBlinkEnabled: Ref<boolean>
live2dForceIdleEyeAnimation: Ref<boolean>
live2dIdleAnimationEnabled: Ref<boolean>
modelParameters: Ref<any>
motionManager: PixiLive2DInternalModel['motionManager']
}
type CubismEyeBlink = Cubism4InternalModel['eyeBlink']
type CubismModel = Cubism4InternalModel['coreModel']
export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpdateOptions) {
const {
internalModel,
motionManager,
modelParameters,
live2dEyeTrackingEnabled,
live2dEyeFocusSourceActive,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
lastUpdateTime,
live2dAutoBlinkEnabled,
live2dEyeFocusSourceActive,
live2dEyeTrackingEnabled,
live2dForceAutoBlinkEnabled,
live2dForceIdleEyeAnimation,
live2dIdleAnimationEnabled,
modelParameters,
motionManager,
} = options
const prePlugins: MotionManagerPlugin[] = []
const postPlugins: MotionManagerPlugin[] = []
const finalPlugins: MotionManagerPlugin[] = []
function register(plugin: MotionManagerPlugin, stage: 'pre' | 'post' | 'final' = 'pre') {
function register(plugin: MotionManagerPlugin, stage: 'final' | 'post' | 'pre' = 'pre') {
if (stage === 'pre')
prePlugins.push(plugin)
else if (stage === 'final')
@@ -96,24 +96,24 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
|| (!!selectedMotionGroup && motionManager.state.currentGroup === selectedMotionGroup)
const ctx: MotionManagerPluginContext = {
model,
now,
timeDelta,
handled: false,
hookedUpdate,
internalModel,
motionManager,
modelParameters,
live2dEyeTrackingEnabled,
live2dEyeFocusSourceActive,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
isIdleMotion,
handled: false,
live2dAutoBlinkEnabled,
live2dEyeFocusSourceActive,
live2dEyeTrackingEnabled,
live2dForceAutoBlinkEnabled,
live2dForceIdleEyeAnimation,
live2dIdleAnimationEnabled,
markHandled: () => {
ctx.handled = true
},
model,
modelParameters,
motionManager,
now,
timeDelta,
}
runPlugins(prePlugins, ctx)
@@ -136,126 +136,23 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
}
return {
register,
hookUpdate,
register,
}
}
// -- Plugins ---------------------------------------------------------------
export function useMotionUpdatePluginBeatSync(beatSync: BeatSyncController): MotionManagerPlugin {
return (ctx) => {
beatSync.updateTargets(ctx.now)
// Semi-implicit Euler approach
const stiffness = 120 // Higher -> Snappier
const damping = 16 // Higher -> Less bounce
const mass = 1
let paramAngleX = ctx.model.getParameterValueById('ParamAngleX') as number
let paramAngleY = ctx.model.getParameterValueById('ParamAngleY') as number
let paramAngleZ = ctx.model.getParameterValueById('ParamAngleZ') as number
// X
{
const target = beatSync.targetX.value
const pos = paramAngleX
const vel = beatSync.velocityX.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityX.value = vel + accel * ctx.timeDelta
paramAngleX = pos + beatSync.velocityX.value * ctx.timeDelta
if (Math.abs(target - paramAngleX) < 0.01 && Math.abs(beatSync.velocityX.value) < 0.01) {
paramAngleX = target
beatSync.velocityX.value = 0
}
}
// Y
{
const target = beatSync.targetY.value
const pos = paramAngleY
const vel = beatSync.velocityY.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityY.value = vel + accel * ctx.timeDelta
paramAngleY = pos + beatSync.velocityY.value * ctx.timeDelta
// Snap
if (Math.abs(target - paramAngleY) < 0.01 && Math.abs(beatSync.velocityY.value) < 0.01) {
paramAngleY = target
beatSync.velocityY.value = 0
}
}
// Z
{
const target = beatSync.targetZ.value
const pos = paramAngleZ
const vel = beatSync.velocityZ.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityZ.value = vel + accel * ctx.timeDelta
paramAngleZ = pos + beatSync.velocityZ.value * ctx.timeDelta
// Snap
if (Math.abs(target - paramAngleZ) < 0.01 && Math.abs(beatSync.velocityZ.value) < 0.01) {
paramAngleZ = target
beatSync.velocityZ.value = 0
}
}
ctx.model.setParameterValueById('ParamAngleX', paramAngleX)
ctx.model.setParameterValueById('ParamAngleY', paramAngleY)
ctx.model.setParameterValueById('ParamAngleZ', paramAngleZ)
}
}
export function useMotionUpdatePluginIdleDisable(idleEyeFocus = useLive2DIdleEyeFocus()): MotionManagerPlugin {
return (ctx) => {
if (ctx.handled)
return
// Stop idle motions if they're disabled
if (!ctx.live2dIdleAnimationEnabled.value && ctx.isIdleMotion) {
ctx.motionManager.stopAllMotions()
if (ctx.live2dForceIdleEyeAnimation.value && (!ctx.live2dEyeTrackingEnabled.value || !ctx.live2dEyeFocusSourceActive.value))
idleEyeFocus.update(ctx.internalModel, ctx.now)
if (ctx.internalModel.eyeBlink != null) {
ctx.internalModel.eyeBlink.updateParameters(ctx.model, ctx.timeDelta / 1000)
}
// Apply manual eye parameters after auto eye blink
ctx.model.setParameterValueById('ParamEyeLOpen', ctx.modelParameters.value.leftEyeOpen)
ctx.model.setParameterValueById('ParamEyeROpen', ctx.modelParameters.value.rightEyeOpen)
ctx.markHandled()
}
}
}
export function useMotionUpdatePluginIdleFocus(idleEyeFocus = useLive2DIdleEyeFocus()): MotionManagerPlugin {
return (ctx) => {
if (!ctx.isIdleMotion || ctx.handled)
return
if (!ctx.live2dForceIdleEyeAnimation.value)
return
if (ctx.live2dEyeTrackingEnabled.value && ctx.live2dEyeFocusSourceActive.value)
return
idleEyeFocus.update(ctx.internalModel, ctx.now)
}
}
export function useMotionUpdatePluginAutoEyeBlink(
live2dExpressionEnabled?: Ref<boolean>,
): MotionManagerPlugin {
const blinkState = {
phase: 'idle' as 'idle' | 'closing' | 'opening',
delayMs: 0,
openDurationMs: 300,
phase: 'idle' as 'closing' | 'idle' | 'opening',
progress: 0,
startLeft: 1,
startRight: 1,
delayMs: 0,
openDurationMs: 300,
}
// Eye values captured at blink start. Used as the base during
@@ -439,6 +336,72 @@ export function useMotionUpdatePluginAutoEyeBlink(
}
}
export function useMotionUpdatePluginBeatSync(beatSync: BeatSyncController): MotionManagerPlugin {
return (ctx) => {
beatSync.updateTargets(ctx.now)
// Semi-implicit Euler approach
const stiffness = 120 // Higher -> Snappier
const damping = 16 // Higher -> Less bounce
const mass = 1
let paramAngleX = ctx.model.getParameterValueById('ParamAngleX') as number
let paramAngleY = ctx.model.getParameterValueById('ParamAngleY') as number
let paramAngleZ = ctx.model.getParameterValueById('ParamAngleZ') as number
// X
{
const target = beatSync.targetX.value
const pos = paramAngleX
const vel = beatSync.velocityX.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityX.value = vel + accel * ctx.timeDelta
paramAngleX = pos + beatSync.velocityX.value * ctx.timeDelta
if (Math.abs(target - paramAngleX) < 0.01 && Math.abs(beatSync.velocityX.value) < 0.01) {
paramAngleX = target
beatSync.velocityX.value = 0
}
}
// Y
{
const target = beatSync.targetY.value
const pos = paramAngleY
const vel = beatSync.velocityY.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityY.value = vel + accel * ctx.timeDelta
paramAngleY = pos + beatSync.velocityY.value * ctx.timeDelta
// Snap
if (Math.abs(target - paramAngleY) < 0.01 && Math.abs(beatSync.velocityY.value) < 0.01) {
paramAngleY = target
beatSync.velocityY.value = 0
}
}
// Z
{
const target = beatSync.targetZ.value
const pos = paramAngleZ
const vel = beatSync.velocityZ.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityZ.value = vel + accel * ctx.timeDelta
paramAngleZ = pos + beatSync.velocityZ.value * ctx.timeDelta
// Snap
if (Math.abs(target - paramAngleZ) < 0.01 && Math.abs(beatSync.velocityZ.value) < 0.01) {
paramAngleZ = target
beatSync.velocityZ.value = 0
}
}
ctx.model.setParameterValueById('ParamAngleX', paramAngleX)
ctx.model.setParameterValueById('ParamAngleY', paramAngleY)
ctx.model.setParameterValueById('ParamAngleZ', paramAngleZ)
}
}
/**
* Post-plugin that applies expression parameter overrides from the expression
* store onto the Live2D model every frame.
@@ -456,6 +419,43 @@ export function useMotionUpdatePluginExpression(
}
}
export function useMotionUpdatePluginIdleDisable(idleEyeFocus = useLive2DIdleEyeFocus()): MotionManagerPlugin {
return (ctx) => {
if (ctx.handled)
return
// Stop idle motions if they're disabled
if (!ctx.live2dIdleAnimationEnabled.value && ctx.isIdleMotion) {
ctx.motionManager.stopAllMotions()
if (ctx.live2dForceIdleEyeAnimation.value && (!ctx.live2dEyeTrackingEnabled.value || !ctx.live2dEyeFocusSourceActive.value))
idleEyeFocus.update(ctx.internalModel, ctx.now)
if (ctx.internalModel.eyeBlink != null) {
ctx.internalModel.eyeBlink.updateParameters(ctx.model, ctx.timeDelta / 1000)
}
// Apply manual eye parameters after auto eye blink
ctx.model.setParameterValueById('ParamEyeLOpen', ctx.modelParameters.value.leftEyeOpen)
ctx.model.setParameterValueById('ParamEyeROpen', ctx.modelParameters.value.rightEyeOpen)
ctx.markHandled()
}
}
}
export function useMotionUpdatePluginIdleFocus(idleEyeFocus = useLive2DIdleEyeFocus()): MotionManagerPlugin {
return (ctx) => {
if (!ctx.isIdleMotion || ctx.handled)
return
if (!ctx.live2dForceIdleEyeAnimation.value)
return
if (ctx.live2dEyeTrackingEnabled.value && ctx.live2dEyeFocusSourceActive.value)
return
idleEyeFocus.update(ctx.internalModel, ctx.now)
}
}
/**
* Final-phase plugin that owns ParamMouthOpenY while speech is active and
* smoothly cross-fades back to the motion-driven value when speech ends.
@@ -1,13 +1,13 @@
export enum Emotion {
Happy = 'happy',
Sad = 'sad',
Angry = 'angry',
Think = 'think',
Surprise = 'surprised',
Awkward = 'awkward',
Question = 'question',
Curious = 'curious',
Happy = 'happy',
Neutral = 'neutral',
Question = 'question',
Sad = 'sad',
Surprise = 'surprised',
Think = 'think',
}
export const EMOTION_VALUES = Object.values(Emotion)
@@ -23,25 +23,25 @@ export const EmotionNeutralMotionName = 'Idle'
export const EmotionCuriousMotionName = 'Curious'
export const EMOTION_EmotionMotionName_value = {
[Emotion.Happy]: EmotionHappyMotionName,
[Emotion.Sad]: EmotionSadMotionName,
[Emotion.Angry]: EmotionAngryMotionName,
[Emotion.Think]: EmotionThinkMotionName,
[Emotion.Surprise]: EmotionSurpriseMotionName,
[Emotion.Awkward]: EmotionAwkwardMotionName,
[Emotion.Question]: EmotionQuestionMotionName,
[Emotion.Neutral]: EmotionNeutralMotionName,
[Emotion.Curious]: EmotionCuriousMotionName,
[Emotion.Happy]: EmotionHappyMotionName,
[Emotion.Neutral]: EmotionNeutralMotionName,
[Emotion.Question]: EmotionQuestionMotionName,
[Emotion.Sad]: EmotionSadMotionName,
[Emotion.Surprise]: EmotionSurpriseMotionName,
[Emotion.Think]: EmotionThinkMotionName,
}
export const EMOTION_VRMExpressionName_value = {
[Emotion.Happy]: 'happy',
[Emotion.Sad]: 'sad',
[Emotion.Angry]: 'angry',
[Emotion.Think]: undefined,
[Emotion.Surprise]: 'surprised',
[Emotion.Awkward]: undefined,
[Emotion.Question]: undefined,
[Emotion.Neutral]: undefined,
[Emotion.Curious]: 'surprised',
[Emotion.Happy]: 'happy',
[Emotion.Neutral]: undefined,
[Emotion.Question]: undefined,
[Emotion.Sad]: 'sad',
[Emotion.Surprise]: 'surprised',
[Emotion.Think]: undefined,
} satisfies Record<Emotion, string | undefined>
@@ -14,10 +14,6 @@ export type ExpressionBlendMode = 'Add' | 'Multiply' | 'Overwrite'
* expression system (either via exp3 files or direct parameter access).
*/
export interface ExpressionEntry {
/** Human-readable name (Expression name or raw parameter ID). */
name: string
/** Live2D parameter ID (e.g. "ParamWatermarkOFF"). */
parameterId: string
/** How this value is applied on top of the base value. */
blend: ExpressionBlendMode
/** Runtime value that will be applied every frame. */
@@ -26,6 +22,12 @@ export interface ExpressionEntry {
defaultValue: number
/** Original default baked into the moc3 / exp3 file. */
modelDefault: number
/** Human-readable name (Expression name or raw parameter ID). */
name: string
/** Live2D parameter ID (e.g. "ParamWatermarkOFF"). */
parameterId: string
/** Active auto-reset timer handle, if any. */
resetTimer?: ReturnType<typeof setTimeout>
/**
* The exp3-specified target value for this parameter (e.g. -1, 1, 10).
* Used by toggle to know what value to set when activating.
@@ -33,8 +35,6 @@ export interface ExpressionEntry {
* non-zero value encountered.
*/
targetValue: number
/** Active auto-reset timer handle, if any. */
resetTimer?: ReturnType<typeof setTimeout>
}
/**
@@ -48,38 +48,34 @@ export interface ExpressionGroupDefinition {
name: string
/** Parameter entries that belong to this expression group. */
parameters: {
parameterId: string
blend: ExpressionBlendMode
parameterId: string
value: number
}[]
}
/** Serialisable snapshot returned to the LLM. */
export interface ExpressionState {
name: string
value: number
default: number
active: boolean
autoResetAt?: number
default: number
name: string
value: number
}
/** Unified tool result envelope. */
export interface ExpressionToolResult {
success: boolean
available?: string[]
error?: string
state?: ExpressionState | ExpressionState[]
available?: string[]
success: boolean
}
// ---------------------------------------------------------------------------
// Persistence helpers (localStorage no extra dependency needed)
// ---------------------------------------------------------------------------
function persistenceKey(modelId: string): string {
return `expression-defaults:${modelId}`
}
function loadPersistedDefaults(modelId: string): Record<string, number> | null {
function loadPersistedDefaults(modelId: string): null | Record<string, number> {
try {
const raw = localStorage.getItem(persistenceKey(modelId))
if (!raw)
@@ -91,6 +87,10 @@ function loadPersistedDefaults(modelId: string): Record<string, number> | null {
}
}
function persistenceKey(modelId: string): string {
return `expression-defaults:${modelId}`
}
function savePersistedDefaults(modelId: string, defaults: Record<string, number>): void {
try {
localStorage.setItem(persistenceKey(modelId), JSON.stringify(defaults))
@@ -120,7 +120,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
const expressionGroups = ref<Map<string, ExpressionGroupDefinition>>(new Map())
/** LLM exposure mode: 'all' exposes everything, 'none' exposes nothing, 'custom' uses per-group map. */
const llmMode = ref<'all' | 'none' | 'custom'>('none')
const llmMode = ref<'all' | 'custom' | 'none'>('none')
/** Per-group LLM exposure flags (only used when llmMode === 'custom'). */
const llmExposed = ref<Map<string, boolean>>(new Map())
@@ -138,11 +138,11 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
function toState(entry: ExpressionEntry): ExpressionState {
return {
name: entry.name,
value: entry.currentValue,
default: entry.defaultValue,
active: entry.currentValue !== entry.defaultValue,
autoResetAt: entry.resetTimer != null ? Date.now() : undefined,
default: entry.defaultValue,
name: entry.name,
value: entry.currentValue,
}
}
@@ -193,14 +193,14 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
* Resolve a name to either an expression group or a direct parameter entry.
* Returns `'group'`, `'param'`, or `null`.
*/
function resolve(name: string): { kind: 'group', group: ExpressionGroupDefinition } | { kind: 'param', entry: ExpressionEntry } | null {
function resolve(name: string): null | { entry: ExpressionEntry, kind: 'param' } | { group: ExpressionGroupDefinition, kind: 'group' } {
const group = expressionGroups.value.get(name)
if (group)
return { kind: 'group', group }
return { group, kind: 'group' }
const entry = expressions.value.get(name)
if (entry)
return { kind: 'param', entry }
return { entry, kind: 'param' }
return null
}
@@ -213,9 +213,9 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
if (!resolved) {
return {
success: false,
error: `Expression or parameter "${name}" not found.`,
available: allNames(),
error: `Expression or parameter "${name}" not found.`,
success: false,
}
}
@@ -230,12 +230,12 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
states.push(toState(entry))
}
}
return { success: true, state: states }
return { state: states, success: true }
}
// Direct parameter
applyValue(resolved.entry, numericValue, duration)
return { success: true, state: toState(resolved.entry) }
return { state: toState(resolved.entry), success: true }
}
/**
@@ -248,15 +248,15 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
for (const entry of expressions.value.values()) {
states.push(toState(entry))
}
return { success: true, state: states }
return { state: states, success: true }
}
const resolved = resolve(name)
if (!resolved) {
return {
success: false,
error: `Expression or parameter "${name}" not found.`,
available: allNames(),
error: `Expression or parameter "${name}" not found.`,
success: false,
}
}
@@ -267,10 +267,10 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
if (entry)
states.push(toState(entry))
}
return { success: true, state: states }
return { state: states, success: true }
}
return { success: true, state: toState(resolved.entry) }
return { state: toState(resolved.entry), success: true }
}
/**
@@ -280,9 +280,9 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
const resolved = resolve(name)
if (!resolved) {
return {
success: false,
error: `Expression or parameter "${name}" not found.`,
available: allNames(),
error: `Expression or parameter "${name}" not found.`,
success: false,
}
}
@@ -305,14 +305,14 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
states.push(toState(entry))
}
}
return { success: true, state: states }
return { state: states, success: true }
}
// Direct parameter toggle: flip between modelDefault and exp3 target value
const entry = resolved.entry
const newValue = entry.currentValue !== entry.modelDefault ? entry.modelDefault : entry.targetValue
applyValue(entry, newValue, duration)
return { success: true, state: toState(entry) }
return { state: toState(entry), success: true }
}
/**
@@ -320,7 +320,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
*/
function saveDefaults(): ExpressionToolResult {
if (!modelId.value) {
return { success: false, error: 'No model loaded.' }
return { error: 'No model loaded.', success: false }
}
const defaults: Record<string, number> = {}
@@ -343,7 +343,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
entry.currentValue = entry.modelDefault
states.push(toState(entry))
}
return { success: true, state: states }
return { state: states, success: true }
}
/**
@@ -360,7 +360,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
// ---- LLM exposure --------------------------------------------------------
function setLlmMode(mode: 'all' | 'none' | 'custom') {
function setLlmMode(mode: 'all' | 'custom' | 'none') {
llmMode.value = mode
}
@@ -399,24 +399,24 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
}
return {
dispose,
expressionGroups,
// State (read-only externally, but reactive)
expressions,
modelId,
expressionGroups,
llmMode,
llmExposed,
get,
isExposedToLlm,
llmExposed,
llmMode,
modelId,
// Actions
registerExpressions,
resolve,
set,
get,
toggle,
saveDefaults,
resetAll,
dispose,
setLlmMode,
resolve,
saveDefaults,
set,
setLlmExposed,
isExposedToLlm,
setLlmMode,
toggle,
}
})
@@ -16,29 +16,29 @@ export const defaultModelParameters = {
angleX: 0,
angleY: 0,
angleZ: 0,
leftEyeOpen: 1,
rightEyeOpen: 1,
leftEyeSmile: 0,
rightEyeSmile: 0,
leftEyebrowLR: 0,
rightEyebrowLR: 0,
leftEyebrowY: 0,
rightEyebrowY: 0,
leftEyebrowAngle: 0,
rightEyebrowAngle: 0,
leftEyebrowForm: 0,
rightEyebrowForm: 0,
mouthOpen: 0,
mouthForm: 0,
cheek: 0,
bodyAngleX: 0,
bodyAngleY: 0,
bodyAngleZ: 0,
breath: 0,
cheek: 0,
leftEyebrowAngle: 0,
leftEyebrowForm: 0,
leftEyebrowLR: 0,
leftEyebrowY: 0,
leftEyeOpen: 1,
leftEyeSmile: 0,
mouthForm: 0,
mouthOpen: 0,
rightEyebrowAngle: 0,
rightEyebrowForm: 0,
rightEyebrowLR: 0,
rightEyebrowY: 0,
rightEyeOpen: 1,
rightEyeSmile: 0,
}
export const useLive2dParams = defineStore('live2d', () => {
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-live2d' })
const { data, post } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-live2d' })
const shouldUpdateViewHooks = ref(new Set<() => void>())
const onShouldUpdateView = (hook: () => void) => {
@@ -60,7 +60,7 @@ export const useLive2dParams = defineStore('live2d', () => {
})
const currentMotion = useLocalStorageManualReset<{ group: string, index?: number }>('settings/live2d/current-motion', () => ({ group: 'Idle', index: 0 }))
const availableMotions = useLocalStorageManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>('settings/live2d/available-motions', () => [])
const availableMotions = useLocalStorageManualReset<{ fileName: string, motionIndex: number, motionName: string }[]>('settings/live2d/available-motions', () => [])
const motionMap = useLocalStorageManualReset<Record<string, string>>('settings/live2d/motion-map', {})
const { position, scale, set: setViewControl } = useL2dViewControl()
@@ -77,16 +77,16 @@ export const useLive2dParams = defineStore('live2d', () => {
}
return {
position,
currentMotion,
availableMotions,
motionMap,
scale,
currentMotion,
modelParameters,
motionMap,
onShouldUpdateView,
shouldUpdateView,
position,
resetState,
scale,
shouldUpdateView,
}
})
export { useL2dViewControl }
@@ -2,8 +2,8 @@ import { useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
export const supportedControl = ['x', 'y', 'scale'] as const
interface ControlConfig { buttonText: string, default: number, max: number, min: number, step: number }
type SupportedControl = typeof supportedControl[number]
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: string }
/** show or hide the control element(slider) on stage */
const viewControlsEnabled = ref(false)
@@ -18,34 +18,34 @@ const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
scale: {
buttonText: 'Scale',
default: 1,
max: 3,
min: 0.01,
step: 0.01,
},
// TODO: allow user to set preferred default
x: {
min: -500,
max: 500,
step: 0.1,
default: 0,
buttonText: 'X',
default: 0,
max: 500,
min: -500,
step: 0.1,
},
y: {
min: -500,
max: 500,
step: 0.1,
default: 0,
buttonText: 'Y',
},
scale: {
min: 0.01,
max: 3,
step: 0.01,
default: 1,
buttonText: 'Scale',
default: 0,
max: 500,
min: -500,
step: 0.1,
},
}
export const formatter: Record<SupportedControl, (val: number) => string> = {
scale: formatToPercent,
x: formatPercentD1,
y: formatPercentD1,
scale: formatToPercent,
}
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
export function useL2dViewControl() {
@@ -57,15 +57,15 @@ export function useL2dViewControl() {
function set(key: SupportedControl, value?: number) {
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
switch (key) {
case 'scale':
scale.value = clamped ?? defaultControlConfig.scale.default
break
case 'x':
position.value.x = clamped ?? defaultControlConfig.x.default
break
case 'y':
position.value.y = clamped ?? defaultControlConfig.y.default
break
case 'scale':
scale.value = clamped ?? defaultControlConfig.scale.default
break
}
}
@@ -76,9 +76,9 @@ export function useL2dViewControl() {
scale,
/** reset the given control to its default value. */
set,
/** show or hide the control element(slider) on stage */
viewControlsEnabled,
/** what value to control for the control element */
viewControlMode,
/** show or hide the control element(slider) on stage */
viewControlsEnabled,
}
}
@@ -12,7 +12,7 @@ import { useExpressionStore } from '../stores/expression-store'
function ensureModelLoaded(): ExpressionToolResult | null {
const store = useExpressionStore()
if (!store.modelId || store.expressions.size === 0) {
return { success: false, error: 'No Live2D model is currently loaded.' }
return { error: 'No Live2D model is currently loaded.', success: false }
}
return null
}
@@ -28,14 +28,13 @@ function serialize(result: ExpressionToolResult): string {
const tools = [
// ----- expression.set ----------------------------------------------------
tool({
name: 'expression_set',
description: [
'Set a Live2D expression or parameter value.',
'Use a boolean (true/false) to toggle an expression, or a number (0.0-1.0) for fine control.',
'Optionally provide a duration in seconds for auto-reset.',
'Examples: expression_set("Cry", true), expression_set("Blush", 0.7, 3)',
].join(' '),
execute: async ({ name, value, duration }) => {
execute: async ({ duration, name, value }) => {
const err = ensureModelLoaded()
if (err)
return serialize(err)
@@ -45,16 +44,16 @@ const tools = [
const result = store.set(name, numericValue, duration ?? undefined)
return serialize(result)
},
name: 'expression_set',
parameters: z.object({
duration: z.number().optional().describe('Seconds until auto-reset to default. Omit for permanent change.'),
name: z.string().describe('Expression name or Live2D parameter ID (e.g. "Cry", "ParamWatermarkOFF")'),
value: z.union([z.boolean(), z.number()]).describe('true/false for toggle, or 0.0-1.0 for numeric control'),
duration: z.number().optional().describe('Seconds until auto-reset to default. Omit for permanent change.'),
}),
}),
// ----- expression.get ----------------------------------------------------
tool({
name: 'expression_get',
description: [
'Get the current state of a Live2D expression or parameter.',
'Omit the name to list all available expressions with their current values.',
@@ -68,6 +67,7 @@ const tools = [
const result = store.get(name ?? undefined)
return serialize(result)
},
name: 'expression_get',
parameters: z.object({
name: z.string().optional().describe('Expression name or parameter ID. Omit to list all.'),
}),
@@ -75,12 +75,11 @@ const tools = [
// ----- expression.toggle -------------------------------------------------
tool({
name: 'expression_toggle',
description: [
'Toggle a Live2D expression (flip between default and active state).',
'Optionally provide a duration in seconds for auto-reset.',
].join(' '),
execute: async ({ name, duration }) => {
execute: async ({ duration, name }) => {
const err = ensureModelLoaded()
if (err)
return serialize(err)
@@ -89,15 +88,15 @@ const tools = [
const result = store.toggle(name, duration ?? undefined)
return serialize(result)
},
name: 'expression_toggle',
parameters: z.object({
name: z.string().describe('Expression name or parameter ID to toggle'),
duration: z.number().optional().describe('Seconds until auto-reset. Omit for permanent toggle.'),
name: z.string().describe('Expression name or parameter ID to toggle'),
}),
}),
// ----- expression.saveDefaults -------------------------------------------
tool({
name: 'expression_save_defaults',
description: 'Save the current expression state as the new defaults. Persists across app restarts.',
execute: async () => {
const err = ensureModelLoaded()
@@ -108,12 +107,12 @@ const tools = [
const result = store.saveDefaults()
return serialize(result)
},
name: 'expression_save_defaults',
parameters: z.object({}),
}),
// ----- expression.resetAll -----------------------------------------------
tool({
name: 'expression_reset_all',
description: 'Reset all expressions to their default values.',
execute: async () => {
const err = ensureModelLoaded()
@@ -124,6 +123,7 @@ const tools = [
const result = store.resetAll()
return serialize(result)
},
name: 'expression_reset_all',
parameters: z.object({}),
}),
]
@@ -29,15 +29,15 @@ export async function loadLive2DModelPreview(file: File) {
document.body.appendChild(offscreenCanvas)
const app = new Application({
view: offscreenCanvas,
width: offscreenCanvas.width,
autoDensity: false,
autoStart: false,
backgroundAlpha: 0,
height: offscreenCanvas.height,
// Ensure the drawing buffer persists so toDataURL() can read pixels
preserveDrawingBuffer: true,
backgroundAlpha: 0,
autoDensity: false,
resolution: 1,
autoStart: false,
view: offscreenCanvas,
width: offscreenCanvas.width,
})
app.stage.scale.set(previewResolution)
app.ticker.stop()
@@ -26,21 +26,21 @@ async function generateReport(zipPath: string) {
const allFiles = Object.keys(zip.files)
const report = {
zipPath,
totalFiles: allFiles.length,
entryPoint: null as string | null,
structureType: 'Unknown',
issues: [] as string[],
checks: [] as string[],
entryPoint: null as null | string,
issues: [] as string[],
metadata: {
moc: null as string | null,
textures: [] as string[],
physics: null as string | null,
pose: null as string | null,
cdi: null as string | null,
cdi: null as null | string,
expressions: [] as string[],
moc: null as null | string,
motions: [] as string[],
physics: null as null | string,
pose: null as null | string,
textures: [] as string[],
},
structureType: 'Unknown',
totalFiles: allFiles.length,
zipPath,
}
// 1. Enumerate Files and Check Non-ASCII
@@ -3,34 +3,34 @@ import JSZip from 'jszip'
import { decodeZipFileName } from './decode-zip-filename'
export interface Live2DValidationReport {
fileName: string
totalFiles: number
status: 'VALID' | 'WARNING' | 'INVALID'
entryPoint: string | null
structureType: 'Standard (model3.json)' | 'Heuristic (Loose Files)' | 'Unknown'
errors: string[]
warnings: string[]
checks: string[]
entryPoint: null | string
errors: string[]
fileName: string
mocInfo?: {
header: string
ver: number
size: number
ver: number
}
status: 'INVALID' | 'VALID' | 'WARNING'
structureType: 'Heuristic (Loose Files)' | 'Standard (model3.json)' | 'Unknown'
totalFiles: number
warnings: string[]
}
export async function validateLive2DZip(file: File | Blob): Promise<Live2DValidationReport> {
export async function validateLive2DZip(file: Blob | File): Promise<Live2DValidationReport> {
const zip = await JSZip.loadAsync(file, { decodeFileName: decodeZipFileName })
const allPaths = Object.keys(zip.files)
const report: Live2DValidationReport = {
fileName: (file as File).name || 'live2d-model.zip',
totalFiles: allPaths.length,
status: 'VALID',
entryPoint: null,
structureType: 'Unknown',
errors: [],
warnings: [],
checks: [],
entryPoint: null,
errors: [],
fileName: (file as File).name || 'live2d-model.zip',
status: 'VALID',
structureType: 'Unknown',
totalFiles: allPaths.length,
warnings: [],
}
// 1. Entry Point Identification
@@ -59,7 +59,7 @@ export async function validateLive2DZip(file: File | Blob): Promise<Live2DValida
const ver = buf[4]
const sizeMb = buf.length / 1024 / 1024
report.mocInfo = { header, ver, size: buf.length }
report.mocInfo = { header, size: buf.length, ver }
if (header !== 'MOC3') {
report.errors.push(`Invalid MOC Header: "${header}" (Expected MOC3)`)
@@ -2,25 +2,10 @@ import JSZip from 'jszip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function blobFromBytes(data: Uint8Array): Blob {
const buffer = new ArrayBuffer(data.byteLength)
new Uint8Array(buffer).set(data)
return new Blob([buffer])
}
function fileWithRelativePath(content: Blob | string | Uint8Array, name: string, webkitRelativePath: string): File {
const fileContent = content instanceof Uint8Array ? blobFromBytes(content) : content
const file = new File([fileContent], name)
Object.defineProperty(file, 'webkitRelativePath', {
value: webkitRelativePath,
})
return file
}
class TestFileReader {
result: string | null = null
onload: (() => void) | null = null
onerror: ((error: unknown) => void) | null = null
onload: (() => void) | null = null
result: null | string = null
readAsText(file: File): void {
void file.text()
@@ -32,43 +17,58 @@ class TestFileReader {
}
}
function createShisihangshiSettingsText(): string {
return JSON.stringify({
Version: 3,
FileReferences: {
Moc: '302301_shisihangshi.moc3',
Textures: ['textures/302301_shisihangshi_00.png'],
Physics: null,
Motions: {
'': [{ File: 'motions/t_idle.motion3.json' }],
},
},
Groups: [],
})
function blobFromBytes(data: Uint8Array): Blob {
const buffer = new ArrayBuffer(data.byteLength)
new Uint8Array(buffer).set(data)
return new Blob([buffer])
}
function createCjkPathSettingsText(): string {
return JSON.stringify({
Version: 3,
FileReferences: {
Moc: '测试角色.moc3',
Textures: ['中文纹理/texture_00.png'],
},
Groups: [],
Version: 3,
})
}
function createShisihangshiSettingsText(): string {
return JSON.stringify({
FileReferences: {
Moc: '302301_shisihangshi.moc3',
Motions: {
'': [{ File: 'motions/t_idle.motion3.json' }],
},
Physics: null,
Textures: ['textures/302301_shisihangshi_00.png'],
},
Groups: [],
Version: 3,
})
}
function createSpacePathSettingsText(): string {
return JSON.stringify({
Version: 3,
FileReferences: {
Moc: 'Avatar Model.moc3',
Textures: ['Avatar Model.4096/texture 00.png'],
},
Groups: [],
Version: 3,
})
}
function fileWithRelativePath(content: Blob | string | Uint8Array, name: string, webkitRelativePath: string): File {
const fileContent = content instanceof Uint8Array ? blobFromBytes(content) : content
const file = new File([fileContent], name)
Object.defineProperty(file, 'webkitRelativePath', {
value: webkitRelativePath,
})
return file
}
const appleDoubleHeader = new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0, 77, 97, 99, 32, 79, 83, 32, 88])
describe('live2d zip loader settings sanitization', () => {
@@ -130,9 +130,9 @@ describe('live2d zip loader settings sanitization', () => {
//
// The loader now keeps both sides as decoded archive paths, matching its unzip and upload stages.
const context = {
source: files,
options: {},
live2dModel: new Live2DModel(),
options: {},
source: files,
}
try {
@@ -171,9 +171,9 @@ describe('live2d zip loader settings sanitization', () => {
//
// We fixed this by comparing canonical decoded archive paths at the loader boundary.
const context = {
source: files,
options: {},
live2dModel: new Live2DModel(),
options: {},
source: files,
}
try {
@@ -263,9 +263,9 @@ describe('live2d zip loader settings sanitization', () => {
]
const existingObjectUrls = new Set(Object.keys(FileLoader.filesMap))
const context = {
source: files,
options: {},
live2dModel: new Live2DModel(),
options: {},
source: files,
}
try {
@@ -41,7 +41,7 @@ ZipLoader.createSettings = async (reader: JSZip) => {
try {
const metadataSettings = settings as ModelSettings & {
_cdiData?: unknown
_expFiles?: Array<{ name: string, fileName: string, data: unknown }>
_expFiles?: Array<{ data: unknown, fileName: string, name: string }>
}
// Find and parse CDI file
@@ -55,14 +55,14 @@ ZipLoader.createSettings = async (reader: JSZip) => {
// Find and collect expression files
const expPaths = filePaths.filter(f => f.toLowerCase().endsWith('.exp3.json'))
if (expPaths.length > 0) {
const expFiles: Array<{ name: string, fileName: string, data: unknown }> = []
const expFiles: Array<{ data: unknown, fileName: string, name: string }> = []
for (const expPath of expPaths) {
const expText = await reader.file(expPath)!.async('text')
const baseName = expPath.split('/').pop()?.replace('.exp3.json', '') || expPath
expFiles.push({
name: baseName,
fileName: expPath,
data: JSON.parse(expText),
fileName: expPath,
name: baseName,
})
}
metadataSettings._expFiles = expFiles
@@ -76,6 +76,101 @@ ZipLoader.createSettings = async (reader: JSZip) => {
return settings
}
export function basename(path: string): string {
// https://stackoverflow.com/a/15270931
return path.split(/[\\/]/).pop()!
}
export function isMocFile(file: string) {
return file.endsWith('.moc3')
}
export function isSettingsFile(file: string) {
return !shouldIgnoreLive2DArchiveEntry(file)
&& !file.endsWith('items_pinned_to_model.json')
&& (file.endsWith('.model3.json') || file.endsWith('.model.json'))
}
// copy and modified from https://github.com/guansss/live2d-viewer-web/blob/f6060b2ce52c2e26b6b61fa903c837fe343f72d1/src/app/upload.ts#L81-L142
function createFakeSettings(files: string[]): ModelSettings {
const mocFiles = files.filter(file => isMocFile(file))
if (mocFiles.length !== 1) {
const fileList = mocFiles.length ? `(${mocFiles.map(f => `"${f}"`).join(',')})` : ''
throw new Error(`Expected exactly one moc file, got ${mocFiles.length} ${fileList}`)
}
const mocFile = mocFiles[0]
const modelName = basename(mocFile).replace(/\.moc3?/, '')
const textures = files.filter(f => f.endsWith('.png'))
if (!textures.length) {
throw new Error('Textures not found')
}
const motions = files.filter(f => f.endsWith('.mtn') || f.endsWith('.motion3.json'))
const physics = files.find(f => f.includes('physics'))
const pose = files.find(f => f.includes('pose'))
const settings = new Cubism4ModelSettings({
FileReferences: {
Moc: mocFile,
Motions: motions.length
? {
'': motions.map(motion => ({ File: motion })),
}
: undefined,
Physics: physics,
Pose: pose,
Textures: textures,
},
url: `${modelName}.model3.json`,
Version: 3,
})
settings.name = modelName
// provide this property for FileLoader
Object.assign(settings, { _objectURL: `example://${settings.url}` })
return settings
}
function createModelSettings(text: string, url: string): ModelSettings {
if (!text) {
throw new Error(`Empty settings file: ${url}`)
}
const settingsJSON = JSON.parse(text) as JSONObject & { url?: string }
settingsJSON.url = url
const runtime = Live2DFactory.findRuntime(settingsJSON)
if (!runtime) {
throw new Error('Unknown settings JSON')
}
return useArchivePathResolution(runtime.createModelSettings(settingsJSON))
}
/**
* Normalizes a resolved Live2D resource path to the decoded archive representation.
*
* @example
* normalizeLive2DArchivePath('Model%20Package/Avatar%20Model.moc3')
* // => 'Model Package/Avatar Model.moc3'
*/
function normalizeLive2DArchivePath(path: string): string {
try {
return decodeURI(path)
}
catch {
// Malformed percent escapes cannot be URI-decoded and therefore represent a literal archive path.
return path
}
}
/**
* Normalizes Live2D model settings JSON before upstream path resolution.
*
@@ -102,107 +197,12 @@ function sanitizeModelSettingsText(text: string): string {
return JSON.stringify(json)
}
/**
* Normalizes a resolved Live2D resource path to the decoded archive representation.
*
* @example
* normalizeLive2DArchivePath('Model%20Package/Avatar%20Model.moc3')
* // => 'Model Package/Avatar Model.moc3'
*/
function normalizeLive2DArchivePath(path: string): string {
try {
return decodeURI(path)
}
catch {
// Malformed percent escapes cannot be URI-decoded and therefore represent a literal archive path.
return path
}
}
function useArchivePathResolution(settings: ModelSettings): ModelSettings {
const resolveURL = settings.resolveURL.bind(settings)
settings.resolveURL = path => normalizeLive2DArchivePath(resolveURL(path))
return settings
}
function createModelSettings(text: string, url: string): ModelSettings {
if (!text) {
throw new Error(`Empty settings file: ${url}`)
}
const settingsJSON = JSON.parse(text) as JSONObject & { url?: string }
settingsJSON.url = url
const runtime = Live2DFactory.findRuntime(settingsJSON)
if (!runtime) {
throw new Error('Unknown settings JSON')
}
return useArchivePathResolution(runtime.createModelSettings(settingsJSON))
}
export function isSettingsFile(file: string) {
return !shouldIgnoreLive2DArchiveEntry(file)
&& !file.endsWith('items_pinned_to_model.json')
&& (file.endsWith('.model3.json') || file.endsWith('.model.json'))
}
export function isMocFile(file: string) {
return file.endsWith('.moc3')
}
export function basename(path: string): string {
// https://stackoverflow.com/a/15270931
return path.split(/[\\/]/).pop()!
}
// copy and modified from https://github.com/guansss/live2d-viewer-web/blob/f6060b2ce52c2e26b6b61fa903c837fe343f72d1/src/app/upload.ts#L81-L142
function createFakeSettings(files: string[]): ModelSettings {
const mocFiles = files.filter(file => isMocFile(file))
if (mocFiles.length !== 1) {
const fileList = mocFiles.length ? `(${mocFiles.map(f => `"${f}"`).join(',')})` : ''
throw new Error(`Expected exactly one moc file, got ${mocFiles.length} ${fileList}`)
}
const mocFile = mocFiles[0]
const modelName = basename(mocFile).replace(/\.moc3?/, '')
const textures = files.filter(f => f.endsWith('.png'))
if (!textures.length) {
throw new Error('Textures not found')
}
const motions = files.filter(f => f.endsWith('.mtn') || f.endsWith('.motion3.json'))
const physics = files.find(f => f.includes('physics'))
const pose = files.find(f => f.includes('pose'))
const settings = new Cubism4ModelSettings({
url: `${modelName}.model3.json`,
Version: 3,
FileReferences: {
Moc: mocFile,
Textures: textures,
Physics: physics,
Pose: pose,
Motions: motions.length
? {
'': motions.map(motion => ({ File: motion })),
}
: undefined,
},
})
settings.name = modelName
// provide this property for FileLoader
Object.assign(settings, { _objectURL: `example://${settings.url}` })
return settings
}
ZipLoader.readText = async (jsZip: JSZip, path: string) => {
const file = jsZip.file(path)
@@ -4,28 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OPFSCache } from './opfs-loader'
class MemoryFileHandle {
kind = 'file' as const
private content: Blob = new Blob()
constructor(public readonly name: string) {}
async getFile(): Promise<File> {
return new File([this.content], this.name)
}
async createWritable(): Promise<{ write: (content: Blob | string) => Promise<void>, close: () => Promise<void> }> {
return {
write: async (content: Blob | string) => {
this.content = typeof content === 'string'
? new Blob([content])
: content
},
close: async () => {},
}
}
}
type MemoryHandle = MemoryDirectoryHandle | MemoryFileHandle
class MemoryDirectoryHandle {
@@ -72,6 +50,28 @@ class MemoryDirectoryHandle {
}
}
class MemoryFileHandle {
kind = 'file' as const
private content: Blob = new Blob()
constructor(public readonly name: string) {}
async createWritable(): Promise<{ close: () => Promise<void>, write: (content: Blob | string) => Promise<void> }> {
return {
close: async () => {},
write: async (content: Blob | string) => {
this.content = typeof content === 'string'
? new Blob([content])
: content
},
}
}
async getFile(): Promise<File> {
return new File([this.content], this.name)
}
}
function blobFromBytes(data: Uint8Array): Blob {
const buffer = new ArrayBuffer(data.byteLength)
new Uint8Array(buffer).set(data)
@@ -89,6 +89,10 @@ async function createZip(entries: Record<string, Blob | string | Uint8Array>): P
return new Blob([await blobFromBytes(data).arrayBuffer()], { type: 'application/zip' })
}
function filePaths(files: File[]): string[] {
return files.map(file => file.webkitRelativePath).sort()
}
function installMemoryOPFS(root = new MemoryDirectoryHandle('root')): MemoryDirectoryHandle {
vi.stubGlobal('navigator', {
storage: {
@@ -107,10 +111,6 @@ async function writeLegacyCache(root: MemoryDirectoryHandle, key: string): Promi
)
}
function filePaths(files: File[]): string[] {
return files.map(file => file.webkitRelativePath).sort()
}
describe('opfs cache full directory persistence', () => {
let root: MemoryDirectoryHandle
@@ -124,15 +124,15 @@ describe('opfs cache full directory persistence', () => {
it('saves every zip entry and restores webkitRelativePath from the physical OPFS directory', async () => {
const zipBlob = await createZip({
'__MACOSX/._model.model3.json': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]),
'._model.moc3': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]),
'model.model3.json': JSON.stringify({
Version: 3,
FileReferences: { Moc: 'model.moc3', Textures: ['textures/texture_00.png'] },
}),
'model.moc3': new Uint8Array([77, 79, 67, 51]),
'textures/texture_00.png': new Uint8Array([1, 2, 3]),
'__MACOSX/._model.model3.json': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]),
'extra/readme.txt': 'kept from original archive',
'model.moc3': new Uint8Array([77, 79, 67, 51]),
'model.model3.json': JSON.stringify({
FileReferences: { Moc: 'model.moc3', Textures: ['textures/texture_00.png'] },
Version: 3,
}),
'textures/texture_00.png': new Uint8Array([1, 2, 3]),
})
await OPFSCache.save('live2d-model', zipBlob, 'blob:first')
@@ -159,8 +159,8 @@ describe('opfs cache full directory persistence', () => {
dir as unknown as FileSystemDirectoryHandle,
'model.model3.json',
JSON.stringify({
Version: 3,
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
Version: 3,
}),
)
await OPFSCache.writeFile(
@@ -178,15 +178,15 @@ describe('opfs cache full directory persistence', () => {
it('keeps the original model3.json text without reconstructing or double-encoding paths', async () => {
const encodedMoc = encodeURI('八千代辉夜姬.moc3')
const settingsText = JSON.stringify({
Version: 3,
FileReferences: {
Moc: encodedMoc,
Textures: ['textures/texture_00.png'],
},
Version: 3,
})
const zipBlob = await createZip({
'model.model3.json': settingsText,
[encodedMoc]: new Uint8Array([77, 79, 67, 51]),
'model.model3.json': settingsText,
'textures/texture_00.png': new Uint8Array([1, 2, 3]),
})
@@ -211,11 +211,11 @@ describe('opfs cache full directory persistence', () => {
it('invalidates non-blob URL cache entries when the source URL changes', async () => {
const zipBlob = await createZip({
'__MACOSX/._model.model3.json': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]),
'model.model3.json': JSON.stringify({
Version: 3,
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
}),
'model.moc3': new Uint8Array([77, 79, 67, 51]),
'model.model3.json': JSON.stringify({
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
Version: 3,
}),
'texture.png': new Uint8Array([1, 2, 3]),
})
@@ -228,11 +228,11 @@ describe('opfs cache full directory persistence', () => {
it('does not invalidate blob URL cache entries when the stable model key matches', async () => {
const zipBlob = await createZip({
'model.model3.json': JSON.stringify({
Version: 3,
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
}),
'model.moc3': new Uint8Array([77, 79, 67, 51]),
'model.model3.json': JSON.stringify({
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
Version: 3,
}),
'texture.png': new Uint8Array([1, 2, 3]),
})
@@ -249,13 +249,13 @@ describe('opfs cache full directory persistence', () => {
it('reads an asar file response through arrayBuffer when Response.blob fails', async () => {
const zipBlob = await createZip({
'model.model3.json': JSON.stringify({
Version: 3,
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
}),
'model.moc3': new Uint8Array([77, 79, 67, 51]),
'texture.png': new Uint8Array([1, 2, 3]),
'model.model3.json': JSON.stringify({
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
Version: 3,
}),
'not-defined-by-settings.txt': 'still cached',
'texture.png': new Uint8Array([1, 2, 3]),
})
const responseBlob = vi.fn(async () => {
throw new TypeError('Failed to fetch')
+188 -188
View File
@@ -4,17 +4,17 @@ import JSZip from 'jszip'
import { decodeZipFileName } from './decode-zip-filename'
interface OPFSCacheMeta {
sourceUrl?: string
version?: number
}
interface OPFSContext extends Live2DFactoryContext {
opfsKey?: string
opfsUrl?: string
opfsZipBlob?: Blob
}
interface OPFSCacheMeta {
sourceUrl?: string
version?: number
}
/**
* Cache schema version for OPFS-stored Live2D zip directories.
*
@@ -34,190 +34,7 @@ const ignoredArchivePathSegmentRules: IgnoredArchivePathSegmentRule[] = [
{ matches: segment => segment.startsWith('._') },
]
function shouldIgnoreLive2DArchiveEntry(filePath: string): boolean {
return filePath
.split('/')
.some(segment => ignoredArchivePathSegmentRules.some(rule => rule.matches(segment)))
}
function blobFromBytes(data: Uint8Array): Blob {
const buffer = new ArrayBuffer(data.byteLength)
new Uint8Array(buffer).set(data)
return new Blob([buffer])
}
export class OPFSCache {
static async clearAll(): Promise<void> {
try {
const root = await navigator.storage.getDirectory()
for await (const entry of root.values()) {
await root.removeEntry(entry.name, { recursive: true })
}
}
catch (e) {
console.error('[OPFS] Failed to clear cache:', e)
}
}
static async readDirectoryRecursive(dir: FileSystemDirectoryHandle, pathPrefix: string): Promise<File[]> {
const files: File[] = []
for await (const entry of dir.values()) {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle
const file = await fileHandle.getFile()
const relativePath = pathPrefix + file.name
if (file.name === '__meta.json' || shouldIgnoreLive2DArchiveEntry(relativePath))
continue
// live2d-display expects this
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
})
files.push(file)
}
else if (entry.kind === 'directory') {
const newPrefix = `${pathPrefix + entry.name}/`
const subFiles = await OPFSCache.readDirectoryRecursive(entry as FileSystemDirectoryHandle, newPrefix)
files.push(...subFiles)
}
}
return files
}
static async resolveDirectory(root: FileSystemDirectoryHandle, path: string): Promise<FileSystemDirectoryHandle> {
let currentDir = root
if (!path || path === '.' || path === './')
return currentDir
const parts = path.split('/').filter(p => p && p !== '.')
for (const part of parts) {
currentDir = await currentDir.getDirectoryHandle(part, { create: true })
}
return currentDir
}
private static async clearDirectory(dirHandle: FileSystemDirectoryHandle): Promise<void> {
const entryNames: string[] = []
// OPFS writes mirror the source zip exactly, so stale files from a previous
// failed or superseded save must be removed before writing fresh entries.
for await (const entry of dirHandle.values()) {
entryNames.push(entry.name)
}
await Promise.all(entryNames.map(name => dirHandle.removeEntry(name, { recursive: true })))
}
static async writeFile(root: FileSystemDirectoryHandle, filePath: string, content: Blob | string): Promise<void> {
const parts = filePath.split('/')
const fileName = parts.pop()!
const dirPath = parts.join('/')
const dirHandle = await OPFSCache.resolveDirectory(root, dirPath)
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(content)
await writable.close()
}
static async readMeta(dirHandle: FileSystemDirectoryHandle) {
try {
const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false })
const metaFile = await metaHandle.getFile()
const metaText = await metaFile.text()
return JSON.parse(metaText) as OPFSCacheMeta
}
catch {
return null
}
}
static async get(key: string, sourceUrl: string): Promise<File[] | null> {
try {
const root = await navigator.storage.getDirectory()
const dirHandle = await root.getDirectoryHandle(key, { create: false })
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache hit for ${key}`)
const meta = await OPFSCache.readMeta(dirHandle)
if (meta?.version !== live2DOpfsCacheVersion) {
// NOTICE: Rebuild caches created before OPFS stored the full zip directory.
// Older caches may contain a reconstructed model3.json instead of the
// original archive settings file.
// Source/context: OPFSCache.saveMiddleware settings reconstruction.
// Removal condition: old OPFS caches no longer need migration support.
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache mismatch for ${key}, schema version changed`)
await root.removeEntry(dirHandle.name, { recursive: true })
return null
}
const shouldValidateSourceUrl = !sourceUrl.startsWith('blob:')
if (shouldValidateSourceUrl && meta.sourceUrl && meta.sourceUrl !== sourceUrl) {
// NOTICE: Skip cache when the requested URL changes while the key stays the same.
// This avoids serving a stale model when ids are reused or props are out of sync.
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache mismatch for ${key}, source url changed`)
await root.removeEntry(dirHandle.name, { recursive: true }) // actually invalidates cache
return null
}
const files = await OPFSCache.readDirectoryRecursive(dirHandle, '')
if (files.length > 0) {
return files
}
}
catch {
// Cache Miss
}
return null
}
/**
* Persists every non-directory entry from a Live2D zip into OPFS.
*
* Use when:
* - Caching a loaded Live2D zip for later FileLoader replay
* - Preserving the original model3.json and archive paths exactly
*
* Expects:
* - `zipBlob` is the original archive blob fetched by checkMiddleware
* - ZIP entry paths are already the physical paths to persist
*
* Returns:
* - A completed OPFS directory write, or logs and returns on cache write failure
*/
static async save(key: string, zipBlob: Blob, sourceUrl?: string): Promise<void> {
try {
const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer(), { decodeFileName: decodeZipFileName })
const fileEntries = Object.values(zip.files)
.filter(file => !file.dir && !shouldIgnoreLive2DArchiveEntry(file.name))
// eslint-disable-next-line no-console
console.debug(`[OPFS] Saving ${fileEntries.length} zip entries to ${key}`)
const root = await navigator.storage.getDirectory()
const dirHandle = await root.getDirectoryHandle(key, { create: true })
await OPFSCache.clearDirectory(dirHandle)
const writePromises = fileEntries.map(async (file) => {
const data = await file.async('uint8array')
return OPFSCache.writeFile(dirHandle, file.name, blobFromBytes(data))
})
await Promise.all(writePromises)
await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({
sourceUrl,
version: live2DOpfsCacheVersion,
}))
// eslint-disable-next-line no-console
console.debug(`[OPFS] Saved to cache`)
}
catch (e) {
console.error('[OPFS] Failed to save to cache:', e)
}
}
// Runs before ZipLoader to check if the file is already cached
static checkMiddleware: Middleware<OPFSContext> = async (context, next) => {
const source = context.source
@@ -275,6 +92,153 @@ export class OPFSCache {
return next()
}
static async clearAll(): Promise<void> {
try {
const root = await navigator.storage.getDirectory()
for await (const entry of root.values()) {
await root.removeEntry(entry.name, { recursive: true })
}
}
catch (e) {
console.error('[OPFS] Failed to clear cache:', e)
}
}
static async get(key: string, sourceUrl: string): Promise<File[] | null> {
try {
const root = await navigator.storage.getDirectory()
const dirHandle = await root.getDirectoryHandle(key, { create: false })
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache hit for ${key}`)
const meta = await OPFSCache.readMeta(dirHandle)
if (meta?.version !== live2DOpfsCacheVersion) {
// NOTICE: Rebuild caches created before OPFS stored the full zip directory.
// Older caches may contain a reconstructed model3.json instead of the
// original archive settings file.
// Source/context: OPFSCache.saveMiddleware settings reconstruction.
// Removal condition: old OPFS caches no longer need migration support.
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache mismatch for ${key}, schema version changed`)
await root.removeEntry(dirHandle.name, { recursive: true })
return null
}
const shouldValidateSourceUrl = !sourceUrl.startsWith('blob:')
if (shouldValidateSourceUrl && meta.sourceUrl && meta.sourceUrl !== sourceUrl) {
// NOTICE: Skip cache when the requested URL changes while the key stays the same.
// This avoids serving a stale model when ids are reused or props are out of sync.
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache mismatch for ${key}, source url changed`)
await root.removeEntry(dirHandle.name, { recursive: true }) // actually invalidates cache
return null
}
const files = await OPFSCache.readDirectoryRecursive(dirHandle, '')
if (files.length > 0) {
return files
}
}
catch {
// Cache Miss
}
return null
}
static async readDirectoryRecursive(dir: FileSystemDirectoryHandle, pathPrefix: string): Promise<File[]> {
const files: File[] = []
for await (const entry of dir.values()) {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle
const file = await fileHandle.getFile()
const relativePath = pathPrefix + file.name
if (file.name === '__meta.json' || shouldIgnoreLive2DArchiveEntry(relativePath))
continue
// live2d-display expects this
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
})
files.push(file)
}
else if (entry.kind === 'directory') {
const newPrefix = `${pathPrefix + entry.name}/`
const subFiles = await OPFSCache.readDirectoryRecursive(entry as FileSystemDirectoryHandle, newPrefix)
files.push(...subFiles)
}
}
return files
}
static async readMeta(dirHandle: FileSystemDirectoryHandle) {
try {
const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false })
const metaFile = await metaHandle.getFile()
const metaText = await metaFile.text()
return JSON.parse(metaText) as OPFSCacheMeta
}
catch {
return null
}
}
static async resolveDirectory(root: FileSystemDirectoryHandle, path: string): Promise<FileSystemDirectoryHandle> {
let currentDir = root
if (!path || path === '.' || path === './')
return currentDir
const parts = path.split('/').filter(p => p && p !== '.')
for (const part of parts) {
currentDir = await currentDir.getDirectoryHandle(part, { create: true })
}
return currentDir
}
/**
* Persists every non-directory entry from a Live2D zip into OPFS.
*
* Use when:
* - Caching a loaded Live2D zip for later FileLoader replay
* - Preserving the original model3.json and archive paths exactly
*
* Expects:
* - `zipBlob` is the original archive blob fetched by checkMiddleware
* - ZIP entry paths are already the physical paths to persist
*
* Returns:
* - A completed OPFS directory write, or logs and returns on cache write failure
*/
static async save(key: string, zipBlob: Blob, sourceUrl?: string): Promise<void> {
try {
const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer(), { decodeFileName: decodeZipFileName })
const fileEntries = Object.values(zip.files)
.filter(file => !file.dir && !shouldIgnoreLive2DArchiveEntry(file.name))
// eslint-disable-next-line no-console
console.debug(`[OPFS] Saving ${fileEntries.length} zip entries to ${key}`)
const root = await navigator.storage.getDirectory()
const dirHandle = await root.getDirectoryHandle(key, { create: true })
await OPFSCache.clearDirectory(dirHandle)
const writePromises = fileEntries.map(async (file) => {
const data = await file.async('uint8array')
return OPFSCache.writeFile(dirHandle, file.name, blobFromBytes(data))
})
await Promise.all(writePromises)
await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({
sourceUrl,
version: live2DOpfsCacheVersion,
}))
// eslint-disable-next-line no-console
console.debug(`[OPFS] Saved to cache`)
}
catch (e) {
console.error('[OPFS] Failed to save to cache:', e)
}
}
// Runs after ZipLoader to cache the files
static saveMiddleware: Middleware<OPFSContext> = async (context, next) => {
if (!context.opfsKey || !context.opfsZipBlob) {
@@ -285,4 +249,40 @@ export class OPFSCache {
return next()
}
static async writeFile(root: FileSystemDirectoryHandle, filePath: string, content: Blob | string): Promise<void> {
const parts = filePath.split('/')
const fileName = parts.pop()!
const dirPath = parts.join('/')
const dirHandle = await OPFSCache.resolveDirectory(root, dirPath)
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(content)
await writable.close()
}
private static async clearDirectory(dirHandle: FileSystemDirectoryHandle): Promise<void> {
const entryNames: string[] = []
// OPFS writes mirror the source zip exactly, so stale files from a previous
// failed or superseded save must be removed before writing fresh entries.
for await (const entry of dirHandle.values()) {
entryNames.push(entry.name)
}
await Promise.all(entryNames.map(name => dirHandle.removeEntry(name, { recursive: true })))
}
}
function blobFromBytes(data: Uint8Array): Blob {
const buffer = new ArrayBuffer(data.byteLength)
new Uint8Array(buffer).set(data)
return new Blob([buffer])
}
function shouldIgnoreLive2DArchiveEntry(filePath: string): boolean {
return filePath
.split('/')
.some(segment => ignoredArchivePathSegmentRules.some(rule => rule.matches(segment)))
}