feat(studio): import .avatar.json definitions into the library
The studio could only write runtime definitions: createAvatarDefinition is one-way, and the sole file picker fed everything to parseImportedStudioDocument, which rejects a definition for lacking version/library/expressions/sequences. There was no way to bring an exported avatar back in. Add the inverse mapping and wire it into the avatar library: - studioAvatarFromDefinition() rebuilds a StudioAvatar, its expressions and its sequences from a definition. `neutral` is deliberately not restored as an expression: it is a reserved semantic key that lives on the avatar as eye defaults and is re-emitted on export. - The import picker now sniffs the format. A definition adds one avatar and keeps the rest of the library; a studio project still replaces the document. - Avatars tab: the "+" card opens a menu offering a new avatar or an import. - Avatar editor: an Exporter button beside Dupliquer downloads the avatar as .avatar.json, disabled with the validation error as a tooltip when the definition would not validate. - Menu primitive built on the existing @base-ui/react dependency, matching context-menu.tsx. No new packages. The round-trip test imports the repo's strobi.avatar.json fixture and exports it again, asserting identical body, colors, expressions and ordering, so the two directions cannot drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
175691ab32
commit
d6fcf63fe6
41
src/components/ui/menu.tsx
Normal file
41
src/components/ui/menu.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Menu(props: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuContent({ className, ...props }: MenuPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner className="z-50" sideOffset={6}>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
className={cn(
|
||||||
|
'min-w-56 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Menu, MenuContent, MenuItem, MenuTrigger }
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import { createAvatarDefinition } from '../avatarDefinition'
|
||||||
|
import { isAvatarDefinitionSource, studioAvatarFromDefinition } from '../importAvatarDefinition'
|
||||||
|
|
||||||
|
describe('studioAvatarFromDefinition', () => {
|
||||||
|
it('recognises a definition and rejects a studio project', () => {
|
||||||
|
expect(isAvatarDefinitionSource(strobi)).toBe(true)
|
||||||
|
expect(isAvatarDefinitionSource({ version: 2, library: { avatars: [] } })).toBe(false)
|
||||||
|
expect(isAvatarDefinitionSource(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a file that is not a valid definition', () => {
|
||||||
|
expect(() => studioAvatarFromDefinition({ schema: 'bible-strong/avatar-definition' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips back to the same definition', () => {
|
||||||
|
const { avatar, expressions, sequences } = studioAvatarFromDefinition(strobi)
|
||||||
|
|
||||||
|
expect(avatar.name).toBe(strobi.name)
|
||||||
|
expect(avatar.colors).toEqual(strobi.colors)
|
||||||
|
expect(avatar.body.primary).toEqual(strobi.body.primary)
|
||||||
|
expect(avatar.body.nodes).toHaveLength(strobi.body.nodes.length)
|
||||||
|
// `neutral` becomes the avatar's eye defaults rather than an expression.
|
||||||
|
expect(expressions).toHaveLength(strobi.expressionOrder.length - 1)
|
||||||
|
expect(expressions.map(e => e.semanticKey)).not.toContain('neutral')
|
||||||
|
expect(sequences).toHaveLength(strobi.animationOrder.length)
|
||||||
|
|
||||||
|
// Eye defaults are taken from `neutral`, the resting pose.
|
||||||
|
expect(avatar.eyes.spacing).toBe(strobi.expressions.neutral.eyes.spacing)
|
||||||
|
|
||||||
|
const result = createAvatarDefinition({ avatar, behavior: { expressions, sequences } })
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
|
||||||
|
expect(result.value.body).toEqual(strobi.body)
|
||||||
|
expect(result.value.colors).toEqual(strobi.colors)
|
||||||
|
expect(result.value.expressionOrder).toEqual(strobi.expressionOrder)
|
||||||
|
expect(result.value.animationOrder).toEqual(strobi.animationOrder)
|
||||||
|
expect(result.value.expressions).toEqual(strobi.expressions)
|
||||||
|
})
|
||||||
|
})
|
||||||
161
src/features/avatar/importAvatarDefinition.ts
Normal file
161
src/features/avatar/importAvatarDefinition.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
validateAvatarDefinition,
|
||||||
|
type AvatarAnimationDefinition,
|
||||||
|
type AvatarDefinition,
|
||||||
|
type AvatarExpressionDefinition,
|
||||||
|
} from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
import type { AvatarSequence, SequenceStep } from '../animation/sequences'
|
||||||
|
import type { StudioAvatar } from './avatars'
|
||||||
|
import type { Expression } from './geometry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a `.avatar.json` runtime definition back into studio state.
|
||||||
|
*
|
||||||
|
* This is the inverse of `createAvatarDefinition`: the definition keys expressions
|
||||||
|
* by semantic key with nested head/eyes objects, while the studio keeps a flat
|
||||||
|
* `Expression` record carrying its own id. Keep this in sync with `mapExpression`
|
||||||
|
* in ./avatarDefinition.ts — the two must round-trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AVATAR_DEFINITION_SCHEMA = 'bible-strong/avatar-definition'
|
||||||
|
|
||||||
|
export const isAvatarDefinitionSource = (value: unknown): boolean =>
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
(value as { schema?: unknown }).schema === AVATAR_DEFINITION_SCHEMA
|
||||||
|
|
||||||
|
const slugify = (value: string) =>
|
||||||
|
value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'avatar'
|
||||||
|
|
||||||
|
const toExpression = (
|
||||||
|
slug: string,
|
||||||
|
semanticKey: string,
|
||||||
|
expression: AvatarExpressionDefinition
|
||||||
|
): Expression => ({
|
||||||
|
id: `expression-${slug}-${semanticKey}`,
|
||||||
|
semanticKey,
|
||||||
|
headX: expression.head.x,
|
||||||
|
headY: expression.head.y,
|
||||||
|
headZ: expression.head.z,
|
||||||
|
widthLeft: expression.eyes.left.width,
|
||||||
|
widthRight: expression.eyes.right.width,
|
||||||
|
heightLeft: expression.eyes.left.height,
|
||||||
|
heightRight: expression.eyes.right.height,
|
||||||
|
spacing: expression.eyes.spacing,
|
||||||
|
positionXLeft: expression.eyes.left.x,
|
||||||
|
positionXRight: expression.eyes.right.x,
|
||||||
|
positionYLeft: expression.eyes.left.y,
|
||||||
|
positionYRight: expression.eyes.right.y,
|
||||||
|
leftAngle: expression.eyes.left.angle,
|
||||||
|
rightAngle: expression.eyes.right.angle,
|
||||||
|
perspective: expression.perspective,
|
||||||
|
eyeMotion: expression.motion.eyes,
|
||||||
|
bodyMotion: expression.motion.body,
|
||||||
|
...(expression.colors?.body ? { bodyColor: expression.colors.body } : {}),
|
||||||
|
...(expression.colors?.eyes ? { eyeColor: expression.colors.eyes } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toSequence = (
|
||||||
|
slug: string,
|
||||||
|
semanticKey: string,
|
||||||
|
animation: AvatarAnimationDefinition,
|
||||||
|
expressionIdByKey: Map<string, string>
|
||||||
|
): AvatarSequence => {
|
||||||
|
const steps: SequenceStep[] = []
|
||||||
|
animation.steps.forEach((step, index) => {
|
||||||
|
const expressionId = expressionIdByKey.get(step.expression)
|
||||||
|
// A step pointing at an expression the definition never declared cannot be
|
||||||
|
// represented; dropping it keeps the sequence playable.
|
||||||
|
if (!expressionId) return
|
||||||
|
steps.push({
|
||||||
|
id: `step-${slug}-${semanticKey}-${index}`,
|
||||||
|
expressionId,
|
||||||
|
holdMs: step.holdMs,
|
||||||
|
transitionMs: step.transitionMs,
|
||||||
|
transition: step.transition,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id: `sequence-${slug}-${semanticKey}`,
|
||||||
|
semanticKey,
|
||||||
|
name: animation.metadata?.label ?? semanticKey,
|
||||||
|
group: animation.metadata?.group ?? 'Importé',
|
||||||
|
description: animation.metadata?.description ?? '',
|
||||||
|
builtIn: false,
|
||||||
|
playbackMode: animation.playbackMode,
|
||||||
|
steps,
|
||||||
|
blink: animation.blink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportedAvatarDefinition = {
|
||||||
|
avatar: StudioAvatar
|
||||||
|
expressions: Expression[]
|
||||||
|
sequences: AvatarSequence[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws when the file is not a valid v1 avatar definition. */
|
||||||
|
export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
|
||||||
|
const result = validateAvatarDefinition(value)
|
||||||
|
if (!result.ok) {
|
||||||
|
const first = result.errors[0]
|
||||||
|
throw new Error(first ? `${first.path}: ${first.message}` : 'Invalid avatar definition')
|
||||||
|
}
|
||||||
|
const definition: AvatarDefinition = result.value
|
||||||
|
const slug = slugify(definition.name ?? 'avatar')
|
||||||
|
|
||||||
|
// `neutral` is reserved: the studio does not keep it as an editable expression,
|
||||||
|
// it lives on the avatar as eye defaults and is re-emitted on export.
|
||||||
|
const keys = definition.expressionOrder.filter(
|
||||||
|
key => key !== 'neutral' && definition.expressions[key]
|
||||||
|
)
|
||||||
|
const expressions = keys.map(key => toExpression(slug, key, definition.expressions[key]!))
|
||||||
|
const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id]))
|
||||||
|
|
||||||
|
const sequences = definition.animationOrder
|
||||||
|
.filter(key => definition.animations[key])
|
||||||
|
.map(key => toSequence(slug, key, definition.animations[key]!, expressionIdByKey))
|
||||||
|
|
||||||
|
// The studio stores one flat set of eye defaults per avatar; `neutral` is the
|
||||||
|
// resting pose, so it is the expression those defaults come from.
|
||||||
|
const neutral = definition.expressions.neutral
|
||||||
|
const avatar: StudioAvatar = {
|
||||||
|
id: `avatar-${slug}-${definition.body.nodes.length}-${expressions.length}`,
|
||||||
|
name: definition.name ?? slug,
|
||||||
|
body: {
|
||||||
|
primary: definition.body.primary,
|
||||||
|
// Definition nodes are anonymous; the studio addresses them by id in the editor.
|
||||||
|
nodes: definition.body.nodes.map((node, index) => ({
|
||||||
|
id: `shape-${slug}-${index}`,
|
||||||
|
name: `${node.surface.type} ${index + 1}`,
|
||||||
|
surface: node.surface,
|
||||||
|
position: node.position,
|
||||||
|
rotation: node.rotation,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
colors: definition.colors,
|
||||||
|
eyes: {
|
||||||
|
widthLeft: neutral.eyes.left.width,
|
||||||
|
widthRight: neutral.eyes.right.width,
|
||||||
|
heightLeft: neutral.eyes.left.height,
|
||||||
|
heightRight: neutral.eyes.right.height,
|
||||||
|
spacing: neutral.eyes.spacing,
|
||||||
|
positionXLeft: neutral.eyes.left.x,
|
||||||
|
positionXRight: neutral.eyes.right.x,
|
||||||
|
positionYLeft: neutral.eyes.left.y,
|
||||||
|
positionYRight: neutral.eyes.right.y,
|
||||||
|
leftAngle: neutral.eyes.left.angle,
|
||||||
|
rightAngle: neutral.eyes.right.angle,
|
||||||
|
},
|
||||||
|
renderStyle: { type: 'vector' },
|
||||||
|
behavior: { expressions, sequences },
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avatar, expressions, sequences }
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Copy, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Menu, MenuContent, MenuItem, MenuTrigger } from '@/components/ui/menu'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@ -24,12 +25,15 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
avatarsRef,
|
avatarsRef,
|
||||||
cancelAvatarMove,
|
cancelAvatarMove,
|
||||||
commitAvatarMove,
|
commitAvatarMove,
|
||||||
|
avatarImportRef,
|
||||||
createNewAvatar,
|
createNewAvatar,
|
||||||
draggedAvatarId,
|
draggedAvatarId,
|
||||||
draggingAvatarId,
|
draggingAvatarId,
|
||||||
duplicateAvatar,
|
duplicateAvatar,
|
||||||
expressions,
|
expressions,
|
||||||
|
prepareStudioProjectImport,
|
||||||
previewAvatarMove,
|
previewAvatarMove,
|
||||||
|
projectImportError,
|
||||||
reduceMotion,
|
reduceMotion,
|
||||||
setDeleteAvatarOpen,
|
setDeleteAvatarOpen,
|
||||||
setDraggingAvatarId,
|
setDraggingAvatarId,
|
||||||
@ -133,15 +137,44 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger
|
||||||
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="avatar-add creation-card"
|
className="avatar-add creation-card"
|
||||||
onClick={createNewAvatar}
|
aria-label={t('Ajouter un avatar')}
|
||||||
aria-label={t('Nouvel avatar')}
|
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem onClick={createNewAvatar}>
|
||||||
|
<Plus /> {t('Nouvel avatar')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => avatarImportRef.current?.click()}>
|
||||||
|
<Upload /> {t('Importer un .avatar.json')}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
<input
|
||||||
|
ref={avatarImportRef}
|
||||||
|
className="project-import-input"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json,.avatar.json"
|
||||||
|
aria-label={t('Importer un .avatar.json')}
|
||||||
|
onChange={event => {
|
||||||
|
prepareStudioProjectImport(event.currentTarget.files?.[0])
|
||||||
|
event.currentTarget.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{projectImportError && (
|
||||||
|
<p className="project-transfer-error" role="alert">
|
||||||
|
{projectImportError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -108,9 +108,17 @@ export function StudioDialogs({ controller }: { controller: StudioController })
|
|||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{t('Importer ce projet ?')}</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
{pendingProjectImport?.kind === 'avatar'
|
||||||
|
? t('Importer cet avatar ?')
|
||||||
|
: t('Importer ce projet ?')}
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{t(
|
{pendingProjectImport?.kind === 'avatar'
|
||||||
|
? t(
|
||||||
|
'Cet avatar sera ajouté à ta bibliothèque avec ses expressions et animations, puis sélectionné. Les autres avatars sont conservés.'
|
||||||
|
)
|
||||||
|
: t(
|
||||||
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
|
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
|
||||||
)}{' '}
|
)}{' '}
|
||||||
{pendingProjectImport?.fileName}
|
{pendingProjectImport?.fileName}
|
||||||
|
|||||||
@ -1065,6 +1065,17 @@ 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>
|
||||||
|
|||||||
@ -64,6 +64,10 @@ import {
|
|||||||
type AvatarEyeDefaults,
|
type AvatarEyeDefaults,
|
||||||
type StudioAvatar,
|
type StudioAvatar,
|
||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
|
import {
|
||||||
|
isAvatarDefinitionSource,
|
||||||
|
studioAvatarFromDefinition,
|
||||||
|
} from '@/features/avatar/importAvatarDefinition'
|
||||||
import {
|
import {
|
||||||
bodyPrimitiveTypes,
|
bodyPrimitiveTypes,
|
||||||
createBodyNode,
|
createBodyNode,
|
||||||
@ -207,9 +211,11 @@ export function useStudioController() {
|
|||||||
const [pendingProjectImport, setPendingProjectImport] = useState<{
|
const [pendingProjectImport, setPendingProjectImport] = useState<{
|
||||||
document: StudioDocument
|
document: StudioDocument
|
||||||
fileName: string
|
fileName: string
|
||||||
|
kind: 'project' | 'avatar'
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [projectImportError, setProjectImportError] = useState<string | null>(null)
|
const [projectImportError, setProjectImportError] = useState<string | null>(null)
|
||||||
const projectImportRef = useRef<HTMLInputElement>(null)
|
const projectImportRef = useRef<HTMLInputElement>(null)
|
||||||
|
const avatarImportRef = useRef<HTMLInputElement>(null)
|
||||||
const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
|
const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
|
||||||
const [activeExpression, setActiveExpression] = useState<number | null>(null)
|
const [activeExpression, setActiveExpression] = useState<number | null>(null)
|
||||||
const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null)
|
const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null)
|
||||||
@ -1809,19 +1815,41 @@ export function useStudioController() {
|
|||||||
setProjectImportError(null)
|
setProjectImportError(null)
|
||||||
if (file.size > 10_000_000) {
|
if (file.size > 10_000_000) {
|
||||||
setProjectImportError(
|
setProjectImportError(
|
||||||
t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.')
|
t('Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file
|
file
|
||||||
.text()
|
.text()
|
||||||
.then(source => {
|
.then(source => {
|
||||||
|
// One picker, two formats: a `.avatar.json` definition adds a single avatar
|
||||||
|
// to the current library, a studio project replaces the whole document.
|
||||||
|
const parsed: unknown = JSON.parse(source)
|
||||||
|
if (isAvatarDefinitionSource(parsed)) {
|
||||||
|
const { avatar } = studioAvatarFromDefinition(parsed)
|
||||||
|
const document = currentStudioDocument()
|
||||||
|
setPendingProjectImport({
|
||||||
|
document: {
|
||||||
|
...document,
|
||||||
|
library: {
|
||||||
|
activeAvatarId: avatar.id,
|
||||||
|
avatars: [
|
||||||
|
...document.library.avatars.filter(existing => existing.id !== avatar.id),
|
||||||
|
avatar,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileName: file.name,
|
||||||
|
kind: 'avatar',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const imported = parseImportedStudioDocument(source, currentStudioDocument())
|
const imported = parseImportedStudioDocument(source, currentStudioDocument())
|
||||||
setPendingProjectImport({ document: imported, fileName: file.name })
|
setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setProjectImportError(
|
setProjectImportError(
|
||||||
t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.')
|
t('Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1995,6 +2023,7 @@ export function useStudioController() {
|
|||||||
previewSelectedBodyNode,
|
previewSelectedBodyNode,
|
||||||
previewStateMove,
|
previewStateMove,
|
||||||
projectImportError,
|
projectImportError,
|
||||||
|
avatarImportRef,
|
||||||
projectImportRef,
|
projectImportRef,
|
||||||
reduceMotion,
|
reduceMotion,
|
||||||
renameActiveAvatar,
|
renameActiveAvatar,
|
||||||
|
|||||||
@ -392,8 +392,13 @@ const english: Record<string, string> = {
|
|||||||
'Transfer every avatar, expression and animation to another browser.',
|
'Transfer every avatar, expression and animation to another browser.',
|
||||||
'Télécharger le projet JSON': 'Download JSON project',
|
'Télécharger le projet JSON': 'Download JSON project',
|
||||||
'Importer un projet JSON': 'Import JSON project',
|
'Importer un projet JSON': 'Import JSON project',
|
||||||
'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
|
'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.':
|
||||||
'This file does not contain a valid, compatible Avatar Studio project.',
|
'This file is neither a .avatar.json avatar nor a valid Avatar Studio project.',
|
||||||
|
'Ajouter un avatar': 'Add an avatar',
|
||||||
|
'Importer un .avatar.json': 'Import a .avatar.json',
|
||||||
|
'Importer cet avatar ?': 'Import this avatar?',
|
||||||
|
'Cet avatar sera ajouté à ta bibliothèque avec ses expressions et animations, puis sélectionné. Les autres avatars sont conservés.':
|
||||||
|
'This avatar will be added to your library with its expressions and animations, then selected. Your other avatars are kept.',
|
||||||
'Importer ce projet ?': 'Import this project?',
|
'Importer ce projet ?': 'Import this project?',
|
||||||
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
|
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
|
||||||
'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.',
|
'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.',
|
||||||
|
|||||||
@ -352,8 +352,13 @@ export const chinese: Record<string, string> = {
|
|||||||
'将所有头像、表情和动画传输到另一个浏览器。',
|
'将所有头像、表情和动画传输到另一个浏览器。',
|
||||||
'Télécharger le projet JSON': '下载 JSON 项目',
|
'Télécharger le projet JSON': '下载 JSON 项目',
|
||||||
'Importer un projet JSON': '导入 JSON 项目',
|
'Importer un projet JSON': '导入 JSON 项目',
|
||||||
'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
|
'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.':
|
||||||
'此文件不包含有效且兼容的头像工作室项目。',
|
'此文件既不是 .avatar.json 头像,也不是有效的头像工作室项目。',
|
||||||
|
'Ajouter un avatar': '添加头像',
|
||||||
|
'Importer un .avatar.json': '导入 .avatar.json',
|
||||||
|
'Importer cet avatar ?': '导入此头像?',
|
||||||
|
'Cet avatar sera ajouté à ta bibliothèque avec ses expressions et animations, puis sélectionné. Les autres avatars sont conservés.':
|
||||||
|
'该头像及其表情和动画将被添加到你的库中并选中,其他头像会保留。',
|
||||||
'Importer ce projet ?': '导入此项目?',
|
'Importer ce projet ?': '导入此项目?',
|
||||||
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
|
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
|
||||||
'当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',
|
'当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user