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.
This commit is contained in:
smontlouis 2026-08-25 13:07:48 +02:00
parent d6fcf63fe6
commit 7f414b82de
13 changed files with 413 additions and 96 deletions

View File

@ -3,6 +3,7 @@ import {
createInitialSequences, createInitialSequences,
duplicateSequence, duplicateSequence,
getSequenceSpring, getSequenceSpring,
NEUTRAL_EXPRESSION_ID,
normalizeSequencesForExpressions, normalizeSequencesForExpressions,
parseSequences, parseSequences,
remapSequencesAfterExpressionDelete, remapSequencesAfterExpressionDelete,
@ -88,6 +89,21 @@ describe('editable avatar sequences', () => {
expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id) expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
}) })
it('preserves the system neutral target during normalization', () => {
const sequence = createInitialSequences()[0]
const [normalized] = normalizeSequencesForExpressions(
[
{
...sequence,
steps: [{ ...sequence.steps[0], expressionId: NEUTRAL_EXPRESSION_ID }],
},
],
[]
)
expect(normalized.steps[0].expressionId).toBe(NEUTRAL_EXPRESSION_ID)
})
it('maps transition styles and durations to distinct spring dynamics', () => { it('maps transition styles and durations to distinct spring dynamics', () => {
const smooth = getSequenceSpring('smooth', 900, 7) const smooth = getSequenceSpring('smooth', 900, 7)
const snappy = getSequenceSpring('snappy', 250, 7) const snappy = getSequenceSpring('snappy', 250, 7)

View File

@ -25,7 +25,8 @@ import { ControlSection, InspectorCard, PanelTitle } from '@/app/components/comm
import { NumericField } from '@/app/components/controls' import { NumericField } from '@/app/components/controls'
import { import {
createSequenceStep, createSequenceStep,
findExpressionIndex, NEUTRAL_EXPRESSION_ID,
resolveSequenceExpression,
type AvatarSequence, type AvatarSequence,
type SequenceStep, type SequenceStep,
} from '@/features/animation/sequences' } from '@/features/animation/sequences'
@ -37,6 +38,7 @@ import {
import { type BodyNode } from '@/features/avatar/body' import { type BodyNode } from '@/features/avatar/body'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { type Expression } from '@/features/avatar/geometry' import { type Expression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type SurfaceConfig } from '@/features/avatar/surfaces'
export function SequenceWorkspace({ export function SequenceWorkspace({
editing, editing,
@ -268,9 +270,8 @@ export function SequenceWorkspace({
{editing.draft.steps.length ? ( {editing.draft.steps.length ? (
<div className="sequence-timeline"> <div className="sequence-timeline">
{editing.draft.steps.map((step, position) => { {editing.draft.steps.map((step, position) => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId) const resolved = resolveSequenceExpression(expressions, step.expressionId)
const preset = expressions[expressionIndex] if (!resolved.expression) return null
if (!preset) return null
const card = ( const card = (
<Button <Button
variant="outline" variant="outline"
@ -280,11 +281,15 @@ export function SequenceWorkspace({
onSelectedStepChange(step.id) onSelectedStepChange(step.id)
onPreviewStep(step) onPreviewStep(step)
}} }}
onDoubleClick={() => onEditExpression(expressionIndex, preset)} onDoubleClick={
resolved.neutral
? undefined
: () => onEditExpression(resolved.index, resolved.expression)
}
> >
<GripVertical className="sequence-grip" /> <GripVertical className="sequence-grip" />
<ExpressionPreview <ExpressionPreview
expression={preset} expression={resolved.expression}
surface={surface} surface={surface}
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={colors} colors={colors}
@ -292,7 +297,11 @@ export function SequenceWorkspace({
renderStyle={renderStyle} renderStyle={renderStyle}
id={`sequence-${editing.draft.id}-${step.id}`} id={`sequence-${editing.draft.id}-${step.id}`}
/> />
<span>{String(expressionIndex).padStart(2, '0')}</span> <span>
{resolved.neutral
? t('Apparence neutre')
: String(resolved.index).padStart(2, '0')}
</span>
<small>{position + 1}</small> <small>{position + 1}</small>
</Button> </Button>
) )
@ -318,16 +327,20 @@ export function SequenceWorkspace({
draggedStepId.current = null draggedStepId.current = null
}} }}
> >
<ContextMenu> {resolved.neutral ? (
<ContextMenuTrigger render={card} /> card
<ContextMenuContent> ) : (
<ContextMenuItem <ContextMenu>
onClick={() => onEditExpression(expressionIndex, preset)} <ContextMenuTrigger render={card} />
> <ContextMenuContent>
<Pencil /> {t('Modifier')} <ContextMenuItem
</ContextMenuItem> onClick={() => onEditExpression(resolved.index, resolved.expression)}
</ContextMenuContent> >
</ContextMenu> <Pencil /> {t('Modifier')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</motion.div> </motion.div>
) )
})} })}
@ -408,6 +421,28 @@ export function SequenceWorkspace({
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline." subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
/> />
<div className="expression-grid sequence-expression-library"> <div className="expression-grid sequence-expression-library">
<Button
className="expression-card"
variant="outline"
type="button"
onClick={() => {
const step = createSequenceStep(NEUTRAL_EXPRESSION_ID)
onChange({ ...editing.draft, steps: [...editing.draft.steps, step] })
onSelectedStepChange(step.id)
onPreviewStep(step)
}}
>
<ExpressionPreview
expression={defaultExpression}
surface={surface}
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
renderStyle={renderStyle}
id="sequence-library-neutral"
/>
<span>{t('Apparence neutre')}</span>
</Button>
{expressions.map((preset, index) => ( {expressions.map((preset, index) => (
<Button <Button
className="expression-card" className="expression-card"

View File

@ -1,4 +1,5 @@
import { import {
defaultExpression,
getStatePlaybackConfig, getStatePlaybackConfig,
initialExpressions, initialExpressions,
stateGroups, stateGroups,
@ -9,6 +10,7 @@ import type { Expression } from '../avatar/geometry'
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong' export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
export type SequenceTransition = 'spring' | 'smooth' | 'snappy' export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
export const NEUTRAL_EXPRESSION_ID = defaultExpression.id
export type SequenceStep = { export type SequenceStep = {
id: string id: string
@ -170,13 +172,16 @@ export const normalizeSequencesForExpressions = (
expressions: Expression[] expressions: Expression[]
) => { ) => {
const availableIds = new Set(expressions.map(expression => expression.id)) const availableIds = new Set(expressions.map(expression => expression.id))
const fallbackId = expressions[0]?.id ?? initialExpressions[0].id const fallbackId = expressions[0]?.id ?? NEUTRAL_EXPRESSION_ID
return sequences.map(sequence => ({ return sequences.map(sequence => ({
...sequence, ...sequence,
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map( steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
step => ({ step => ({
...step, ...step,
expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId, expressionId:
step.expressionId === NEUTRAL_EXPRESSION_ID || availableIds.has(step.expressionId)
? step.expressionId
: fallbackId,
}) })
), ),
})) }))
@ -185,6 +190,17 @@ export const normalizeSequencesForExpressions = (
export const findExpressionIndex = (expressions: Expression[], expressionId: string) => export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
expressions.findIndex(expression => expression.id === expressionId) expressions.findIndex(expression => expression.id === expressionId)
export const resolveSequenceExpression = (expressions: Expression[], expressionId: string) => {
if (expressionId === NEUTRAL_EXPRESSION_ID) {
return { expression: defaultExpression, index: null, neutral: true } as const
}
const index = findExpressionIndex(expressions, expressionId)
const expression = expressions[index]
return expression
? ({ expression, index, neutral: false } as const)
: ({ expression: undefined, index: null, neutral: false } as const)
}
export const readSequenceClock = () => performance.now() export const readSequenceClock = () => performance.now()
export const groupSequences = (sequences: AvatarSequence[]) => { export const groupSequences = (sequences: AvatarSequence[]) => {

View File

@ -1,5 +1,5 @@
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { createInitialSequences } from '@/features/animation/sequences' import { createInitialSequences, NEUTRAL_EXPRESSION_ID } from '@/features/animation/sequences'
import { import {
applyAvatarEyeDefaults, applyAvatarEyeDefaults,
cloneAvatarBehavior, cloneAvatarBehavior,
@ -7,6 +7,7 @@ import {
createUnkeyedExpressionCopy, createUnkeyedExpressionCopy,
defaultAvatarEyes, defaultAvatarEyes,
parseAvatarEyeDefaults, parseAvatarEyeDefaults,
parseAvatarLibrary,
parseAvatarRenderStyle, parseAvatarRenderStyle,
resolveAvatarBehavior, resolveAvatarBehavior,
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
@ -84,4 +85,30 @@ describe('avatar behavior library', () => {
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps) expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink) expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
}) })
it('preserves an owned neutral-only behavior library on reload', () => {
const avatar = createAvatar('Neutral only')
const sequence = createInitialSequences()[0]
avatar.behavior = {
expressions: [],
sequences: [
{
...sequence,
steps: [{ ...sequence.steps[0], expressionId: NEUTRAL_EXPRESSION_ID }],
},
],
}
const fallbackAvatar = createAvatar('Fallback')
const parsed = parseAvatarLibrary(
{ activeAvatarId: avatar.id, avatars: [avatar] },
{ activeAvatarId: fallbackAvatar.id, avatars: [fallbackAvatar] },
base
)
expect(parsed.avatars[0].behavior?.expressions).toEqual([])
expect(parsed.avatars[0].behavior?.sequences[0].steps[0].expressionId).toBe(
NEUTRAL_EXPRESSION_ID
)
})
}) })

View File

@ -1,8 +1,14 @@
import { type AvatarDefinition } from '@bible-strong/avatar-core'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json' import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
import { NEUTRAL_EXPRESSION_ID } from '../../animation/sequences'
import { createAvatarDefinition } from '../avatarDefinition' import { createAvatarDefinition } from '../avatarDefinition'
import { isAvatarDefinitionSource, studioAvatarFromDefinition } from '../importAvatarDefinition' import {
isAvatarDefinitionSource,
studioAvatarFromDefinition,
studioAvatarFromDefinitionSource,
} from '../importAvatarDefinition'
describe('studioAvatarFromDefinition', () => { describe('studioAvatarFromDefinition', () => {
it('recognises a definition and rejects a studio project', () => { it('recognises a definition and rejects a studio project', () => {
@ -39,5 +45,101 @@ describe('studioAvatarFromDefinition', () => {
expect(result.value.expressionOrder).toEqual(strobi.expressionOrder) expect(result.value.expressionOrder).toEqual(strobi.expressionOrder)
expect(result.value.animationOrder).toEqual(strobi.animationOrder) expect(result.value.animationOrder).toEqual(strobi.animationOrder)
expect(result.value.expressions).toEqual(strobi.expressions) expect(result.value.expressions).toEqual(strobi.expressions)
expect(result.value.animations).toEqual(strobi.animations)
})
it('inverts customized neutral eyes instead of applying them twice', () => {
const customized = structuredClone(strobi) as AvatarDefinition
Object.values(customized.expressions).forEach(item => {
item.eyes.left.width += 10
item.eyes.right.width += 10
item.eyes.spacing += 6
item.eyes.left.y -= 4
item.eyes.right.y -= 4
})
const imported = studioAvatarFromDefinition(customized)
const result = createAvatarDefinition({
avatar: imported.avatar,
behavior: { expressions: imported.expressions, sequences: imported.sequences },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressions.neutral).toEqual(customized.expressions.neutral)
Object.keys(customized.expressions).forEach(key => {
expect(result.value.expressions[key].eyes.left.width).toBeCloseTo(
customized.expressions[key].eyes.left.width,
10
)
expect(result.value.expressions[key].eyes.spacing).toBeCloseTo(
customized.expressions[key].eyes.spacing,
10
)
expect(result.value.expressions[key].eyes.left.y).toBeCloseTo(
customized.expressions[key].eyes.left.y,
10
)
})
})
it('preserves animation steps targeting the neutral appearance', () => {
const customized = structuredClone(strobi) as AvatarDefinition
customized.animations.sleeping.steps[0].expression = 'neutral'
const imported = studioAvatarFromDefinition(customized)
const sleeping = imported.sequences.find(sequence => sequence.semanticKey === 'sleeping')
expect(sleeping?.steps[0].expressionId).toBe(NEUTRAL_EXPRESSION_ID)
const result = createAvatarDefinition({
avatar: imported.avatar,
behavior: { expressions: imported.expressions, sequences: imported.sequences },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.animations.sleeping).toEqual(customized.animations.sleeping)
})
it('creates unique Studio ids for repeated imports', () => {
const first = studioAvatarFromDefinition(strobi)
const second = studioAvatarFromDefinition(strobi)
expect(first.avatar.id).not.toBe(second.avatar.id)
expect(first.expressions[0].id).not.toBe(second.expressions[0].id)
expect(first.sequences[0].id).not.toBe(second.sequences[0].id)
})
it('rejects neutral properties that the Studio cannot represent', () => {
const customized = structuredClone(strobi) as AvatarDefinition
customized.expressions.neutral.head.x = 1
expect(() => studioAvatarFromDefinition(customized)).toThrow(
'The neutral expression must use zero head rotation'
)
})
it('rejects eye dimensions that the Studio would clamp during re-export', () => {
const customized = structuredClone(strobi) as AvatarDefinition
customized.expressions['upward-side-glance'].eyes.left.width = 9
expect(() => studioAvatarFromDefinition(customized)).toThrow(
'uses an eye dimension below the Studio minimum of 10'
)
})
it('uses bounded parsing for untrusted definition text', () => {
expect(() => studioAvatarFromDefinitionSource('{')).toThrow('Unterminated object')
expect(() =>
studioAvatarFromDefinitionSource(JSON.stringify({ ...strobi, schemaVersion: 2 }))
).toThrow()
const duplicateKey = JSON.stringify(strobi).replace(
'"schemaVersion":1',
'"schemaVersion":1,"schemaVersion":1'
)
expect(() => studioAvatarFromDefinitionSource(duplicateKey)).toThrow('Duplicate object member')
expect(() => studioAvatarFromDefinitionSource(' '.repeat(262_145))).toThrow(
'JSON input exceeds'
)
}) })
}) })

View File

@ -13,6 +13,7 @@ import {
} from '@bible-strong/avatar-core' } from '@bible-strong/avatar-core'
import type { SurfaceType } from '@bible-strong/avatar-core' import type { SurfaceType } from '@bible-strong/avatar-core'
import { NEUTRAL_EXPRESSION_ID } from '../animation/sequences'
import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars' import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
import { defaultExpression } from './presets' import { defaultExpression } from './presets'
import type { Expression } from './geometry' import type { Expression } from './geometry'
@ -124,7 +125,10 @@ export const createAvatarDefinition = ({
) )
if (error) errors.push(error) if (error) errors.push(error)
sequence.steps.forEach((step, stepIndex) => { sequence.steps.forEach((step, stepIndex) => {
if (!expressionKeyById.has(step.expressionId)) { if (
step.expressionId !== NEUTRAL_EXPRESSION_ID &&
!expressionKeyById.has(step.expressionId)
) {
errors.push({ errors.push({
path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`, path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
code: 'unresolved_expression_reference', code: 'unresolved_expression_reference',
@ -149,7 +153,10 @@ export const createAvatarDefinition = ({
{ {
playbackMode: sequence.playbackMode, playbackMode: sequence.playbackMode,
steps: sequence.steps.map(step => ({ steps: sequence.steps.map(step => ({
expression: expressionKeyById.get(step.expressionId)!, expression:
step.expressionId === NEUTRAL_EXPRESSION_ID
? 'neutral'
: expressionKeyById.get(step.expressionId)!,
holdMs: step.holdMs, holdMs: step.holdMs,
transitionMs: step.transitionMs, transitionMs: step.transitionMs,
transition: step.transition, transition: step.transition,

View File

@ -229,8 +229,8 @@ const parseAvatarBehavior = (
): AvatarBehaviorLibrary | undefined => { ): AvatarBehaviorLibrary | undefined => {
if (!value || typeof value !== 'object') return undefined if (!value || typeof value !== 'object') return undefined
const candidate = value as Partial<AvatarBehaviorLibrary> const candidate = value as Partial<AvatarBehaviorLibrary>
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined if (!Array.isArray(candidate.expressions)) return undefined
const expressions = parseExpressions(candidate.expressions) const expressions = candidate.expressions.length ? parseExpressions(candidate.expressions) : []
return restoreLegacyBehaviorSemanticKeys( return restoreLegacyBehaviorSemanticKeys(
{ {
expressions, expressions,

View File

@ -1,12 +1,17 @@
import { import {
parseAvatarDefinition,
validateAvatarDefinition, validateAvatarDefinition,
type AvatarAnimationDefinition, type AvatarAnimationDefinition,
type AvatarDefinition, type AvatarDefinition,
type AvatarExpressionDefinition, type AvatarExpressionDefinition,
} from '@bible-strong/avatar-core' } from '@bible-strong/avatar-core'
import type { AvatarSequence, SequenceStep } from '../animation/sequences' import {
import type { StudioAvatar } from './avatars' NEUTRAL_EXPRESSION_ID,
type AvatarSequence,
type SequenceStep,
} from '../animation/sequences'
import { defaultAvatarEyes, type StudioAvatar } from './avatars'
import type { Expression } from './geometry' import type { Expression } from './geometry'
/** /**
@ -33,27 +38,82 @@ const slugify = (value: string) =>
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'avatar' .replace(/^-+|-+$/g, '') || 'avatar'
const createImportId = (slug: string) =>
`${slug}-${
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
}`
const relativeEyeValue = (value: number, neutral: number, fallback: number) =>
value - neutral + fallback
const toExpression = ( const toExpression = (
slug: string, importId: string,
semanticKey: string, semanticKey: string,
expression: AvatarExpressionDefinition expression: AvatarExpressionDefinition,
neutral: AvatarExpressionDefinition
): Expression => ({ ): Expression => ({
id: `expression-${slug}-${semanticKey}`, id: `expression-${importId}-${semanticKey}`,
semanticKey, semanticKey,
headX: expression.head.x, headX: expression.head.x,
headY: expression.head.y, headY: expression.head.y,
headZ: expression.head.z, headZ: expression.head.z,
widthLeft: expression.eyes.left.width, widthLeft: relativeEyeValue(
widthRight: expression.eyes.right.width, expression.eyes.left.width,
heightLeft: expression.eyes.left.height, neutral.eyes.left.width,
heightRight: expression.eyes.right.height, defaultAvatarEyes.widthLeft
spacing: expression.eyes.spacing, ),
positionXLeft: expression.eyes.left.x, widthRight: relativeEyeValue(
positionXRight: expression.eyes.right.x, expression.eyes.right.width,
positionYLeft: expression.eyes.left.y, neutral.eyes.right.width,
positionYRight: expression.eyes.right.y, defaultAvatarEyes.widthRight
leftAngle: expression.eyes.left.angle, ),
rightAngle: expression.eyes.right.angle, heightLeft: relativeEyeValue(
expression.eyes.left.height,
neutral.eyes.left.height,
defaultAvatarEyes.heightLeft
),
heightRight: relativeEyeValue(
expression.eyes.right.height,
neutral.eyes.right.height,
defaultAvatarEyes.heightRight
),
spacing: relativeEyeValue(
expression.eyes.spacing,
neutral.eyes.spacing,
defaultAvatarEyes.spacing
),
positionXLeft: relativeEyeValue(
expression.eyes.left.x,
neutral.eyes.left.x,
defaultAvatarEyes.positionXLeft
),
positionXRight: relativeEyeValue(
expression.eyes.right.x,
neutral.eyes.right.x,
defaultAvatarEyes.positionXRight
),
positionYLeft: relativeEyeValue(
expression.eyes.left.y,
neutral.eyes.left.y,
defaultAvatarEyes.positionYLeft
),
positionYRight: relativeEyeValue(
expression.eyes.right.y,
neutral.eyes.right.y,
defaultAvatarEyes.positionYRight
),
leftAngle: relativeEyeValue(
expression.eyes.left.angle,
neutral.eyes.left.angle,
defaultAvatarEyes.leftAngle
),
rightAngle: relativeEyeValue(
expression.eyes.right.angle,
neutral.eyes.right.angle,
defaultAvatarEyes.rightAngle
),
perspective: expression.perspective, perspective: expression.perspective,
eyeMotion: expression.motion.eyes, eyeMotion: expression.motion.eyes,
bodyMotion: expression.motion.body, bodyMotion: expression.motion.body,
@ -62,19 +122,19 @@ const toExpression = (
}) })
const toSequence = ( const toSequence = (
slug: string, importId: string,
semanticKey: string, semanticKey: string,
animation: AvatarAnimationDefinition, animation: AvatarAnimationDefinition,
expressionIdByKey: Map<string, string> expressionIdByKey: Map<string, string>
): AvatarSequence => { ): AvatarSequence => {
const steps: SequenceStep[] = [] const steps: SequenceStep[] = []
animation.steps.forEach((step, index) => { animation.steps.forEach((step, index) => {
const expressionId = expressionIdByKey.get(step.expression) const expressionId =
// A step pointing at an expression the definition never declared cannot be step.expression === 'neutral' ? NEUTRAL_EXPRESSION_ID : expressionIdByKey.get(step.expression)
// represented; dropping it keeps the sequence playable. // Validation guarantees that non-neutral references resolve.
if (!expressionId) return if (!expressionId) throw new Error(`Unknown expression '${step.expression}'`)
steps.push({ steps.push({
id: `step-${slug}-${semanticKey}-${index}`, id: `step-${importId}-${semanticKey}-${index}`,
expressionId, expressionId,
holdMs: step.holdMs, holdMs: step.holdMs,
transitionMs: step.transitionMs, transitionMs: step.transitionMs,
@ -82,7 +142,7 @@ const toSequence = (
}) })
}) })
return { return {
id: `sequence-${slug}-${semanticKey}`, id: `sequence-${importId}-${semanticKey}`,
semanticKey, semanticKey,
name: animation.metadata?.label ?? semanticKey, name: animation.metadata?.label ?? semanticKey,
group: animation.metadata?.group ?? 'Importé', group: animation.metadata?.group ?? 'Importé',
@ -100,39 +160,78 @@ export type ImportedAvatarDefinition = {
sequences: AvatarSequence[] sequences: AvatarSequence[]
} }
const assertRepresentableNeutral = (neutral: AvatarExpressionDefinition) => {
const canonical =
neutral.head.x === 0 &&
neutral.head.y === 0 &&
neutral.head.z === 0 &&
neutral.perspective === 1 &&
neutral.motion.eyes === 'none' &&
neutral.motion.body === 'none' &&
neutral.colors === undefined
if (!canonical) {
throw new Error(
'The neutral expression must use zero head rotation, perspective 1, no ambient motion and no color overrides.'
)
}
}
const assertRepresentableEyeDimensions = (definition: AvatarDefinition) => {
Object.entries(definition.expressions).forEach(([key, expression]) => {
const dimensions = [
expression.eyes.left.width,
expression.eyes.right.width,
expression.eyes.left.height,
expression.eyes.right.height,
]
if (dimensions.some(value => value < 10)) {
throw new Error(`Expression '${key}' uses an eye dimension below the Studio minimum of 10.`)
}
})
}
const formatValidationError = (errors: readonly { path: string; message: string }[]) => {
const first = errors[0]
return first ? `${first.path}: ${first.message}` : 'Invalid avatar definition'
}
/** Throws when the file is not a valid v1 avatar definition. */ /** Throws when the file is not a valid v1 avatar definition. */
export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => { export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
const result = validateAvatarDefinition(value) const result = validateAvatarDefinition(value)
if (!result.ok) { if (!result.ok) {
const first = result.errors[0] throw new Error(formatValidationError(result.errors))
throw new Error(first ? `${first.path}: ${first.message}` : 'Invalid avatar definition')
} }
const definition: AvatarDefinition = result.value const definition: AvatarDefinition = result.value
const slug = slugify(definition.name ?? 'avatar') const slug = slugify(definition.name ?? 'avatar')
const importId = createImportId(slug)
const neutral = definition.expressions.neutral
assertRepresentableNeutral(neutral)
assertRepresentableEyeDimensions(definition)
// `neutral` is reserved: the studio does not keep it as an editable expression, // `neutral` is reserved: the studio does not keep it as an editable expression,
// it lives on the avatar as eye defaults and is re-emitted on export. // it lives on the avatar as eye defaults and is re-emitted on export.
const keys = definition.expressionOrder.filter( const keys = definition.expressionOrder.filter(
key => key !== 'neutral' && definition.expressions[key] key => key !== 'neutral' && definition.expressions[key]
) )
const expressions = keys.map(key => toExpression(slug, key, definition.expressions[key]!)) const expressions = keys.map(key =>
toExpression(importId, key, definition.expressions[key]!, neutral)
)
const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id])) const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id]))
const sequences = definition.animationOrder const sequences = definition.animationOrder
.filter(key => definition.animations[key]) .filter(key => definition.animations[key])
.map(key => toSequence(slug, key, definition.animations[key]!, expressionIdByKey)) .map(key => toSequence(importId, key, definition.animations[key]!, expressionIdByKey))
// The studio stores one flat set of eye defaults per avatar; `neutral` is the // The studio stores one flat set of eye defaults per avatar; `neutral` is the
// resting pose, so it is the expression those defaults come from. // resting pose, so it is the expression those defaults come from.
const neutral = definition.expressions.neutral
const avatar: StudioAvatar = { const avatar: StudioAvatar = {
id: `avatar-${slug}-${definition.body.nodes.length}-${expressions.length}`, id: `avatar-${importId}`,
name: definition.name ?? slug, name: definition.name ?? slug,
body: { body: {
primary: definition.body.primary, primary: definition.body.primary,
// Definition nodes are anonymous; the studio addresses them by id in the editor. // Definition nodes are anonymous; the studio addresses them by id in the editor.
nodes: definition.body.nodes.map((node, index) => ({ nodes: definition.body.nodes.map((node, index) => ({
id: `shape-${slug}-${index}`, id: `shape-${importId}-${index}`,
name: `${node.surface.type} ${index + 1}`, name: `${node.surface.type} ${index + 1}`,
surface: node.surface, surface: node.surface,
position: node.position, position: node.position,
@ -159,3 +258,10 @@ export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefini
return { avatar, expressions, sequences } return { avatar, expressions, sequences }
} }
/** Parses untrusted `.avatar.json` text with the core size, depth and duplicate-key limits. */
export const studioAvatarFromDefinitionSource = (source: string): ImportedAvatarDefinition => {
const result = parseAvatarDefinition(source)
if (!result.ok) throw new Error(formatValidationError(result.errors))
return studioAvatarFromDefinition(result.value)
}

View File

@ -1,9 +1,10 @@
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars' import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core' import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
import type { Expression } from '../avatar/geometry' import type { Expression } from '../avatar/geometry'
import { defaultExpression } from '../avatar/presets'
import { translateStudioText, type StudioLanguage } from '../../i18n' import { translateStudioText, type StudioLanguage } from '../../i18n'
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime' import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
import type { AvatarSequence } from '../animation/sequences' import { NEUTRAL_EXPRESSION_ID, type AvatarSequence } from '../animation/sequences'
import { standaloneEngineSource } from './standaloneEngine.generated' import { standaloneEngineSource } from './standaloneEngine.generated'
import { createStoredZip } from './storedZip' import { createStoredZip } from './storedZip'
@ -60,7 +61,10 @@ export const createAvatarExportPayload = (
) )
const exportedExpressions = Object.fromEntries( const exportedExpressions = Object.fromEntries(
[...referencedIds].flatMap(expressionId => { [...referencedIds].flatMap(expressionId => {
const expression = expressionById.get(expressionId) const expression =
expressionId === NEUTRAL_EXPRESSION_ID
? defaultExpression
: expressionById.get(expressionId)
if (!expression) return [] if (!expression) return []
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults( const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
expression, expression,

View File

@ -66,7 +66,7 @@ import {
type SnapshotFormat, type SnapshotFormat,
} from '@/app/studio-utils' } from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { groupSequences, resolveSequenceExpression } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { import {
ExpressionCard, ExpressionCard,
@ -679,9 +679,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
setSequenceEditing(current => (current ? { ...current, draft } : current)) setSequenceEditing(current => (current ? { ...current, draft } : current))
} }
onPreviewStep={step => { onPreviewStep={step => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId) const resolved = resolveSequenceExpression(expressions, step.expressionId)
const preset = expressions[expressionIndex] if (resolved.expression) {
if (preset) transitionToExpression(preset, expressionIndex, step) transitionToExpression(resolved.expression, resolved.index, step)
}
}} }}
onEditExpression={openExpressionEditor} onEditExpression={openExpressionEditor}
onPlay={() => launchSequence(sequenceEditing.draft, false, false)} onPlay={() => launchSequence(sequenceEditing.draft, false, false)}
@ -1065,17 +1066,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
<Copy /> <Copy />
{t('Dupliquer')} {t('Dupliquer')}
</Button> </Button>
<Button
variant="outline"
disabled={!runtimeDefinitionResult.ok}
// downloadAvatarRuntimeDefinition is a no-op on an invalid
// definition, so surface why the button is dead.
title={runtimeExportErrors[0] ?? undefined}
onClick={downloadAvatarRuntimeDefinition}
>
<Download />
{t('Exporter')}
</Button>
</div> </div>
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button> <Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
</footer> </footer>
@ -1225,7 +1215,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
{group.sequences.map(sequence => { {group.sequences.map(sequence => {
const firstStep = sequence.steps[0] const firstStep = sequence.steps[0]
const firstExpression = firstStep const firstExpression = firstStep
? expressionById.get(firstStep.expressionId) ? resolveSequenceExpression(expressions, firstStep.expressionId)
.expression
: undefined : undefined
const card = ( const card = (
<Button <Button
@ -1705,7 +1696,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
{sequences.map(animation => { {sequences.map(animation => {
const firstStep = animation.steps[0] const firstStep = animation.steps[0]
const firstExpression = firstStep const firstExpression = firstStep
? expressionById.get(firstStep.expressionId) ? resolveSequenceExpression(expressions, firstStep.expressionId)
.expression
: undefined : undefined
return ( return (
<Button <Button
@ -2017,20 +2009,21 @@ export function StudioInspector({ controller }: { controller: StudioController }
<div className="state-playback-bar"> <div className="state-playback-bar">
<div className="state-playback-timeline"> <div className="state-playback-timeline">
{activeSequence.steps.map((step, position) => { {activeSequence.steps.map((step, position) => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId) const resolved = resolveSequenceExpression(expressions, step.expressionId)
const preset = expressions[expressionIndex] if (!resolved.expression) return null
if (!preset) return null
return ( return (
<Button <Button
className="state-playback-step" className="state-playback-step"
variant="outline" variant="outline"
type="button" type="button"
key={step.id} key={step.id}
aria-pressed={activeExpression === expressionIndex} aria-pressed={activeExpression === resolved.index}
onClick={() => transitionToExpression(preset, expressionIndex, step)} onClick={() =>
transitionToExpression(resolved.expression, resolved.index, step)
}
> >
<ExpressionPreview <ExpressionPreview
expression={preset} expression={resolved.expression}
surface={surface} surface={surface}
bodyNodes={bodyNodes} bodyNodes={bodyNodes}
colors={activeAvatar.colors} colors={activeAvatar.colors}

View File

@ -36,8 +36,10 @@ import {
duplicateSequence, duplicateSequence,
findExpressionIndex, findExpressionIndex,
getSequenceSpring, getSequenceSpring,
NEUTRAL_EXPRESSION_ID,
readSequenceClock, readSequenceClock,
remapSequencesAfterExpressionDelete, remapSequencesAfterExpressionDelete,
resolveSequenceExpression,
type AvatarSequence, type AvatarSequence,
type SequenceStep, type SequenceStep,
} from '@/features/animation/sequences' } from '@/features/animation/sequences'
@ -66,7 +68,7 @@ import {
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
import { import {
isAvatarDefinitionSource, isAvatarDefinitionSource,
studioAvatarFromDefinition, studioAvatarFromDefinitionSource,
} from '@/features/avatar/importAvatarDefinition' } from '@/features/avatar/importAvatarDefinition'
import { import {
bodyPrimitiveTypes, bodyPrimitiveTypes,
@ -1104,16 +1106,15 @@ export function useStudioController() {
playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null } playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null }
const step = sequence.steps[playbackTimeline.current.position] const step = sequence.steps[playbackTimeline.current.position]
const availableExpressions = expressionsRef.current const availableExpressions = expressionsRef.current
const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId) const resolved = resolveSequenceExpression(availableExpressions, step.expressionId)
const preset = availableExpressions[expressionIndex]
const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs
setPlaybackVisual(current => ({ setPlaybackVisual(current => ({
position: playbackTimeline.current.position, position: playbackTimeline.current.position,
run: current.run + 1, run: current.run + 1,
durationMs, durationMs,
})) }))
if (preset) { if (resolved.expression) {
transitionToExpression(preset, expressionIndex, step) transitionToExpression(resolved.expression, resolved.index, step)
} }
scheduleAdvance(durationMs) scheduleAdvance(durationMs)
} }
@ -1402,15 +1403,18 @@ export function useStudioController() {
steps: sequence.steps.map(step => ({ ...step })), steps: sequence.steps.map(step => ({ ...step })),
blink: { ...sequence.blink }, blink: { ...sequence.blink },
} }
: createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id) : createSequence(
expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id ?? NEUTRAL_EXPRESSION_ID
)
setSequenceEditing({ sourceId: sequence?.id ?? null, draft }) setSequenceEditing({ sourceId: sequence?.id ?? null, draft })
setSelectedSequenceStepId(draft.steps[0]?.id ?? null) setSelectedSequenceStepId(draft.steps[0]?.id ?? null)
const firstStep = draft.steps[0] const firstStep = draft.steps[0]
const expressionIndex = firstStep const resolved = firstStep
? findExpressionIndex(expressions, firstStep.expressionId) ? resolveSequenceExpression(expressions, firstStep.expressionId)
: -1 : null
const preset = expressions[expressionIndex] if (resolved?.expression) {
if (preset) transitionToExpression(preset, expressionIndex, firstStep) transitionToExpression(resolved.expression, resolved.index, firstStep)
}
} }
const cancelSequenceEditing = () => { const cancelSequenceEditing = () => {
@ -1824,9 +1828,8 @@ export function useStudioController() {
.then(source => { .then(source => {
// One picker, two formats: a `.avatar.json` definition adds a single avatar // One picker, two formats: a `.avatar.json` definition adds a single avatar
// to the current library, a studio project replaces the whole document. // to the current library, a studio project replaces the whole document.
const parsed: unknown = JSON.parse(source) try {
if (isAvatarDefinitionSource(parsed)) { const { avatar } = studioAvatarFromDefinitionSource(source)
const { avatar } = studioAvatarFromDefinition(parsed)
const document = currentStudioDocument() const document = currentStudioDocument()
setPendingProjectImport({ setPendingProjectImport({
document: { document: {
@ -1843,13 +1846,19 @@ export function useStudioController() {
kind: 'avatar', kind: 'avatar',
}) })
return return
} catch (avatarError) {
const parsed: unknown = JSON.parse(source)
if (isAvatarDefinitionSource(parsed)) throw avatarError
} }
const imported = parseImportedStudioDocument(source, currentStudioDocument()) const imported = parseImportedStudioDocument(source, currentStudioDocument())
setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' }) setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
}) })
.catch(() => { .catch(error => {
const summary = t(
'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.'
)
setProjectImportError( setProjectImportError(
t('Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.') error instanceof Error && error.message ? `${summary} ${error.message}` : summary
) )
}) })
} }

View File

@ -76,6 +76,7 @@ const english: Record<string, string> = {
'Use lowercase letters, numbers, and hyphens, for example happy-smile.', 'Use lowercase letters, numbers, and hyphens, for example happy-smile.',
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral est réservé à l’apparence neutre de l’avatar.':
'neutral is reserved for the avatar neutral appearance.', 'neutral is reserved for the avatar neutral appearance.',
'Apparence neutre': 'Neutral appearance',
'Cette clé est déjà utilisée dans cette bibliothèque.': 'Cette clé est déjà utilisée dans cette bibliothèque.':
'This key is already used in this library.', 'This key is already used in this library.',
'L’avatar à gauche affiche cette expression en direct.': 'L’avatar à gauche affiche cette expression en direct.':

View File

@ -48,6 +48,7 @@ export const chinese: Record<string, string> = {
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.': 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
'请使用小写字母、数字和连字符,例如 happy-smile。', '请使用小写字母、数字和连字符,例如 happy-smile。',
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。', 'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
'Apparence neutre': '中性外观',
'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。', 'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。', 'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
Corps: '身体', Corps: '身体',