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 { Button } from '@/components/ui/button'
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from '@/components/ui/menu'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@ -24,12 +25,15 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||
avatarsRef,
|
||||
cancelAvatarMove,
|
||||
commitAvatarMove,
|
||||
avatarImportRef,
|
||||
createNewAvatar,
|
||||
draggedAvatarId,
|
||||
draggingAvatarId,
|
||||
duplicateAvatar,
|
||||
expressions,
|
||||
prepareStudioProjectImport,
|
||||
previewAvatarMove,
|
||||
projectImportError,
|
||||
reduceMotion,
|
||||
setDeleteAvatarOpen,
|
||||
setDraggingAvatarId,
|
||||
@ -133,15 +137,44 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||
</ContextMenu>
|
||||
</motion.div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="avatar-add creation-card"
|
||||
onClick={createNewAvatar}
|
||||
aria-label={t('Nouvel avatar')}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="avatar-add creation-card"
|
||||
aria-label={t('Ajouter un avatar')}
|
||||
>
|
||||
<Plus />
|
||||
</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>
|
||||
{projectImportError && (
|
||||
<p className="project-transfer-error" role="alert">
|
||||
{projectImportError}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -108,11 +108,19 @@ export function StudioDialogs({ controller }: { controller: StudioController })
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Importer ce projet ?')}</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{pendingProjectImport?.kind === 'avatar'
|
||||
? t('Importer cet avatar ?')
|
||||
: t('Importer ce projet ?')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
|
||||
)}{' '}
|
||||
{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.'
|
||||
)}{' '}
|
||||
{pendingProjectImport?.fileName}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
@ -1065,6 +1065,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
<Copy />
|
||||
{t('Dupliquer')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
// downloadAvatarRuntimeDefinition is a no-op on an invalid
|
||||
// definition, so surface why the button is dead.
|
||||
title={runtimeExportErrors[0] ?? undefined}
|
||||
onClick={downloadAvatarRuntimeDefinition}
|
||||
>
|
||||
<Download />
|
||||
{t('Exporter')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
|
||||
</footer>
|
||||
|
||||
@ -64,6 +64,10 @@ import {
|
||||
type AvatarEyeDefaults,
|
||||
type StudioAvatar,
|
||||
} from '@/features/avatar/avatars'
|
||||
import {
|
||||
isAvatarDefinitionSource,
|
||||
studioAvatarFromDefinition,
|
||||
} from '@/features/avatar/importAvatarDefinition'
|
||||
import {
|
||||
bodyPrimitiveTypes,
|
||||
createBodyNode,
|
||||
@ -207,9 +211,11 @@ export function useStudioController() {
|
||||
const [pendingProjectImport, setPendingProjectImport] = useState<{
|
||||
document: StudioDocument
|
||||
fileName: string
|
||||
kind: 'project' | 'avatar'
|
||||
} | null>(null)
|
||||
const [projectImportError, setProjectImportError] = useState<string | null>(null)
|
||||
const projectImportRef = useRef<HTMLInputElement>(null)
|
||||
const avatarImportRef = useRef<HTMLInputElement>(null)
|
||||
const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
|
||||
const [activeExpression, setActiveExpression] = useState<number | null>(null)
|
||||
const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null)
|
||||
@ -1809,19 +1815,41 @@ export function useStudioController() {
|
||||
setProjectImportError(null)
|
||||
if (file.size > 10_000_000) {
|
||||
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
|
||||
}
|
||||
file
|
||||
.text()
|
||||
.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())
|
||||
setPendingProjectImport({ document: imported, fileName: file.name })
|
||||
setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
|
||||
})
|
||||
.catch(() => {
|
||||
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,
|
||||
previewStateMove,
|
||||
projectImportError,
|
||||
avatarImportRef,
|
||||
projectImportRef,
|
||||
reduceMotion,
|
||||
renameActiveAvatar,
|
||||
|
||||
@ -392,8 +392,13 @@ const english: Record<string, string> = {
|
||||
'Transfer every avatar, expression and animation to another browser.',
|
||||
'Télécharger le projet JSON': 'Download JSON project',
|
||||
'Importer un projet JSON': 'Import JSON project',
|
||||
'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
|
||||
'This file does not contain a valid, compatible Avatar Studio project.',
|
||||
'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.':
|
||||
'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?',
|
||||
'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.',
|
||||
|
||||
@ -352,8 +352,13 @@ export const chinese: Record<string, string> = {
|
||||
'将所有头像、表情和动画传输到另一个浏览器。',
|
||||
'Télécharger le 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 ?': '导入此项目?',
|
||||
'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