Merge pull request #6 from smontlouis/feat/import-avatar-definition

fix(studio): preserve avatar definitions during import
This commit is contained in:
Montlouis-Calixte Stéphane 2026-08-25 13:09:00 +02:00 committed by GitHub
commit 79fe9ba06e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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
}} }}
> >
{resolved.neutral ? (
card
) : (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger render={card} /> <ContextMenuTrigger render={card} />
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem <ContextMenuItem
onClick={() => onEditExpression(expressionIndex, preset)} onClick={() => onEditExpression(resolved.index, resolved.expression)}
> >
<Pencil /> {t('Modifier')} <Pencil /> {t('Modifier')}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </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: '身体',