style: lint
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user