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:
parent
d6fcf63fe6
commit
7f414b82de
@ -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)
|
||||
|
||||
@ -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
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={card} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onEditExpression(expressionIndex, preset)}
|
||||
>
|
||||
<Pencil /> {t('Modifier')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{resolved.neutral ? (
|
||||
card
|
||||
) : (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={card} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
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"
|
||||
|
||||
@ -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[]) => {
|
||||
|
||||
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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.':
|
||||
|
||||
@ -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: '身体',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user