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 | 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 | 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 | 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 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 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 | null if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback const seenIds = new Set() 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 } }