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,
duplicateSequence,
getSequenceSpring,
NEUTRAL_EXPRESSION_ID,
normalizeSequencesForExpressions,
parseSequences,
remapSequencesAfterExpressionDelete,
@ -88,6 +89,21 @@ describe('editable avatar sequences', () => {
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', () => {
const smooth = getSequenceSpring('smooth', 900, 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 {
createSequenceStep,
findExpressionIndex,
NEUTRAL_EXPRESSION_ID,
resolveSequenceExpression,
type AvatarSequence,
type SequenceStep,
} from '@/features/animation/sequences'
@ -37,6 +38,7 @@ import {
import { type BodyNode } from '@/features/avatar/body'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { type Expression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
export function SequenceWorkspace({
editing,
@ -268,9 +270,8 @@ export function SequenceWorkspace({
{editing.draft.steps.length ? (
<div className="sequence-timeline">
{editing.draft.steps.map((step, position) => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
const preset = expressions[expressionIndex]
if (!preset) return null
const resolved = resolveSequenceExpression(expressions, step.expressionId)
if (!resolved.expression) return null
const card = (
<Button
variant="outline"
@ -280,11 +281,15 @@ export function SequenceWorkspace({
onSelectedStepChange(step.id)
onPreviewStep(step)
}}
onDoubleClick={() => onEditExpression(expressionIndex, preset)}
onDoubleClick={
resolved.neutral
? undefined
: () => onEditExpression(resolved.index, resolved.expression)
}
>
<GripVertical className="sequence-grip" />
<ExpressionPreview
expression={preset}
expression={resolved.expression}
surface={surface}
bodyNodes={bodyNodes}
colors={colors}
@ -292,7 +297,11 @@ export function SequenceWorkspace({
renderStyle={renderStyle}
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>
</Button>
)
@ -318,16 +327,20 @@ export function SequenceWorkspace({
draggedStepId.current = null
}}
>
{resolved.neutral ? (
card
) : (
<ContextMenu>
<ContextMenuTrigger render={card} />
<ContextMenuContent>
<ContextMenuItem
onClick={() => onEditExpression(expressionIndex, preset)}
onClick={() => onEditExpression(resolved.index, resolved.expression)}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</motion.div>
)
})}
@ -408,6 +421,28 @@ export function SequenceWorkspace({
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
/>
<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) => (
<Button
className="expression-card"

View File

@ -1,4 +1,5 @@
import {
defaultExpression,
getStatePlaybackConfig,
initialExpressions,
stateGroups,
@ -9,6 +10,7 @@ import type { Expression } from '../avatar/geometry'
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
export const NEUTRAL_EXPRESSION_ID = defaultExpression.id
export type SequenceStep = {
id: string
@ -170,13 +172,16 @@ export const normalizeSequencesForExpressions = (
expressions: Expression[]
) => {
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 => ({
...sequence,
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
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) =>
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 groupSequences = (sequences: AvatarSequence[]) => {

View File

@ -1,5 +1,5 @@
import { defaultExpression } from '@/features/avatar/presets'
import { createInitialSequences } from '@/features/animation/sequences'
import { createInitialSequences, NEUTRAL_EXPRESSION_ID } from '@/features/animation/sequences'
import {
applyAvatarEyeDefaults,
cloneAvatarBehavior,
@ -7,6 +7,7 @@ import {
createUnkeyedExpressionCopy,
defaultAvatarEyes,
parseAvatarEyeDefaults,
parseAvatarLibrary,
parseAvatarRenderStyle,
resolveAvatarBehavior,
} 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].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 strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
import { NEUTRAL_EXPRESSION_ID } from '../../animation/sequences'
import { createAvatarDefinition } from '../avatarDefinition'
import { isAvatarDefinitionSource, studioAvatarFromDefinition } from '../importAvatarDefinition'
import {
isAvatarDefinitionSource,
studioAvatarFromDefinition,
studioAvatarFromDefinitionSource,
} from '../importAvatarDefinition'
describe('studioAvatarFromDefinition', () => {
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.animationOrder).toEqual(strobi.animationOrder)
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'
import type { SurfaceType } from '@bible-strong/avatar-core'
import { NEUTRAL_EXPRESSION_ID } from '../animation/sequences'
import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
import { defaultExpression } from './presets'
import type { Expression } from './geometry'
@ -124,7 +125,10 @@ export const createAvatarDefinition = ({
)
if (error) errors.push(error)
sequence.steps.forEach((step, stepIndex) => {
if (!expressionKeyById.has(step.expressionId)) {
if (
step.expressionId !== NEUTRAL_EXPRESSION_ID &&
!expressionKeyById.has(step.expressionId)
) {
errors.push({
path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
code: 'unresolved_expression_reference',
@ -149,7 +153,10 @@ export const createAvatarDefinition = ({
{
playbackMode: sequence.playbackMode,
steps: sequence.steps.map(step => ({
expression: expressionKeyById.get(step.expressionId)!,
expression:
step.expressionId === NEUTRAL_EXPRESSION_ID
? 'neutral'
: expressionKeyById.get(step.expressionId)!,
holdMs: step.holdMs,
transitionMs: step.transitionMs,
transition: step.transition,

View File

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

View File

@ -1,12 +1,17 @@
import {
parseAvatarDefinition,
validateAvatarDefinition,
type AvatarAnimationDefinition,
type AvatarDefinition,
type AvatarExpressionDefinition,
} from '@bible-strong/avatar-core'
import type { AvatarSequence, SequenceStep } from '../animation/sequences'
import type { StudioAvatar } from './avatars'
import {
NEUTRAL_EXPRESSION_ID,
type AvatarSequence,
type SequenceStep,
} from '../animation/sequences'
import { defaultAvatarEyes, type StudioAvatar } from './avatars'
import type { Expression } from './geometry'
/**
@ -33,27 +38,82 @@ const slugify = (value: string) =>
.replace(/[^a-z0-9]+/g, '-')
.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 = (
slug: string,
importId: string,
semanticKey: string,
expression: AvatarExpressionDefinition
expression: AvatarExpressionDefinition,
neutral: AvatarExpressionDefinition
): Expression => ({
id: `expression-${slug}-${semanticKey}`,
id: `expression-${importId}-${semanticKey}`,
semanticKey,
headX: expression.head.x,
headY: expression.head.y,
headZ: expression.head.z,
widthLeft: expression.eyes.left.width,
widthRight: expression.eyes.right.width,
heightLeft: expression.eyes.left.height,
heightRight: expression.eyes.right.height,
spacing: expression.eyes.spacing,
positionXLeft: expression.eyes.left.x,
positionXRight: expression.eyes.right.x,
positionYLeft: expression.eyes.left.y,
positionYRight: expression.eyes.right.y,
leftAngle: expression.eyes.left.angle,
rightAngle: expression.eyes.right.angle,
widthLeft: relativeEyeValue(
expression.eyes.left.width,
neutral.eyes.left.width,
defaultAvatarEyes.widthLeft
),
widthRight: relativeEyeValue(
expression.eyes.right.width,
neutral.eyes.right.width,
defaultAvatarEyes.widthRight
),
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,
eyeMotion: expression.motion.eyes,
bodyMotion: expression.motion.body,
@ -62,19 +122,19 @@ const toExpression = (
})
const toSequence = (
slug: string,
importId: string,
semanticKey: string,
animation: AvatarAnimationDefinition,
expressionIdByKey: Map<string, string>
): AvatarSequence => {
const steps: SequenceStep[] = []
animation.steps.forEach((step, index) => {
const expressionId = expressionIdByKey.get(step.expression)
// A step pointing at an expression the definition never declared cannot be
// represented; dropping it keeps the sequence playable.
if (!expressionId) return
const expressionId =
step.expression === 'neutral' ? NEUTRAL_EXPRESSION_ID : expressionIdByKey.get(step.expression)
// Validation guarantees that non-neutral references resolve.
if (!expressionId) throw new Error(`Unknown expression '${step.expression}'`)
steps.push({
id: `step-${slug}-${semanticKey}-${index}`,
id: `step-${importId}-${semanticKey}-${index}`,
expressionId,
holdMs: step.holdMs,
transitionMs: step.transitionMs,
@ -82,7 +142,7 @@ const toSequence = (
})
})
return {
id: `sequence-${slug}-${semanticKey}`,
id: `sequence-${importId}-${semanticKey}`,
semanticKey,
name: animation.metadata?.label ?? semanticKey,
group: animation.metadata?.group ?? 'Importé',
@ -100,39 +160,78 @@ export type ImportedAvatarDefinition = {
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. */
export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
const result = validateAvatarDefinition(value)
if (!result.ok) {
const first = result.errors[0]
throw new Error(first ? `${first.path}: ${first.message}` : 'Invalid avatar definition')
throw new Error(formatValidationError(result.errors))
}
const definition: AvatarDefinition = result.value
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,
// it lives on the avatar as eye defaults and is re-emitted on export.
const keys = definition.expressionOrder.filter(
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 sequences = definition.animationOrder
.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
// resting pose, so it is the expression those defaults come from.
const neutral = definition.expressions.neutral
const avatar: StudioAvatar = {
id: `avatar-${slug}-${definition.body.nodes.length}-${expressions.length}`,
id: `avatar-${importId}`,
name: definition.name ?? slug,
body: {
primary: definition.body.primary,
// Definition nodes are anonymous; the studio addresses them by id in the editor.
nodes: definition.body.nodes.map((node, index) => ({
id: `shape-${slug}-${index}`,
id: `shape-${importId}-${index}`,
name: `${node.surface.type} ${index + 1}`,
surface: node.surface,
position: node.position,
@ -159,3 +258,10 @@ export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefini
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 { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
import type { Expression } from '../avatar/geometry'
import { defaultExpression } from '../avatar/presets'
import { translateStudioText, type StudioLanguage } from '../../i18n'
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 { createStoredZip } from './storedZip'
@ -60,7 +61,10 @@ export const createAvatarExportPayload = (
)
const exportedExpressions = Object.fromEntries(
[...referencedIds].flatMap(expressionId => {
const expression = expressionById.get(expressionId)
const expression =
expressionId === NEUTRAL_EXPRESSION_ID
? defaultExpression
: expressionById.get(expressionId)
if (!expression) return []
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
expression,

View File

@ -66,7 +66,7 @@ import {
type SnapshotFormat,
} from '@/app/studio-utils'
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 {
ExpressionCard,
@ -679,9 +679,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
setSequenceEditing(current => (current ? { ...current, draft } : current))
}
onPreviewStep={step => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
const preset = expressions[expressionIndex]
if (preset) transitionToExpression(preset, expressionIndex, step)
const resolved = resolveSequenceExpression(expressions, step.expressionId)
if (resolved.expression) {
transitionToExpression(resolved.expression, resolved.index, step)
}
}}
onEditExpression={openExpressionEditor}
onPlay={() => launchSequence(sequenceEditing.draft, false, false)}
@ -1065,17 +1066,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
<Copy />
{t('Dupliquer')}
</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>
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
</footer>
@ -1225,7 +1215,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
{group.sequences.map(sequence => {
const firstStep = sequence.steps[0]
const firstExpression = firstStep
? expressionById.get(firstStep.expressionId)
? resolveSequenceExpression(expressions, firstStep.expressionId)
.expression
: undefined
const card = (
<Button
@ -1705,7 +1696,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
{sequences.map(animation => {
const firstStep = animation.steps[0]
const firstExpression = firstStep
? expressionById.get(firstStep.expressionId)
? resolveSequenceExpression(expressions, firstStep.expressionId)
.expression
: undefined
return (
<Button
@ -2017,20 +2009,21 @@ export function StudioInspector({ controller }: { controller: StudioController }
<div className="state-playback-bar">
<div className="state-playback-timeline">
{activeSequence.steps.map((step, position) => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
const preset = expressions[expressionIndex]
if (!preset) return null
const resolved = resolveSequenceExpression(expressions, step.expressionId)
if (!resolved.expression) return null
return (
<Button
className="state-playback-step"
variant="outline"
type="button"
key={step.id}
aria-pressed={activeExpression === expressionIndex}
onClick={() => transitionToExpression(preset, expressionIndex, step)}
aria-pressed={activeExpression === resolved.index}
onClick={() =>
transitionToExpression(resolved.expression, resolved.index, step)
}
>
<ExpressionPreview
expression={preset}
expression={resolved.expression}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}

View File

@ -36,8 +36,10 @@ import {
duplicateSequence,
findExpressionIndex,
getSequenceSpring,
NEUTRAL_EXPRESSION_ID,
readSequenceClock,
remapSequencesAfterExpressionDelete,
resolveSequenceExpression,
type AvatarSequence,
type SequenceStep,
} from '@/features/animation/sequences'
@ -66,7 +68,7 @@ import {
} from '@/features/avatar/avatars'
import {
isAvatarDefinitionSource,
studioAvatarFromDefinition,
studioAvatarFromDefinitionSource,
} from '@/features/avatar/importAvatarDefinition'
import {
bodyPrimitiveTypes,
@ -1104,16 +1106,15 @@ export function useStudioController() {
playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null }
const step = sequence.steps[playbackTimeline.current.position]
const availableExpressions = expressionsRef.current
const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId)
const preset = availableExpressions[expressionIndex]
const resolved = resolveSequenceExpression(availableExpressions, step.expressionId)
const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs
setPlaybackVisual(current => ({
position: playbackTimeline.current.position,
run: current.run + 1,
durationMs,
}))
if (preset) {
transitionToExpression(preset, expressionIndex, step)
if (resolved.expression) {
transitionToExpression(resolved.expression, resolved.index, step)
}
scheduleAdvance(durationMs)
}
@ -1402,15 +1403,18 @@ export function useStudioController() {
steps: sequence.steps.map(step => ({ ...step })),
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 })
setSelectedSequenceStepId(draft.steps[0]?.id ?? null)
const firstStep = draft.steps[0]
const expressionIndex = firstStep
? findExpressionIndex(expressions, firstStep.expressionId)
: -1
const preset = expressions[expressionIndex]
if (preset) transitionToExpression(preset, expressionIndex, firstStep)
const resolved = firstStep
? resolveSequenceExpression(expressions, firstStep.expressionId)
: null
if (resolved?.expression) {
transitionToExpression(resolved.expression, resolved.index, firstStep)
}
}
const cancelSequenceEditing = () => {
@ -1824,9 +1828,8 @@ export function useStudioController() {
.then(source => {
// One picker, two formats: a `.avatar.json` definition adds a single avatar
// to the current library, a studio project replaces the whole document.
const parsed: unknown = JSON.parse(source)
if (isAvatarDefinitionSource(parsed)) {
const { avatar } = studioAvatarFromDefinition(parsed)
try {
const { avatar } = studioAvatarFromDefinitionSource(source)
const document = currentStudioDocument()
setPendingProjectImport({
document: {
@ -1843,13 +1846,19 @@ export function useStudioController() {
kind: 'avatar',
})
return
} catch (avatarError) {
const parsed: unknown = JSON.parse(source)
if (isAvatarDefinitionSource(parsed)) throw avatarError
}
const imported = parseImportedStudioDocument(source, currentStudioDocument())
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(
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.',
'neutral est réservé à l’apparence neutre de l’avatar.':
'neutral is reserved for the avatar neutral appearance.',
'Apparence neutre': 'Neutral appearance',
'Cette clé est déjà utilisée dans cette bibliothèque.':
'This key is already used in this library.',
'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.':
'请使用小写字母、数字和连字符,例如 happy-smile。',
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
'Apparence neutre': '中性外观',
'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
Corps: '身体',