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,
|
createInitialSequences,
|
||||||
duplicateSequence,
|
duplicateSequence,
|
||||||
getSequenceSpring,
|
getSequenceSpring,
|
||||||
|
NEUTRAL_EXPRESSION_ID,
|
||||||
normalizeSequencesForExpressions,
|
normalizeSequencesForExpressions,
|
||||||
parseSequences,
|
parseSequences,
|
||||||
remapSequencesAfterExpressionDelete,
|
remapSequencesAfterExpressionDelete,
|
||||||
@ -88,6 +89,21 @@ describe('editable avatar sequences', () => {
|
|||||||
expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
|
expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves the system neutral target during normalization', () => {
|
||||||
|
const sequence = createInitialSequences()[0]
|
||||||
|
const [normalized] = normalizeSequencesForExpressions(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...sequence,
|
||||||
|
steps: [{ ...sequence.steps[0], expressionId: NEUTRAL_EXPRESSION_ID }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(normalized.steps[0].expressionId).toBe(NEUTRAL_EXPRESSION_ID)
|
||||||
|
})
|
||||||
|
|
||||||
it('maps transition styles and durations to distinct spring dynamics', () => {
|
it('maps transition styles and durations to distinct spring dynamics', () => {
|
||||||
const smooth = getSequenceSpring('smooth', 900, 7)
|
const smooth = getSequenceSpring('smooth', 900, 7)
|
||||||
const snappy = getSequenceSpring('snappy', 250, 7)
|
const snappy = getSequenceSpring('snappy', 250, 7)
|
||||||
|
|||||||
@ -25,7 +25,8 @@ import { ControlSection, InspectorCard, PanelTitle } from '@/app/components/comm
|
|||||||
import { NumericField } from '@/app/components/controls'
|
import { NumericField } from '@/app/components/controls'
|
||||||
import {
|
import {
|
||||||
createSequenceStep,
|
createSequenceStep,
|
||||||
findExpressionIndex,
|
NEUTRAL_EXPRESSION_ID,
|
||||||
|
resolveSequenceExpression,
|
||||||
type AvatarSequence,
|
type AvatarSequence,
|
||||||
type SequenceStep,
|
type SequenceStep,
|
||||||
} from '@/features/animation/sequences'
|
} from '@/features/animation/sequences'
|
||||||
@ -37,6 +38,7 @@ import {
|
|||||||
import { type BodyNode } from '@/features/avatar/body'
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
import { type Expression } from '@/features/avatar/geometry'
|
import { type Expression } from '@/features/avatar/geometry'
|
||||||
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
export function SequenceWorkspace({
|
export function SequenceWorkspace({
|
||||||
editing,
|
editing,
|
||||||
@ -268,9 +270,8 @@ export function SequenceWorkspace({
|
|||||||
{editing.draft.steps.length ? (
|
{editing.draft.steps.length ? (
|
||||||
<div className="sequence-timeline">
|
<div className="sequence-timeline">
|
||||||
{editing.draft.steps.map((step, position) => {
|
{editing.draft.steps.map((step, position) => {
|
||||||
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
|
const resolved = resolveSequenceExpression(expressions, step.expressionId)
|
||||||
const preset = expressions[expressionIndex]
|
if (!resolved.expression) return null
|
||||||
if (!preset) return null
|
|
||||||
const card = (
|
const card = (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -280,11 +281,15 @@ export function SequenceWorkspace({
|
|||||||
onSelectedStepChange(step.id)
|
onSelectedStepChange(step.id)
|
||||||
onPreviewStep(step)
|
onPreviewStep(step)
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => onEditExpression(expressionIndex, preset)}
|
onDoubleClick={
|
||||||
|
resolved.neutral
|
||||||
|
? undefined
|
||||||
|
: () => onEditExpression(resolved.index, resolved.expression)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<GripVertical className="sequence-grip" />
|
<GripVertical className="sequence-grip" />
|
||||||
<ExpressionPreview
|
<ExpressionPreview
|
||||||
expression={preset}
|
expression={resolved.expression}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
bodyNodes={bodyNodes}
|
bodyNodes={bodyNodes}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
@ -292,7 +297,11 @@ export function SequenceWorkspace({
|
|||||||
renderStyle={renderStyle}
|
renderStyle={renderStyle}
|
||||||
id={`sequence-${editing.draft.id}-${step.id}`}
|
id={`sequence-${editing.draft.id}-${step.id}`}
|
||||||
/>
|
/>
|
||||||
<span>{String(expressionIndex).padStart(2, '0')}</span>
|
<span>
|
||||||
|
{resolved.neutral
|
||||||
|
? t('Apparence neutre')
|
||||||
|
: String(resolved.index).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
<small>{position + 1}</small>
|
<small>{position + 1}</small>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -318,16 +327,20 @@ export function SequenceWorkspace({
|
|||||||
draggedStepId.current = null
|
draggedStepId.current = null
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{resolved.neutral ? (
|
||||||
|
card
|
||||||
|
) : (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger render={card} />
|
<ContextMenuTrigger render={card} />
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => onEditExpression(expressionIndex, preset)}
|
onClick={() => onEditExpression(resolved.index, resolved.expression)}
|
||||||
>
|
>
|
||||||
<Pencil /> {t('Modifier')}
|
<Pencil /> {t('Modifier')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -408,6 +421,28 @@ export function SequenceWorkspace({
|
|||||||
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
|
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
|
||||||
/>
|
/>
|
||||||
<div className="expression-grid sequence-expression-library">
|
<div className="expression-grid sequence-expression-library">
|
||||||
|
<Button
|
||||||
|
className="expression-card"
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const step = createSequenceStep(NEUTRAL_EXPRESSION_ID)
|
||||||
|
onChange({ ...editing.draft, steps: [...editing.draft.steps, step] })
|
||||||
|
onSelectedStepChange(step.id)
|
||||||
|
onPreviewStep(step)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExpressionPreview
|
||||||
|
expression={defaultExpression}
|
||||||
|
surface={surface}
|
||||||
|
bodyNodes={bodyNodes}
|
||||||
|
colors={colors}
|
||||||
|
avatarEyes={avatarEyes}
|
||||||
|
renderStyle={renderStyle}
|
||||||
|
id="sequence-library-neutral"
|
||||||
|
/>
|
||||||
|
<span>{t('Apparence neutre')}</span>
|
||||||
|
</Button>
|
||||||
{expressions.map((preset, index) => (
|
{expressions.map((preset, index) => (
|
||||||
<Button
|
<Button
|
||||||
className="expression-card"
|
className="expression-card"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
defaultExpression,
|
||||||
getStatePlaybackConfig,
|
getStatePlaybackConfig,
|
||||||
initialExpressions,
|
initialExpressions,
|
||||||
stateGroups,
|
stateGroups,
|
||||||
@ -9,6 +10,7 @@ import type { Expression } from '../avatar/geometry'
|
|||||||
|
|
||||||
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
||||||
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
||||||
|
export const NEUTRAL_EXPRESSION_ID = defaultExpression.id
|
||||||
|
|
||||||
export type SequenceStep = {
|
export type SequenceStep = {
|
||||||
id: string
|
id: string
|
||||||
@ -170,13 +172,16 @@ export const normalizeSequencesForExpressions = (
|
|||||||
expressions: Expression[]
|
expressions: Expression[]
|
||||||
) => {
|
) => {
|
||||||
const availableIds = new Set(expressions.map(expression => expression.id))
|
const availableIds = new Set(expressions.map(expression => expression.id))
|
||||||
const fallbackId = expressions[0]?.id ?? initialExpressions[0].id
|
const fallbackId = expressions[0]?.id ?? NEUTRAL_EXPRESSION_ID
|
||||||
return sequences.map(sequence => ({
|
return sequences.map(sequence => ({
|
||||||
...sequence,
|
...sequence,
|
||||||
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
|
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
|
||||||
step => ({
|
step => ({
|
||||||
...step,
|
...step,
|
||||||
expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId,
|
expressionId:
|
||||||
|
step.expressionId === NEUTRAL_EXPRESSION_ID || availableIds.has(step.expressionId)
|
||||||
|
? step.expressionId
|
||||||
|
: fallbackId,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
@ -185,6 +190,17 @@ export const normalizeSequencesForExpressions = (
|
|||||||
export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
|
export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
|
||||||
expressions.findIndex(expression => expression.id === expressionId)
|
expressions.findIndex(expression => expression.id === expressionId)
|
||||||
|
|
||||||
|
export const resolveSequenceExpression = (expressions: Expression[], expressionId: string) => {
|
||||||
|
if (expressionId === NEUTRAL_EXPRESSION_ID) {
|
||||||
|
return { expression: defaultExpression, index: null, neutral: true } as const
|
||||||
|
}
|
||||||
|
const index = findExpressionIndex(expressions, expressionId)
|
||||||
|
const expression = expressions[index]
|
||||||
|
return expression
|
||||||
|
? ({ expression, index, neutral: false } as const)
|
||||||
|
: ({ expression: undefined, index: null, neutral: false } as const)
|
||||||
|
}
|
||||||
|
|
||||||
export const readSequenceClock = () => performance.now()
|
export const readSequenceClock = () => performance.now()
|
||||||
|
|
||||||
export const groupSequences = (sequences: AvatarSequence[]) => {
|
export const groupSequences = (sequences: AvatarSequence[]) => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { createInitialSequences } from '@/features/animation/sequences'
|
import { createInitialSequences, NEUTRAL_EXPRESSION_ID } from '@/features/animation/sequences'
|
||||||
import {
|
import {
|
||||||
applyAvatarEyeDefaults,
|
applyAvatarEyeDefaults,
|
||||||
cloneAvatarBehavior,
|
cloneAvatarBehavior,
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
createUnkeyedExpressionCopy,
|
createUnkeyedExpressionCopy,
|
||||||
defaultAvatarEyes,
|
defaultAvatarEyes,
|
||||||
parseAvatarEyeDefaults,
|
parseAvatarEyeDefaults,
|
||||||
|
parseAvatarLibrary,
|
||||||
parseAvatarRenderStyle,
|
parseAvatarRenderStyle,
|
||||||
resolveAvatarBehavior,
|
resolveAvatarBehavior,
|
||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
@ -84,4 +85,30 @@ describe('avatar behavior library', () => {
|
|||||||
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
|
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
|
||||||
expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
|
expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves an owned neutral-only behavior library on reload', () => {
|
||||||
|
const avatar = createAvatar('Neutral only')
|
||||||
|
const sequence = createInitialSequences()[0]
|
||||||
|
avatar.behavior = {
|
||||||
|
expressions: [],
|
||||||
|
sequences: [
|
||||||
|
{
|
||||||
|
...sequence,
|
||||||
|
steps: [{ ...sequence.steps[0], expressionId: NEUTRAL_EXPRESSION_ID }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const fallbackAvatar = createAvatar('Fallback')
|
||||||
|
|
||||||
|
const parsed = parseAvatarLibrary(
|
||||||
|
{ activeAvatarId: avatar.id, avatars: [avatar] },
|
||||||
|
{ activeAvatarId: fallbackAvatar.id, avatars: [fallbackAvatar] },
|
||||||
|
base
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(parsed.avatars[0].behavior?.expressions).toEqual([])
|
||||||
|
expect(parsed.avatars[0].behavior?.sequences[0].steps[0].expressionId).toBe(
|
||||||
|
NEUTRAL_EXPRESSION_ID
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
|
import { type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import { NEUTRAL_EXPRESSION_ID } from '../../animation/sequences'
|
||||||
import { createAvatarDefinition } from '../avatarDefinition'
|
import { createAvatarDefinition } from '../avatarDefinition'
|
||||||
import { isAvatarDefinitionSource, studioAvatarFromDefinition } from '../importAvatarDefinition'
|
import {
|
||||||
|
isAvatarDefinitionSource,
|
||||||
|
studioAvatarFromDefinition,
|
||||||
|
studioAvatarFromDefinitionSource,
|
||||||
|
} from '../importAvatarDefinition'
|
||||||
|
|
||||||
describe('studioAvatarFromDefinition', () => {
|
describe('studioAvatarFromDefinition', () => {
|
||||||
it('recognises a definition and rejects a studio project', () => {
|
it('recognises a definition and rejects a studio project', () => {
|
||||||
@ -39,5 +45,101 @@ describe('studioAvatarFromDefinition', () => {
|
|||||||
expect(result.value.expressionOrder).toEqual(strobi.expressionOrder)
|
expect(result.value.expressionOrder).toEqual(strobi.expressionOrder)
|
||||||
expect(result.value.animationOrder).toEqual(strobi.animationOrder)
|
expect(result.value.animationOrder).toEqual(strobi.animationOrder)
|
||||||
expect(result.value.expressions).toEqual(strobi.expressions)
|
expect(result.value.expressions).toEqual(strobi.expressions)
|
||||||
|
expect(result.value.animations).toEqual(strobi.animations)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inverts customized neutral eyes instead of applying them twice', () => {
|
||||||
|
const customized = structuredClone(strobi) as AvatarDefinition
|
||||||
|
Object.values(customized.expressions).forEach(item => {
|
||||||
|
item.eyes.left.width += 10
|
||||||
|
item.eyes.right.width += 10
|
||||||
|
item.eyes.spacing += 6
|
||||||
|
item.eyes.left.y -= 4
|
||||||
|
item.eyes.right.y -= 4
|
||||||
|
})
|
||||||
|
|
||||||
|
const imported = studioAvatarFromDefinition(customized)
|
||||||
|
const result = createAvatarDefinition({
|
||||||
|
avatar: imported.avatar,
|
||||||
|
behavior: { expressions: imported.expressions, sequences: imported.sequences },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
expect(result.value.expressions.neutral).toEqual(customized.expressions.neutral)
|
||||||
|
Object.keys(customized.expressions).forEach(key => {
|
||||||
|
expect(result.value.expressions[key].eyes.left.width).toBeCloseTo(
|
||||||
|
customized.expressions[key].eyes.left.width,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
expect(result.value.expressions[key].eyes.spacing).toBeCloseTo(
|
||||||
|
customized.expressions[key].eyes.spacing,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
expect(result.value.expressions[key].eyes.left.y).toBeCloseTo(
|
||||||
|
customized.expressions[key].eyes.left.y,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves animation steps targeting the neutral appearance', () => {
|
||||||
|
const customized = structuredClone(strobi) as AvatarDefinition
|
||||||
|
customized.animations.sleeping.steps[0].expression = 'neutral'
|
||||||
|
|
||||||
|
const imported = studioAvatarFromDefinition(customized)
|
||||||
|
const sleeping = imported.sequences.find(sequence => sequence.semanticKey === 'sleeping')
|
||||||
|
expect(sleeping?.steps[0].expressionId).toBe(NEUTRAL_EXPRESSION_ID)
|
||||||
|
|
||||||
|
const result = createAvatarDefinition({
|
||||||
|
avatar: imported.avatar,
|
||||||
|
behavior: { expressions: imported.expressions, sequences: imported.sequences },
|
||||||
|
})
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
expect(result.value.animations.sleeping).toEqual(customized.animations.sleeping)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates unique Studio ids for repeated imports', () => {
|
||||||
|
const first = studioAvatarFromDefinition(strobi)
|
||||||
|
const second = studioAvatarFromDefinition(strobi)
|
||||||
|
|
||||||
|
expect(first.avatar.id).not.toBe(second.avatar.id)
|
||||||
|
expect(first.expressions[0].id).not.toBe(second.expressions[0].id)
|
||||||
|
expect(first.sequences[0].id).not.toBe(second.sequences[0].id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects neutral properties that the Studio cannot represent', () => {
|
||||||
|
const customized = structuredClone(strobi) as AvatarDefinition
|
||||||
|
customized.expressions.neutral.head.x = 1
|
||||||
|
|
||||||
|
expect(() => studioAvatarFromDefinition(customized)).toThrow(
|
||||||
|
'The neutral expression must use zero head rotation'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects eye dimensions that the Studio would clamp during re-export', () => {
|
||||||
|
const customized = structuredClone(strobi) as AvatarDefinition
|
||||||
|
customized.expressions['upward-side-glance'].eyes.left.width = 9
|
||||||
|
|
||||||
|
expect(() => studioAvatarFromDefinition(customized)).toThrow(
|
||||||
|
'uses an eye dimension below the Studio minimum of 10'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses bounded parsing for untrusted definition text', () => {
|
||||||
|
expect(() => studioAvatarFromDefinitionSource('{')).toThrow('Unterminated object')
|
||||||
|
expect(() =>
|
||||||
|
studioAvatarFromDefinitionSource(JSON.stringify({ ...strobi, schemaVersion: 2 }))
|
||||||
|
).toThrow()
|
||||||
|
|
||||||
|
const duplicateKey = JSON.stringify(strobi).replace(
|
||||||
|
'"schemaVersion":1',
|
||||||
|
'"schemaVersion":1,"schemaVersion":1'
|
||||||
|
)
|
||||||
|
expect(() => studioAvatarFromDefinitionSource(duplicateKey)).toThrow('Duplicate object member')
|
||||||
|
expect(() => studioAvatarFromDefinitionSource(' '.repeat(262_145))).toThrow(
|
||||||
|
'JSON input exceeds'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from '@bible-strong/avatar-core'
|
} from '@bible-strong/avatar-core'
|
||||||
import type { SurfaceType } from '@bible-strong/avatar-core'
|
import type { SurfaceType } from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
import { NEUTRAL_EXPRESSION_ID } from '../animation/sequences'
|
||||||
import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
|
import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
|
||||||
import { defaultExpression } from './presets'
|
import { defaultExpression } from './presets'
|
||||||
import type { Expression } from './geometry'
|
import type { Expression } from './geometry'
|
||||||
@ -124,7 +125,10 @@ export const createAvatarDefinition = ({
|
|||||||
)
|
)
|
||||||
if (error) errors.push(error)
|
if (error) errors.push(error)
|
||||||
sequence.steps.forEach((step, stepIndex) => {
|
sequence.steps.forEach((step, stepIndex) => {
|
||||||
if (!expressionKeyById.has(step.expressionId)) {
|
if (
|
||||||
|
step.expressionId !== NEUTRAL_EXPRESSION_ID &&
|
||||||
|
!expressionKeyById.has(step.expressionId)
|
||||||
|
) {
|
||||||
errors.push({
|
errors.push({
|
||||||
path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
|
path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
|
||||||
code: 'unresolved_expression_reference',
|
code: 'unresolved_expression_reference',
|
||||||
@ -149,7 +153,10 @@ export const createAvatarDefinition = ({
|
|||||||
{
|
{
|
||||||
playbackMode: sequence.playbackMode,
|
playbackMode: sequence.playbackMode,
|
||||||
steps: sequence.steps.map(step => ({
|
steps: sequence.steps.map(step => ({
|
||||||
expression: expressionKeyById.get(step.expressionId)!,
|
expression:
|
||||||
|
step.expressionId === NEUTRAL_EXPRESSION_ID
|
||||||
|
? 'neutral'
|
||||||
|
: expressionKeyById.get(step.expressionId)!,
|
||||||
holdMs: step.holdMs,
|
holdMs: step.holdMs,
|
||||||
transitionMs: step.transitionMs,
|
transitionMs: step.transitionMs,
|
||||||
transition: step.transition,
|
transition: step.transition,
|
||||||
|
|||||||
@ -229,8 +229,8 @@ const parseAvatarBehavior = (
|
|||||||
): AvatarBehaviorLibrary | undefined => {
|
): AvatarBehaviorLibrary | undefined => {
|
||||||
if (!value || typeof value !== 'object') return undefined
|
if (!value || typeof value !== 'object') return undefined
|
||||||
const candidate = value as Partial<AvatarBehaviorLibrary>
|
const candidate = value as Partial<AvatarBehaviorLibrary>
|
||||||
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
|
if (!Array.isArray(candidate.expressions)) return undefined
|
||||||
const expressions = parseExpressions(candidate.expressions)
|
const expressions = candidate.expressions.length ? parseExpressions(candidate.expressions) : []
|
||||||
return restoreLegacyBehaviorSemanticKeys(
|
return restoreLegacyBehaviorSemanticKeys(
|
||||||
{
|
{
|
||||||
expressions,
|
expressions,
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
parseAvatarDefinition,
|
||||||
validateAvatarDefinition,
|
validateAvatarDefinition,
|
||||||
type AvatarAnimationDefinition,
|
type AvatarAnimationDefinition,
|
||||||
type AvatarDefinition,
|
type AvatarDefinition,
|
||||||
type AvatarExpressionDefinition,
|
type AvatarExpressionDefinition,
|
||||||
} from '@bible-strong/avatar-core'
|
} from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
import type { AvatarSequence, SequenceStep } from '../animation/sequences'
|
import {
|
||||||
import type { StudioAvatar } from './avatars'
|
NEUTRAL_EXPRESSION_ID,
|
||||||
|
type AvatarSequence,
|
||||||
|
type SequenceStep,
|
||||||
|
} from '../animation/sequences'
|
||||||
|
import { defaultAvatarEyes, type StudioAvatar } from './avatars'
|
||||||
import type { Expression } from './geometry'
|
import type { Expression } from './geometry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,27 +38,82 @@ const slugify = (value: string) =>
|
|||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '') || 'avatar'
|
.replace(/^-+|-+$/g, '') || 'avatar'
|
||||||
|
|
||||||
|
const createImportId = (slug: string) =>
|
||||||
|
`${slug}-${
|
||||||
|
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}`
|
||||||
|
|
||||||
|
const relativeEyeValue = (value: number, neutral: number, fallback: number) =>
|
||||||
|
value - neutral + fallback
|
||||||
|
|
||||||
const toExpression = (
|
const toExpression = (
|
||||||
slug: string,
|
importId: string,
|
||||||
semanticKey: string,
|
semanticKey: string,
|
||||||
expression: AvatarExpressionDefinition
|
expression: AvatarExpressionDefinition,
|
||||||
|
neutral: AvatarExpressionDefinition
|
||||||
): Expression => ({
|
): Expression => ({
|
||||||
id: `expression-${slug}-${semanticKey}`,
|
id: `expression-${importId}-${semanticKey}`,
|
||||||
semanticKey,
|
semanticKey,
|
||||||
headX: expression.head.x,
|
headX: expression.head.x,
|
||||||
headY: expression.head.y,
|
headY: expression.head.y,
|
||||||
headZ: expression.head.z,
|
headZ: expression.head.z,
|
||||||
widthLeft: expression.eyes.left.width,
|
widthLeft: relativeEyeValue(
|
||||||
widthRight: expression.eyes.right.width,
|
expression.eyes.left.width,
|
||||||
heightLeft: expression.eyes.left.height,
|
neutral.eyes.left.width,
|
||||||
heightRight: expression.eyes.right.height,
|
defaultAvatarEyes.widthLeft
|
||||||
spacing: expression.eyes.spacing,
|
),
|
||||||
positionXLeft: expression.eyes.left.x,
|
widthRight: relativeEyeValue(
|
||||||
positionXRight: expression.eyes.right.x,
|
expression.eyes.right.width,
|
||||||
positionYLeft: expression.eyes.left.y,
|
neutral.eyes.right.width,
|
||||||
positionYRight: expression.eyes.right.y,
|
defaultAvatarEyes.widthRight
|
||||||
leftAngle: expression.eyes.left.angle,
|
),
|
||||||
rightAngle: expression.eyes.right.angle,
|
heightLeft: relativeEyeValue(
|
||||||
|
expression.eyes.left.height,
|
||||||
|
neutral.eyes.left.height,
|
||||||
|
defaultAvatarEyes.heightLeft
|
||||||
|
),
|
||||||
|
heightRight: relativeEyeValue(
|
||||||
|
expression.eyes.right.height,
|
||||||
|
neutral.eyes.right.height,
|
||||||
|
defaultAvatarEyes.heightRight
|
||||||
|
),
|
||||||
|
spacing: relativeEyeValue(
|
||||||
|
expression.eyes.spacing,
|
||||||
|
neutral.eyes.spacing,
|
||||||
|
defaultAvatarEyes.spacing
|
||||||
|
),
|
||||||
|
positionXLeft: relativeEyeValue(
|
||||||
|
expression.eyes.left.x,
|
||||||
|
neutral.eyes.left.x,
|
||||||
|
defaultAvatarEyes.positionXLeft
|
||||||
|
),
|
||||||
|
positionXRight: relativeEyeValue(
|
||||||
|
expression.eyes.right.x,
|
||||||
|
neutral.eyes.right.x,
|
||||||
|
defaultAvatarEyes.positionXRight
|
||||||
|
),
|
||||||
|
positionYLeft: relativeEyeValue(
|
||||||
|
expression.eyes.left.y,
|
||||||
|
neutral.eyes.left.y,
|
||||||
|
defaultAvatarEyes.positionYLeft
|
||||||
|
),
|
||||||
|
positionYRight: relativeEyeValue(
|
||||||
|
expression.eyes.right.y,
|
||||||
|
neutral.eyes.right.y,
|
||||||
|
defaultAvatarEyes.positionYRight
|
||||||
|
),
|
||||||
|
leftAngle: relativeEyeValue(
|
||||||
|
expression.eyes.left.angle,
|
||||||
|
neutral.eyes.left.angle,
|
||||||
|
defaultAvatarEyes.leftAngle
|
||||||
|
),
|
||||||
|
rightAngle: relativeEyeValue(
|
||||||
|
expression.eyes.right.angle,
|
||||||
|
neutral.eyes.right.angle,
|
||||||
|
defaultAvatarEyes.rightAngle
|
||||||
|
),
|
||||||
perspective: expression.perspective,
|
perspective: expression.perspective,
|
||||||
eyeMotion: expression.motion.eyes,
|
eyeMotion: expression.motion.eyes,
|
||||||
bodyMotion: expression.motion.body,
|
bodyMotion: expression.motion.body,
|
||||||
@ -62,19 +122,19 @@ const toExpression = (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toSequence = (
|
const toSequence = (
|
||||||
slug: string,
|
importId: string,
|
||||||
semanticKey: string,
|
semanticKey: string,
|
||||||
animation: AvatarAnimationDefinition,
|
animation: AvatarAnimationDefinition,
|
||||||
expressionIdByKey: Map<string, string>
|
expressionIdByKey: Map<string, string>
|
||||||
): AvatarSequence => {
|
): AvatarSequence => {
|
||||||
const steps: SequenceStep[] = []
|
const steps: SequenceStep[] = []
|
||||||
animation.steps.forEach((step, index) => {
|
animation.steps.forEach((step, index) => {
|
||||||
const expressionId = expressionIdByKey.get(step.expression)
|
const expressionId =
|
||||||
// A step pointing at an expression the definition never declared cannot be
|
step.expression === 'neutral' ? NEUTRAL_EXPRESSION_ID : expressionIdByKey.get(step.expression)
|
||||||
// represented; dropping it keeps the sequence playable.
|
// Validation guarantees that non-neutral references resolve.
|
||||||
if (!expressionId) return
|
if (!expressionId) throw new Error(`Unknown expression '${step.expression}'`)
|
||||||
steps.push({
|
steps.push({
|
||||||
id: `step-${slug}-${semanticKey}-${index}`,
|
id: `step-${importId}-${semanticKey}-${index}`,
|
||||||
expressionId,
|
expressionId,
|
||||||
holdMs: step.holdMs,
|
holdMs: step.holdMs,
|
||||||
transitionMs: step.transitionMs,
|
transitionMs: step.transitionMs,
|
||||||
@ -82,7 +142,7 @@ const toSequence = (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: `sequence-${slug}-${semanticKey}`,
|
id: `sequence-${importId}-${semanticKey}`,
|
||||||
semanticKey,
|
semanticKey,
|
||||||
name: animation.metadata?.label ?? semanticKey,
|
name: animation.metadata?.label ?? semanticKey,
|
||||||
group: animation.metadata?.group ?? 'Importé',
|
group: animation.metadata?.group ?? 'Importé',
|
||||||
@ -100,39 +160,78 @@ export type ImportedAvatarDefinition = {
|
|||||||
sequences: AvatarSequence[]
|
sequences: AvatarSequence[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assertRepresentableNeutral = (neutral: AvatarExpressionDefinition) => {
|
||||||
|
const canonical =
|
||||||
|
neutral.head.x === 0 &&
|
||||||
|
neutral.head.y === 0 &&
|
||||||
|
neutral.head.z === 0 &&
|
||||||
|
neutral.perspective === 1 &&
|
||||||
|
neutral.motion.eyes === 'none' &&
|
||||||
|
neutral.motion.body === 'none' &&
|
||||||
|
neutral.colors === undefined
|
||||||
|
if (!canonical) {
|
||||||
|
throw new Error(
|
||||||
|
'The neutral expression must use zero head rotation, perspective 1, no ambient motion and no color overrides.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertRepresentableEyeDimensions = (definition: AvatarDefinition) => {
|
||||||
|
Object.entries(definition.expressions).forEach(([key, expression]) => {
|
||||||
|
const dimensions = [
|
||||||
|
expression.eyes.left.width,
|
||||||
|
expression.eyes.right.width,
|
||||||
|
expression.eyes.left.height,
|
||||||
|
expression.eyes.right.height,
|
||||||
|
]
|
||||||
|
if (dimensions.some(value => value < 10)) {
|
||||||
|
throw new Error(`Expression '${key}' uses an eye dimension below the Studio minimum of 10.`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValidationError = (errors: readonly { path: string; message: string }[]) => {
|
||||||
|
const first = errors[0]
|
||||||
|
return first ? `${first.path}: ${first.message}` : 'Invalid avatar definition'
|
||||||
|
}
|
||||||
|
|
||||||
/** Throws when the file is not a valid v1 avatar definition. */
|
/** Throws when the file is not a valid v1 avatar definition. */
|
||||||
export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
|
export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
|
||||||
const result = validateAvatarDefinition(value)
|
const result = validateAvatarDefinition(value)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const first = result.errors[0]
|
throw new Error(formatValidationError(result.errors))
|
||||||
throw new Error(first ? `${first.path}: ${first.message}` : 'Invalid avatar definition')
|
|
||||||
}
|
}
|
||||||
const definition: AvatarDefinition = result.value
|
const definition: AvatarDefinition = result.value
|
||||||
const slug = slugify(definition.name ?? 'avatar')
|
const slug = slugify(definition.name ?? 'avatar')
|
||||||
|
const importId = createImportId(slug)
|
||||||
|
const neutral = definition.expressions.neutral
|
||||||
|
assertRepresentableNeutral(neutral)
|
||||||
|
assertRepresentableEyeDimensions(definition)
|
||||||
|
|
||||||
// `neutral` is reserved: the studio does not keep it as an editable expression,
|
// `neutral` is reserved: the studio does not keep it as an editable expression,
|
||||||
// it lives on the avatar as eye defaults and is re-emitted on export.
|
// it lives on the avatar as eye defaults and is re-emitted on export.
|
||||||
const keys = definition.expressionOrder.filter(
|
const keys = definition.expressionOrder.filter(
|
||||||
key => key !== 'neutral' && definition.expressions[key]
|
key => key !== 'neutral' && definition.expressions[key]
|
||||||
)
|
)
|
||||||
const expressions = keys.map(key => toExpression(slug, key, definition.expressions[key]!))
|
const expressions = keys.map(key =>
|
||||||
|
toExpression(importId, key, definition.expressions[key]!, neutral)
|
||||||
|
)
|
||||||
const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id]))
|
const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id]))
|
||||||
|
|
||||||
const sequences = definition.animationOrder
|
const sequences = definition.animationOrder
|
||||||
.filter(key => definition.animations[key])
|
.filter(key => definition.animations[key])
|
||||||
.map(key => toSequence(slug, key, definition.animations[key]!, expressionIdByKey))
|
.map(key => toSequence(importId, key, definition.animations[key]!, expressionIdByKey))
|
||||||
|
|
||||||
// The studio stores one flat set of eye defaults per avatar; `neutral` is the
|
// The studio stores one flat set of eye defaults per avatar; `neutral` is the
|
||||||
// resting pose, so it is the expression those defaults come from.
|
// resting pose, so it is the expression those defaults come from.
|
||||||
const neutral = definition.expressions.neutral
|
|
||||||
const avatar: StudioAvatar = {
|
const avatar: StudioAvatar = {
|
||||||
id: `avatar-${slug}-${definition.body.nodes.length}-${expressions.length}`,
|
id: `avatar-${importId}`,
|
||||||
name: definition.name ?? slug,
|
name: definition.name ?? slug,
|
||||||
body: {
|
body: {
|
||||||
primary: definition.body.primary,
|
primary: definition.body.primary,
|
||||||
// Definition nodes are anonymous; the studio addresses them by id in the editor.
|
// Definition nodes are anonymous; the studio addresses them by id in the editor.
|
||||||
nodes: definition.body.nodes.map((node, index) => ({
|
nodes: definition.body.nodes.map((node, index) => ({
|
||||||
id: `shape-${slug}-${index}`,
|
id: `shape-${importId}-${index}`,
|
||||||
name: `${node.surface.type} ${index + 1}`,
|
name: `${node.surface.type} ${index + 1}`,
|
||||||
surface: node.surface,
|
surface: node.surface,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
@ -159,3 +258,10 @@ export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefini
|
|||||||
|
|
||||||
return { avatar, expressions, sequences }
|
return { avatar, expressions, sequences }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parses untrusted `.avatar.json` text with the core size, depth and duplicate-key limits. */
|
||||||
|
export const studioAvatarFromDefinitionSource = (source: string): ImportedAvatarDefinition => {
|
||||||
|
const result = parseAvatarDefinition(source)
|
||||||
|
if (!result.ok) throw new Error(formatValidationError(result.errors))
|
||||||
|
return studioAvatarFromDefinition(result.value)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
||||||
import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
|
import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import type { Expression } from '../avatar/geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
|
import { defaultExpression } from '../avatar/presets'
|
||||||
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
||||||
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
||||||
import type { AvatarSequence } from '../animation/sequences'
|
import { NEUTRAL_EXPRESSION_ID, type AvatarSequence } from '../animation/sequences'
|
||||||
import { standaloneEngineSource } from './standaloneEngine.generated'
|
import { standaloneEngineSource } from './standaloneEngine.generated'
|
||||||
import { createStoredZip } from './storedZip'
|
import { createStoredZip } from './storedZip'
|
||||||
|
|
||||||
@ -60,7 +61,10 @@ export const createAvatarExportPayload = (
|
|||||||
)
|
)
|
||||||
const exportedExpressions = Object.fromEntries(
|
const exportedExpressions = Object.fromEntries(
|
||||||
[...referencedIds].flatMap(expressionId => {
|
[...referencedIds].flatMap(expressionId => {
|
||||||
const expression = expressionById.get(expressionId)
|
const expression =
|
||||||
|
expressionId === NEUTRAL_EXPRESSION_ID
|
||||||
|
? defaultExpression
|
||||||
|
: expressionById.get(expressionId)
|
||||||
if (!expression) return []
|
if (!expression) return []
|
||||||
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
|
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
|
||||||
expression,
|
expression,
|
||||||
|
|||||||
@ -66,7 +66,7 @@ import {
|
|||||||
type SnapshotFormat,
|
type SnapshotFormat,
|
||||||
} from '@/app/studio-utils'
|
} from '@/app/studio-utils'
|
||||||
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
|
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
|
||||||
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
|
import { groupSequences, resolveSequenceExpression } from '@/features/animation/sequences'
|
||||||
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||||
import {
|
import {
|
||||||
ExpressionCard,
|
ExpressionCard,
|
||||||
@ -679,9 +679,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
setSequenceEditing(current => (current ? { ...current, draft } : current))
|
setSequenceEditing(current => (current ? { ...current, draft } : current))
|
||||||
}
|
}
|
||||||
onPreviewStep={step => {
|
onPreviewStep={step => {
|
||||||
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
|
const resolved = resolveSequenceExpression(expressions, step.expressionId)
|
||||||
const preset = expressions[expressionIndex]
|
if (resolved.expression) {
|
||||||
if (preset) transitionToExpression(preset, expressionIndex, step)
|
transitionToExpression(resolved.expression, resolved.index, step)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onEditExpression={openExpressionEditor}
|
onEditExpression={openExpressionEditor}
|
||||||
onPlay={() => launchSequence(sequenceEditing.draft, false, false)}
|
onPlay={() => launchSequence(sequenceEditing.draft, false, false)}
|
||||||
@ -1065,17 +1066,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
<Copy />
|
<Copy />
|
||||||
{t('Dupliquer')}
|
{t('Dupliquer')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
|
||||||
// downloadAvatarRuntimeDefinition is a no-op on an invalid
|
|
||||||
// definition, so surface why the button is dead.
|
|
||||||
title={runtimeExportErrors[0] ?? undefined}
|
|
||||||
onClick={downloadAvatarRuntimeDefinition}
|
|
||||||
>
|
|
||||||
<Download />
|
|
||||||
{t('Exporter')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
|
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
|
||||||
</footer>
|
</footer>
|
||||||
@ -1225,7 +1215,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
{group.sequences.map(sequence => {
|
{group.sequences.map(sequence => {
|
||||||
const firstStep = sequence.steps[0]
|
const firstStep = sequence.steps[0]
|
||||||
const firstExpression = firstStep
|
const firstExpression = firstStep
|
||||||
? expressionById.get(firstStep.expressionId)
|
? resolveSequenceExpression(expressions, firstStep.expressionId)
|
||||||
|
.expression
|
||||||
: undefined
|
: undefined
|
||||||
const card = (
|
const card = (
|
||||||
<Button
|
<Button
|
||||||
@ -1705,7 +1696,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
{sequences.map(animation => {
|
{sequences.map(animation => {
|
||||||
const firstStep = animation.steps[0]
|
const firstStep = animation.steps[0]
|
||||||
const firstExpression = firstStep
|
const firstExpression = firstStep
|
||||||
? expressionById.get(firstStep.expressionId)
|
? resolveSequenceExpression(expressions, firstStep.expressionId)
|
||||||
|
.expression
|
||||||
: undefined
|
: undefined
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -2017,20 +2009,21 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
<div className="state-playback-bar">
|
<div className="state-playback-bar">
|
||||||
<div className="state-playback-timeline">
|
<div className="state-playback-timeline">
|
||||||
{activeSequence.steps.map((step, position) => {
|
{activeSequence.steps.map((step, position) => {
|
||||||
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
|
const resolved = resolveSequenceExpression(expressions, step.expressionId)
|
||||||
const preset = expressions[expressionIndex]
|
if (!resolved.expression) return null
|
||||||
if (!preset) return null
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="state-playback-step"
|
className="state-playback-step"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
key={step.id}
|
key={step.id}
|
||||||
aria-pressed={activeExpression === expressionIndex}
|
aria-pressed={activeExpression === resolved.index}
|
||||||
onClick={() => transitionToExpression(preset, expressionIndex, step)}
|
onClick={() =>
|
||||||
|
transitionToExpression(resolved.expression, resolved.index, step)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ExpressionPreview
|
<ExpressionPreview
|
||||||
expression={preset}
|
expression={resolved.expression}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
bodyNodes={bodyNodes}
|
bodyNodes={bodyNodes}
|
||||||
colors={activeAvatar.colors}
|
colors={activeAvatar.colors}
|
||||||
|
|||||||
@ -36,8 +36,10 @@ import {
|
|||||||
duplicateSequence,
|
duplicateSequence,
|
||||||
findExpressionIndex,
|
findExpressionIndex,
|
||||||
getSequenceSpring,
|
getSequenceSpring,
|
||||||
|
NEUTRAL_EXPRESSION_ID,
|
||||||
readSequenceClock,
|
readSequenceClock,
|
||||||
remapSequencesAfterExpressionDelete,
|
remapSequencesAfterExpressionDelete,
|
||||||
|
resolveSequenceExpression,
|
||||||
type AvatarSequence,
|
type AvatarSequence,
|
||||||
type SequenceStep,
|
type SequenceStep,
|
||||||
} from '@/features/animation/sequences'
|
} from '@/features/animation/sequences'
|
||||||
@ -66,7 +68,7 @@ import {
|
|||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
import {
|
import {
|
||||||
isAvatarDefinitionSource,
|
isAvatarDefinitionSource,
|
||||||
studioAvatarFromDefinition,
|
studioAvatarFromDefinitionSource,
|
||||||
} from '@/features/avatar/importAvatarDefinition'
|
} from '@/features/avatar/importAvatarDefinition'
|
||||||
import {
|
import {
|
||||||
bodyPrimitiveTypes,
|
bodyPrimitiveTypes,
|
||||||
@ -1104,16 +1106,15 @@ export function useStudioController() {
|
|||||||
playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null }
|
playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null }
|
||||||
const step = sequence.steps[playbackTimeline.current.position]
|
const step = sequence.steps[playbackTimeline.current.position]
|
||||||
const availableExpressions = expressionsRef.current
|
const availableExpressions = expressionsRef.current
|
||||||
const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId)
|
const resolved = resolveSequenceExpression(availableExpressions, step.expressionId)
|
||||||
const preset = availableExpressions[expressionIndex]
|
|
||||||
const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs
|
const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs
|
||||||
setPlaybackVisual(current => ({
|
setPlaybackVisual(current => ({
|
||||||
position: playbackTimeline.current.position,
|
position: playbackTimeline.current.position,
|
||||||
run: current.run + 1,
|
run: current.run + 1,
|
||||||
durationMs,
|
durationMs,
|
||||||
}))
|
}))
|
||||||
if (preset) {
|
if (resolved.expression) {
|
||||||
transitionToExpression(preset, expressionIndex, step)
|
transitionToExpression(resolved.expression, resolved.index, step)
|
||||||
}
|
}
|
||||||
scheduleAdvance(durationMs)
|
scheduleAdvance(durationMs)
|
||||||
}
|
}
|
||||||
@ -1402,15 +1403,18 @@ export function useStudioController() {
|
|||||||
steps: sequence.steps.map(step => ({ ...step })),
|
steps: sequence.steps.map(step => ({ ...step })),
|
||||||
blink: { ...sequence.blink },
|
blink: { ...sequence.blink },
|
||||||
}
|
}
|
||||||
: createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id)
|
: createSequence(
|
||||||
|
expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id ?? NEUTRAL_EXPRESSION_ID
|
||||||
|
)
|
||||||
setSequenceEditing({ sourceId: sequence?.id ?? null, draft })
|
setSequenceEditing({ sourceId: sequence?.id ?? null, draft })
|
||||||
setSelectedSequenceStepId(draft.steps[0]?.id ?? null)
|
setSelectedSequenceStepId(draft.steps[0]?.id ?? null)
|
||||||
const firstStep = draft.steps[0]
|
const firstStep = draft.steps[0]
|
||||||
const expressionIndex = firstStep
|
const resolved = firstStep
|
||||||
? findExpressionIndex(expressions, firstStep.expressionId)
|
? resolveSequenceExpression(expressions, firstStep.expressionId)
|
||||||
: -1
|
: null
|
||||||
const preset = expressions[expressionIndex]
|
if (resolved?.expression) {
|
||||||
if (preset) transitionToExpression(preset, expressionIndex, firstStep)
|
transitionToExpression(resolved.expression, resolved.index, firstStep)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelSequenceEditing = () => {
|
const cancelSequenceEditing = () => {
|
||||||
@ -1824,9 +1828,8 @@ export function useStudioController() {
|
|||||||
.then(source => {
|
.then(source => {
|
||||||
// One picker, two formats: a `.avatar.json` definition adds a single avatar
|
// One picker, two formats: a `.avatar.json` definition adds a single avatar
|
||||||
// to the current library, a studio project replaces the whole document.
|
// to the current library, a studio project replaces the whole document.
|
||||||
const parsed: unknown = JSON.parse(source)
|
try {
|
||||||
if (isAvatarDefinitionSource(parsed)) {
|
const { avatar } = studioAvatarFromDefinitionSource(source)
|
||||||
const { avatar } = studioAvatarFromDefinition(parsed)
|
|
||||||
const document = currentStudioDocument()
|
const document = currentStudioDocument()
|
||||||
setPendingProjectImport({
|
setPendingProjectImport({
|
||||||
document: {
|
document: {
|
||||||
@ -1843,13 +1846,19 @@ export function useStudioController() {
|
|||||||
kind: 'avatar',
|
kind: 'avatar',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
} catch (avatarError) {
|
||||||
|
const parsed: unknown = JSON.parse(source)
|
||||||
|
if (isAvatarDefinitionSource(parsed)) throw avatarError
|
||||||
}
|
}
|
||||||
const imported = parseImportedStudioDocument(source, currentStudioDocument())
|
const imported = parseImportedStudioDocument(source, currentStudioDocument())
|
||||||
setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
|
setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(error => {
|
||||||
|
const summary = t(
|
||||||
|
'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.'
|
||||||
|
)
|
||||||
setProjectImportError(
|
setProjectImportError(
|
||||||
t('Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.')
|
error instanceof Error && error.message ? `${summary} ${error.message}` : summary
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,6 +76,7 @@ const english: Record<string, string> = {
|
|||||||
'Use lowercase letters, numbers, and hyphens, for example happy-smile.',
|
'Use lowercase letters, numbers, and hyphens, for example happy-smile.',
|
||||||
'neutral est réservé à l’apparence neutre de l’avatar.':
|
'neutral est réservé à l’apparence neutre de l’avatar.':
|
||||||
'neutral is reserved for the avatar neutral appearance.',
|
'neutral is reserved for the avatar neutral appearance.',
|
||||||
|
'Apparence neutre': 'Neutral appearance',
|
||||||
'Cette clé est déjà utilisée dans cette bibliothèque.':
|
'Cette clé est déjà utilisée dans cette bibliothèque.':
|
||||||
'This key is already used in this library.',
|
'This key is already used in this library.',
|
||||||
'L’avatar à gauche affiche cette expression en direct.':
|
'L’avatar à gauche affiche cette expression en direct.':
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const chinese: Record<string, string> = {
|
|||||||
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
|
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
|
||||||
'请使用小写字母、数字和连字符,例如 happy-smile。',
|
'请使用小写字母、数字和连字符,例如 happy-smile。',
|
||||||
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
|
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
|
||||||
|
'Apparence neutre': '中性外观',
|
||||||
'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
|
'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
|
||||||
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
|
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
|
||||||
Corps: '身体',
|
Corps: '身体',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user