bible-strong-avatar-lab/src/features/avatar/avatars.ts
smontlouis 7f414b82de fix(studio): preserve avatar definitions during import
Preserve neutral animation targets and relative eye values across round trips. Use bounded parsing and unique IDs to reject unsafe or lossy imports.
2026-08-25 13:07:48 +02:00

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
}
}