Preserve neutral animation targets and relative eye values across round trips. Use bounded parsing and unique IDs to reject unsafe or lossy imports.
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { parseAvatarBody, type AvatarBody } from './body'
|
|
import { defaultExpression, initialExpressions } from './presets'
|
|
import { surfacePresets } from './surfaces'
|
|
import type { Expression } from './geometry'
|
|
import { isBodyMotion, isEyeMotion } from './ambientMotion'
|
|
import {
|
|
normalizeSequencesForExpressions,
|
|
parseSequences,
|
|
type AvatarSequence,
|
|
} from '../animation/sequences'
|
|
|
|
export type AvatarBehaviorLibrary = {
|
|
expressions: Expression[]
|
|
sequences: AvatarSequence[]
|
|
}
|
|
|
|
export type StudioAvatar = {
|
|
id: string
|
|
name: string
|
|
body: AvatarBody
|
|
colors: AvatarColors
|
|
eyes: AvatarEyeDefaults
|
|
renderStyle: AvatarRenderStyle
|
|
behavior?: AvatarBehaviorLibrary
|
|
}
|
|
|
|
export type AvatarColors = { body: string; eyes: string }
|
|
export const PIXEL_RENDERING_ENABLED = false
|
|
export type PixelRenderStyle = {
|
|
type: 'pixel'
|
|
resolution: number
|
|
}
|
|
export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle
|
|
export type AvatarEyeDefaults = Pick<
|
|
Expression,
|
|
| 'widthLeft'
|
|
| 'widthRight'
|
|
| 'heightLeft'
|
|
| 'heightRight'
|
|
| 'spacing'
|
|
| 'positionXLeft'
|
|
| 'positionXRight'
|
|
| 'positionYLeft'
|
|
| 'positionYRight'
|
|
| 'leftAngle'
|
|
| 'rightAngle'
|
|
>
|
|
export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' }
|
|
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
|
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
|
type: 'pixel',
|
|
resolution: 64,
|
|
}
|
|
export const defaultAvatarEyes: AvatarEyeDefaults = {
|
|
widthLeft: defaultExpression.widthLeft,
|
|
widthRight: defaultExpression.widthRight,
|
|
heightLeft: defaultExpression.heightLeft,
|
|
heightRight: defaultExpression.heightRight,
|
|
spacing: defaultExpression.spacing,
|
|
positionXLeft: defaultExpression.positionXLeft,
|
|
positionXRight: defaultExpression.positionXRight,
|
|
positionYLeft: defaultExpression.positionYLeft,
|
|
positionYRight: defaultExpression.positionYRight,
|
|
leftAngle: defaultExpression.leftAngle,
|
|
rightAngle: defaultExpression.rightAngle,
|
|
}
|
|
const hexColor = /^#[0-9a-f]{6}$/i
|
|
const parseColors = (value: unknown): AvatarColors => {
|
|
const candidate = value as Partial<AvatarColors> | null
|
|
return {
|
|
body:
|
|
typeof candidate?.body === 'string' && hexColor.test(candidate.body)
|
|
? candidate.body
|
|
: defaultAvatarColors.body,
|
|
eyes:
|
|
typeof candidate?.eyes === 'string' && hexColor.test(candidate.eyes)
|
|
? candidate.eyes
|
|
: defaultAvatarColors.eyes,
|
|
}
|
|
}
|
|
|
|
const finiteBounded = (value: unknown, fallback: number, min: number, max: number) =>
|
|
typeof value === 'number' && Number.isFinite(value)
|
|
? Math.min(max, Math.max(min, value))
|
|
: fallback
|
|
|
|
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
|
const candidate = value as Partial<PixelRenderStyle> | null
|
|
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
|
return { ...defaultAvatarRenderStyle }
|
|
}
|
|
return {
|
|
type: 'pixel',
|
|
resolution: Math.round(
|
|
finiteBounded(candidate.resolution, defaultPixelRenderStyle.resolution, 8, 192)
|
|
),
|
|
}
|
|
}
|
|
|
|
const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[]
|
|
export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => {
|
|
const candidate = value as Partial<AvatarEyeDefaults> | null
|
|
const parsed = { ...defaultAvatarEyes }
|
|
eyeDefaultFields.forEach(field => {
|
|
const stored = candidate?.[field]
|
|
if (typeof stored === 'number' && Number.isFinite(stored)) parsed[field] = stored
|
|
})
|
|
return parsed
|
|
}
|
|
|
|
export const applyAvatarEyeDefaults = (
|
|
expression: Expression,
|
|
eyes: AvatarEyeDefaults = defaultAvatarEyes
|
|
): Expression => {
|
|
const result = { ...expression }
|
|
eyeDefaultFields.forEach(field => {
|
|
result[field] = expression[field] + eyes[field] - defaultAvatarEyes[field]
|
|
})
|
|
result.widthLeft = Math.max(10, result.widthLeft)
|
|
result.widthRight = Math.max(10, result.widthRight)
|
|
result.heightLeft = Math.max(10, result.heightLeft)
|
|
result.heightRight = Math.max(10, result.heightRight)
|
|
return result
|
|
}
|
|
|
|
export const createUnkeyedExpressionCopy = (source: Expression, id: string): Expression => ({
|
|
...source,
|
|
id,
|
|
semanticKey: undefined,
|
|
})
|
|
|
|
export type AvatarLibrary = {
|
|
activeAvatarId: string
|
|
avatars: StudioAvatar[]
|
|
}
|
|
|
|
const cloneExpressions = (expressions: Expression[]) => expressions.map(item => ({ ...item }))
|
|
export const parseExpressions = (value: unknown): Expression[] => {
|
|
if (!Array.isArray(value) || !value.length) return cloneExpressions(initialExpressions)
|
|
return value.map((item, index) => {
|
|
if (!item || typeof item !== 'object') {
|
|
return { ...defaultExpression, id: `expression-${String(index).padStart(2, '0')}` }
|
|
}
|
|
const candidate = item as Partial<Expression>
|
|
const storedEyeMotion = (item as { eyeMotion?: unknown }).eyeMotion
|
|
const storedBodyMotion = (item as { bodyMotion?: unknown }).bodyMotion
|
|
const parsed = Object.fromEntries(
|
|
Object.entries(defaultExpression).map(([field, fallback]) => {
|
|
if (field === 'id') {
|
|
return [
|
|
field,
|
|
typeof candidate.id === 'string' && candidate.id
|
|
? candidate.id
|
|
: `expression-${String(index).padStart(2, '0')}`,
|
|
]
|
|
}
|
|
const stored = candidate[field as keyof Expression]
|
|
return [field, typeof stored === 'number' && Number.isFinite(stored) ? stored : fallback]
|
|
})
|
|
) as Expression
|
|
if (typeof candidate.bodyColor === 'string' && hexColor.test(candidate.bodyColor))
|
|
parsed.bodyColor = candidate.bodyColor
|
|
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
|
|
parsed.eyeColor = candidate.eyeColor
|
|
if (typeof candidate.semanticKey === 'string') parsed.semanticKey = candidate.semanticKey
|
|
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
|
|
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
|
|
? storedBodyMotion
|
|
: defaultExpression.bodyMotion
|
|
return parsed
|
|
})
|
|
}
|
|
|
|
const cloneSequences = (sequences: AvatarSequence[]) =>
|
|
sequences.map(sequence => ({
|
|
...sequence,
|
|
steps: sequence.steps.map(step => ({ ...step })),
|
|
blink: { ...sequence.blink },
|
|
}))
|
|
|
|
export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
|
|
expressions: cloneExpressions(behavior.expressions),
|
|
sequences: cloneSequences(behavior.sequences),
|
|
})
|
|
|
|
export const restoreLegacyBehaviorSemanticKeys = (
|
|
behavior: AvatarBehaviorLibrary,
|
|
reference: AvatarBehaviorLibrary
|
|
): AvatarBehaviorLibrary => {
|
|
const expressionKeys = new Map(
|
|
reference.expressions.flatMap(expression =>
|
|
expression.semanticKey ? [[expression.id, expression.semanticKey] as const] : []
|
|
)
|
|
)
|
|
const sequenceKeys = new Map(
|
|
reference.sequences.flatMap(sequence =>
|
|
sequence.semanticKey ? [[sequence.id, sequence.semanticKey] as const] : []
|
|
)
|
|
)
|
|
const restoreExpressions = behavior.expressions.every(
|
|
expression => expression.semanticKey === undefined
|
|
)
|
|
const restoreSequences = behavior.sequences.every(sequence => sequence.semanticKey === undefined)
|
|
|
|
return {
|
|
expressions: restoreExpressions
|
|
? behavior.expressions.map(expression => {
|
|
const semanticKey = expressionKeys.get(expression.id)
|
|
return semanticKey ? { ...expression, semanticKey } : expression
|
|
})
|
|
: behavior.expressions,
|
|
sequences: restoreSequences
|
|
? behavior.sequences.map(sequence => {
|
|
const semanticKey = sequenceKeys.get(sequence.id)
|
|
return semanticKey ? { ...sequence, semanticKey } : sequence
|
|
})
|
|
: behavior.sequences,
|
|
}
|
|
}
|
|
|
|
export const resolveAvatarBehavior = (
|
|
avatar: StudioAvatar,
|
|
base: AvatarBehaviorLibrary
|
|
): AvatarBehaviorLibrary => avatar.behavior ?? base
|
|
|
|
const parseAvatarBehavior = (
|
|
value: unknown,
|
|
base: AvatarBehaviorLibrary
|
|
): AvatarBehaviorLibrary | undefined => {
|
|
if (!value || typeof value !== 'object') return undefined
|
|
const candidate = value as Partial<AvatarBehaviorLibrary>
|
|
if (!Array.isArray(candidate.expressions)) return undefined
|
|
const expressions = candidate.expressions.length ? parseExpressions(candidate.expressions) : []
|
|
return restoreLegacyBehaviorSemanticKeys(
|
|
{
|
|
expressions,
|
|
sequences: normalizeSequencesForExpressions(
|
|
Array.isArray(candidate.sequences)
|
|
? parseSequences(candidate.sequences)
|
|
: cloneSequences(base.sequences),
|
|
expressions
|
|
),
|
|
},
|
|
base
|
|
)
|
|
}
|
|
|
|
export const createAvatar = (name: string): StudioAvatar => ({
|
|
id: `avatar-${crypto.randomUUID()}`,
|
|
name: name.trim() || 'Nouvel avatar',
|
|
body: { primary: { ...surfacePresets.sphere }, nodes: [] },
|
|
colors: { ...defaultAvatarColors },
|
|
eyes: { ...defaultAvatarEyes },
|
|
renderStyle: { ...defaultAvatarRenderStyle },
|
|
})
|
|
|
|
export const parseAvatarLibrary = (
|
|
value: unknown,
|
|
fallback: AvatarLibrary,
|
|
baseBehavior: AvatarBehaviorLibrary
|
|
): AvatarLibrary => {
|
|
try {
|
|
const parsed = value as Partial<AvatarLibrary> | null
|
|
if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback
|
|
const seenIds = new Set<string>()
|
|
const avatars = parsed.avatars
|
|
.filter(avatar => {
|
|
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
|
|
return false
|
|
if (seenIds.has(avatar.id)) return false
|
|
seenIds.add(avatar.id)
|
|
return true
|
|
})
|
|
.map(avatar => {
|
|
const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior)
|
|
return {
|
|
id: avatar.id,
|
|
name: avatar.name,
|
|
body: parseAvatarBody(avatar.body, surfacePresets.sphere),
|
|
colors: parseColors(avatar.colors),
|
|
eyes: parseAvatarEyeDefaults(avatar.eyes),
|
|
renderStyle: parseAvatarRenderStyle(avatar.renderStyle),
|
|
...(behavior ? { behavior } : {}),
|
|
}
|
|
})
|
|
if (!avatars.length) return fallback
|
|
const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId)
|
|
? parsed.activeAvatarId!
|
|
: avatars[0].id
|
|
return { activeAvatarId, avatars }
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|