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,
|
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
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContextMenu>
|
{resolved.neutral ? (
|
||||||
<ContextMenuTrigger render={card} />
|
card
|
||||||
<ContextMenuContent>
|
) : (
|
||||||
<ContextMenuItem
|
<ContextMenu>
|
||||||
onClick={() => onEditExpression(expressionIndex, preset)}
|
<ContextMenuTrigger render={card} />
|
||||||
>
|
<ContextMenuContent>
|
||||||
<Pencil /> {t('Modifier')}
|
<ContextMenuItem
|
||||||
</ContextMenuItem>
|
onClick={() => onEditExpression(resolved.index, resolved.expression)}
|
||||||
</ContextMenuContent>
|
>
|
||||||
</ContextMenu>
|
<Pencil /> {t('Modifier')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</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