Merge pull request #6 from smontlouis/feat/import-avatar-definition
fix(studio): preserve avatar definitions during import
This commit is contained in:
commit
79fe9ba06e
@ -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