feat(export): add ready-to-run avatar demos
This commit is contained in:
parent
2ba720e059
commit
843da29718
@ -447,6 +447,13 @@ input {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-format-section {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
|
||||
.export-format-grid > button {
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
@ -521,14 +528,6 @@ input {
|
||||
min-height: 44px;
|
||||
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
|
||||
}
|
||||
.runtime-export-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.runtime-export-actions > button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
.runtime-copy-status {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
@ -538,9 +537,10 @@ input {
|
||||
.runtime-copy-status[role='alert'] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.runtime-example-card {
|
||||
.runtime-quick-start-card,
|
||||
.runtime-export-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
.runtime-export-description {
|
||||
margin: 0;
|
||||
@ -548,32 +548,95 @@ input {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.runtime-example-heading {
|
||||
.runtime-quick-start-heading {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.runtime-example-heading > div:first-child {
|
||||
.runtime-quick-start-heading > div,
|
||||
.runtime-export-heading > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
.runtime-example-heading small {
|
||||
.runtime-quick-start-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.runtime-quick-start-heading > button {
|
||||
flex: none;
|
||||
color: #4168d5;
|
||||
font-size: 11px;
|
||||
}
|
||||
.runtime-quick-start-heading > button > svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.runtime-quick-start-step {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
.runtime-quick-start-step > span,
|
||||
.runtime-export-heading small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.runtime-example-heading strong {
|
||||
.runtime-quick-start-step > code {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dfe3eb;
|
||||
border-radius: 9px;
|
||||
background: #f7f8fb;
|
||||
color: #3159cb;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.runtime-quick-start-step > pre {
|
||||
max-width: 100%;
|
||||
max-height: 245px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid #2d323b;
|
||||
border-radius: 10px;
|
||||
background: #17191d;
|
||||
color: #d8dee9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.65;
|
||||
white-space: pre;
|
||||
}
|
||||
.runtime-export-heading {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.runtime-export-heading strong {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.runtime-example-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
.runtime-export-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.runtime-export-actions button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
}
|
||||
.runtime-export-secondary-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.runtime-token-comment {
|
||||
color: #a6adbb;
|
||||
@ -3175,12 +3238,12 @@ p {
|
||||
.runtime-preview-option-grid {
|
||||
max-height: none;
|
||||
}
|
||||
.runtime-example-heading {
|
||||
.runtime-quick-start-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.runtime-example-actions {
|
||||
justify-content: flex-start;
|
||||
.runtime-quick-start-heading > button {
|
||||
padding-inline: 0;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { createAvatar } from '@/features/avatar/avatars'
|
||||
import { parse } from '@babel/parser'
|
||||
import {
|
||||
avatarDemoFileName,
|
||||
createAvatarExportPayload,
|
||||
generateJavaScriptAvatarHtml,
|
||||
generateJavaScriptAvatarModule,
|
||||
@ -10,6 +11,8 @@ import {
|
||||
generateReactAvatarComponent,
|
||||
generateReactAvatarPackage,
|
||||
generateReactAvatarRuntime,
|
||||
generateReactViteMain,
|
||||
generateReactVitePackage,
|
||||
} from '@/features/export/exporter'
|
||||
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
|
||||
@ -42,6 +45,11 @@ describe('avatar export', () => {
|
||||
)
|
||||
const payload = createAvatarExportPayload(avatar, initialExpressions, animations)
|
||||
|
||||
it('uses distinct filenames for React and ESM demo archives', () => {
|
||||
expect(avatarDemoFileName('Strobi', 'react')).toBe('strobi-avatar-react.zip')
|
||||
expect(avatarDemoFileName('Strobi', 'javascript')).toBe('strobi-avatar-esm.zip')
|
||||
})
|
||||
|
||||
it('includes only the selected animations and their referenced expressions', () => {
|
||||
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
|
||||
expect(Object.keys(payload.expressions).sort()).toEqual(
|
||||
@ -172,6 +180,50 @@ describe('avatar export', () => {
|
||||
expect(contents).not.toContain('AvatarProceduralEngine')
|
||||
})
|
||||
|
||||
it('generates a ready-to-run React TypeScript demo backed by avatar-react', async () => {
|
||||
const document = loadStudioDocument({ getItem: () => null })
|
||||
const studioAvatar = document.library.avatars[0]
|
||||
const definition = createAvatarDefinition({
|
||||
avatar: studioAvatar,
|
||||
behavior: resolveAvatarBehavior(studioAvatar, {
|
||||
expressions: document.expressions,
|
||||
sequences: document.sequences,
|
||||
}),
|
||||
})
|
||||
expect(definition.ok).toBe(true)
|
||||
if (!definition.ok) return
|
||||
|
||||
const source = generateReactViteMain('../strobi.avatar.json', 'Strobi')
|
||||
expect(source).toContain("from '@bible-strong/avatar-react'")
|
||||
expect(source).toContain("from '../strobi.avatar.json'")
|
||||
expect(source).toContain('createAvatar(definition)')
|
||||
expect(source).toContain("kind: 'animation'")
|
||||
expect(source).toContain("kind: 'expression'")
|
||||
expect(() =>
|
||||
parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] })
|
||||
).not.toThrow()
|
||||
|
||||
const archive = new Uint8Array(
|
||||
await generateReactVitePackage(definition.value, 'Strobi').arrayBuffer()
|
||||
)
|
||||
const contents = new TextDecoder().decode(archive)
|
||||
expect(storedZipFileNames(archive)).toEqual([
|
||||
'strobi.avatar.json',
|
||||
'package.json',
|
||||
'index.html',
|
||||
'tsconfig.json',
|
||||
'vite.config.ts',
|
||||
'src/main.tsx',
|
||||
'src/vite-env.d.ts',
|
||||
'src/styles.css',
|
||||
'README.md',
|
||||
])
|
||||
expect(contents).toContain('"@bible-strong/avatar-react": "0.1.0"')
|
||||
expect(contents).toContain('npm install')
|
||||
expect(contents).toContain('npm run dev')
|
||||
expect(contents).not.toContain('AvatarProceduralEngine')
|
||||
})
|
||||
|
||||
it('generates a typed React component backed by the local runtime', () => {
|
||||
const source = generateReactAvatarComponent(payload)
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ import type { AvatarSequence } from '../animation/sequences'
|
||||
import { standaloneEngineSource } from './standaloneEngine.generated'
|
||||
import { createStoredZip } from './storedZip'
|
||||
|
||||
const avatarRuntimePackageVersion = '0.1.0'
|
||||
|
||||
export type AvatarExportAnimation = Pick<
|
||||
AvatarSequence,
|
||||
'name' | 'description' | 'playbackMode' | 'blink'
|
||||
@ -398,7 +400,7 @@ export const generateJavaScriptEsmHtml = (
|
||||
</div>
|
||||
</main>
|
||||
<script type="module">
|
||||
import { createAvatar } from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'
|
||||
import { createAvatar } from 'https://esm.sh/@bible-strong/avatar-web@${avatarRuntimePackageVersion}'
|
||||
|
||||
const definition = await fetch('./${definitionFileName}').then(response => response.json())
|
||||
const avatar = createAvatar('#avatar', { definition, size: '100%' })
|
||||
@ -488,6 +490,210 @@ const avatarComponentName = (name: string) => {
|
||||
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
|
||||
}
|
||||
|
||||
export const generateReactViteMain = (
|
||||
definitionImportPath: string,
|
||||
avatarName: string
|
||||
) => `import { createAvatar } from '@bible-strong/avatar-react'
|
||||
import '@bible-strong/avatar-react/styles.css'
|
||||
import { StrictMode, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import definition from '${definitionImportPath}'
|
||||
import './styles.css'
|
||||
|
||||
const ExportedAvatar = createAvatar(definition)
|
||||
type AnimationName = keyof typeof definition.animations
|
||||
type ExpressionName = keyof typeof definition.expressions
|
||||
type Target =
|
||||
| { kind: 'animation'; key: AnimationName }
|
||||
| { kind: 'expression'; key: ExpressionName }
|
||||
|
||||
const animations = definition.animationOrder as AnimationName[]
|
||||
const expressions = definition.expressionOrder as ExpressionName[]
|
||||
const initialTarget: Target = animations[0]
|
||||
? { kind: 'animation', key: animations[0] }
|
||||
: { kind: 'expression', key: expressions[0] ?? ('neutral' as ExpressionName) }
|
||||
|
||||
function App() {
|
||||
const [target, setTarget] = useState<Target>(initialTarget)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>{${JSON.stringify(avatarName)}}</h1>
|
||||
<div className="demo">
|
||||
<section className="stage">
|
||||
<ExportedAvatar
|
||||
{...(target.kind === 'animation'
|
||||
? { animation: target.key }
|
||||
: { expression: target.key })}
|
||||
size="100%"
|
||||
ariaLabel={${JSON.stringify(`${avatarName} avatar`)}}
|
||||
/>
|
||||
</section>
|
||||
<aside className="controls">
|
||||
<h2>Animations</h2>
|
||||
<div className="grid">
|
||||
{animations.map(key => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={target.kind === 'animation' && target.key === key}
|
||||
onClick={() => setTarget({ kind: 'animation', key })}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h2>Expressions</h2>
|
||||
<div className="grid">
|
||||
{expressions.map(key => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={target.kind === 'expression' && target.key === key}
|
||||
onClick={() => setTarget({ kind: 'expression', key })}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
`
|
||||
|
||||
const generateReactViteStyles = () => `* { box-sizing: border-box; }
|
||||
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
|
||||
body { margin: 0; min-height: 100vh; padding: 32px; color: #f7f8fa; background: #0d1117; }
|
||||
main { width: min(980px, 100%); margin: auto; }
|
||||
h1 { margin: 0 0 24px; }
|
||||
.demo { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, .8fr); gap: 20px; }
|
||||
.stage, .controls { border: 1px solid #293140; border-radius: 22px; background: #141a22; }
|
||||
.stage { display: grid; min-height: 560px; padding: 32px; place-items: center; }
|
||||
.stage > * { width: min(80%, 460px) !important; height: auto !important; aspect-ratio: 1; }
|
||||
.controls { padding: 20px; }
|
||||
h2 { margin: 0 0 12px; font-size: 15px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
|
||||
button { min-width: 0; padding: 10px; overflow: hidden; color: #dbe3ef; border: 1px solid #354052; border-radius: 10px; background: #1b2330; font: inherit; text-overflow: ellipsis; cursor: pointer; }
|
||||
button:hover, button[aria-pressed="true"] { color: #fff; border-color: #7392ed; background: #263657; }
|
||||
@media (max-width: 760px) { body { padding: 16px; } .demo { grid-template-columns: 1fr; } .stage { min-height: 420px; } }
|
||||
`
|
||||
|
||||
export const generateReactViteReadme = (
|
||||
definitionFileName: string,
|
||||
avatarName: string
|
||||
) => `# ${avatarName} · React / TypeScript demo
|
||||
|
||||
This ready-to-run Vite project uses the exported ${definitionFileName} definition with
|
||||
@bible-strong/avatar-react.
|
||||
|
||||
## Run the demo
|
||||
|
||||
\`\`\`sh
|
||||
npm install
|
||||
npm run dev
|
||||
\`\`\`
|
||||
|
||||
Then open the local URL displayed by Vite. The demo includes controls for every exported animation
|
||||
and expression.
|
||||
|
||||
## Production build
|
||||
|
||||
\`\`\`sh
|
||||
npm run build
|
||||
\`\`\`
|
||||
|
||||
The component is created once from the JSON definition with \`createAvatar(definition)\`. TypeScript
|
||||
derives the accepted animation and expression prop keys from that imported JSON file.
|
||||
`
|
||||
|
||||
export const generateReactVitePackage = (
|
||||
definition: Readonly<AvatarDefinition>,
|
||||
avatarName: string
|
||||
) => {
|
||||
const definitionFileName = avatarDefinitionFileName(avatarName)
|
||||
const packageName = `${avatarExportSlug(avatarName)}-avatar-react-demo`
|
||||
const packageJson = {
|
||||
name: packageName,
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
type: 'module',
|
||||
engines: { node: '>=22.12.0' },
|
||||
scripts: {
|
||||
dev: 'vite',
|
||||
build: 'tsc --noEmit && vite build',
|
||||
},
|
||||
dependencies: {
|
||||
'@bible-strong/avatar-react': avatarRuntimePackageVersion,
|
||||
react: '^19.0.0',
|
||||
'react-dom': '^19.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/react': '^19.0.0',
|
||||
'@types/react-dom': '^19.0.0',
|
||||
'@vitejs/plugin-react': '^6.0.2',
|
||||
typescript: '~6.0.3',
|
||||
vite: '^8.0.13',
|
||||
},
|
||||
}
|
||||
const indexHtml = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${escapedHtml(avatarName)} · React avatar demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
||||
strict: true,
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
noEmit: true,
|
||||
jsx: 'react-jsx',
|
||||
skipLibCheck: true,
|
||||
},
|
||||
include: ['src', 'vite.config.ts'],
|
||||
}
|
||||
const viteConfig = `import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({ plugins: [react()] })
|
||||
`
|
||||
|
||||
return createStoredZip([
|
||||
{ name: definitionFileName, content: JSON.stringify(definition, null, 2) },
|
||||
{ name: 'package.json', content: JSON.stringify(packageJson, null, 2) },
|
||||
{ name: 'index.html', content: indexHtml },
|
||||
{ name: 'tsconfig.json', content: JSON.stringify(tsconfig, null, 2) },
|
||||
{ name: 'vite.config.ts', content: viteConfig },
|
||||
{
|
||||
name: 'src/main.tsx',
|
||||
content: generateReactViteMain(`../${definitionFileName}`, avatarName),
|
||||
},
|
||||
{ name: 'src/vite-env.d.ts', content: '/// <reference types="vite/client" />\n' },
|
||||
{ name: 'src/styles.css', content: generateReactViteStyles() },
|
||||
{ name: 'README.md', content: generateReactViteReadme(definitionFileName, avatarName) },
|
||||
])
|
||||
}
|
||||
|
||||
export const generateReactAvatarRuntime = () => `
|
||||
export type AvatarData<AnimationName extends string = string> = Readonly<{
|
||||
version: number
|
||||
@ -665,3 +871,6 @@ export const generateReactAvatarPackage = (payload: AvatarExportPayload) => {
|
||||
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
|
||||
return `${avatarExportSlug(name)}-avatar.${extension}`
|
||||
}
|
||||
|
||||
export const avatarDemoFileName = (name: string, format: 'react' | 'javascript') =>
|
||||
`${avatarExportSlug(name)}-avatar-${format === 'react' ? 'react' : 'esm'}.zip`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
@ -71,6 +71,29 @@ import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePrevie
|
||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||
import type { StudioController } from '@/features/studio/useStudioController'
|
||||
|
||||
const reactQuickStartInstall = 'npm install @bible-strong/avatar-react react react-dom'
|
||||
const webQuickStartInstall = 'npm install @bible-strong/avatar-web'
|
||||
|
||||
const reactQuickStartExample = (animationKey: string | undefined) =>
|
||||
`import { createAvatar } from '@bible-strong/avatar-react'
|
||||
import '@bible-strong/avatar-react/styles.css'
|
||||
import definition from './avatar.avatar.json'
|
||||
|
||||
const Avatar = createAvatar(definition)
|
||||
|
||||
export function App() {
|
||||
return <Avatar ${animationKey ? `defaultAnimation="${animationKey}"` : 'defaultExpression="neutral"'} />
|
||||
}`
|
||||
|
||||
const webQuickStartExample = (animationKey: string | undefined) =>
|
||||
`import { createAvatar } from '@bible-strong/avatar-web'
|
||||
import definition from './avatar.avatar.json'
|
||||
|
||||
const avatar = createAvatar('#avatar', {
|
||||
definition,
|
||||
${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`}
|
||||
})`
|
||||
|
||||
export function StudioInspector({ controller }: { controller: StudioController }) {
|
||||
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
@ -1637,7 +1660,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
</div>
|
||||
</InspectorCard>
|
||||
|
||||
<InspectorCard>
|
||||
<section className="export-format-section" aria-label={t('Format')}>
|
||||
<PanelTitle
|
||||
title="Format"
|
||||
subtitle="Choisis l’intégration correspondant à ton projet."
|
||||
@ -1668,7 +1691,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</InspectorCard>
|
||||
</section>
|
||||
|
||||
<InspectorCard>
|
||||
<div className="preset-header export-animation-header">
|
||||
@ -1780,10 +1803,41 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
</InspectorCard>
|
||||
)}
|
||||
|
||||
<InspectorCard className="runtime-example-card">
|
||||
<div className="runtime-example-heading">
|
||||
<InspectorCard className="runtime-quick-start-card">
|
||||
<div className="runtime-quick-start-heading">
|
||||
<div>
|
||||
<small>{t('Définition prête à tester')}</small>
|
||||
<p className="eyebrow">{t('Démarrage rapide')}</p>
|
||||
<h2>{t('Utiliser cet avatar')}</h2>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
|
||||
{t('Voir le guide complet')}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="runtime-quick-start-step">
|
||||
<span>{t('Installation')}</span>
|
||||
<code>
|
||||
{exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="runtime-quick-start-step">
|
||||
<span>{t('Utilisation minimale')}</span>
|
||||
<pre tabIndex={0}>
|
||||
<code>
|
||||
{exportFormat === 'react'
|
||||
? reactQuickStartExample(runtimePreviewAnimation)
|
||||
: webQuickStartExample(runtimePreviewAnimation)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</InspectorCard>
|
||||
|
||||
<InspectorCard className="runtime-export-card">
|
||||
<div className="runtime-export-heading">
|
||||
<div>
|
||||
<small>{t('Prêt à exporter')}</small>
|
||||
<strong>
|
||||
{selectedExportAnimations.length} {t('animations')} ·{' '}
|
||||
{runtimeDefinitionResult.ok
|
||||
@ -1792,30 +1846,14 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
{t('expressions')}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="runtime-example-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
onClick={() => setRuntimePreviewOpen(true)}
|
||||
>
|
||||
<Play />
|
||||
{t('Preview')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
|
||||
<BookOpen />
|
||||
{t('Guide d’utilisation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{exportFormat === 'javascript' && (
|
||||
<p className="runtime-export-description">
|
||||
{t(
|
||||
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.'
|
||||
exportFormat === 'javascript'
|
||||
? 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.'
|
||||
: 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</InspectorCard>
|
||||
</div>
|
||||
|
||||
<div className="runtime-export-actions">
|
||||
<Button
|
||||
@ -1831,23 +1869,37 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||
onClick={downloadAvatarExport}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copier le JSON formaté')}
|
||||
<Download />
|
||||
{t(
|
||||
exportFormat === 'javascript'
|
||||
? 'Télécharger la démo ESM (.zip)'
|
||||
: 'Télécharger la démo React (.zip)'
|
||||
)}
|
||||
</Button>
|
||||
{exportFormat === 'javascript' && (
|
||||
<div className="runtime-export-secondary-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
onClick={downloadAvatarExport}
|
||||
onClick={() => setRuntimePreviewOpen(true)}
|
||||
>
|
||||
<Download />
|
||||
{t('Télécharger l’intégration ESM (.zip)')}
|
||||
<Play />
|
||||
{t('Preview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copier le JSON')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</InspectorCard>
|
||||
{runtimeCopyStatus !== 'idle' && (
|
||||
<p
|
||||
className="runtime-copy-status"
|
||||
|
||||
@ -83,7 +83,11 @@ import {
|
||||
} from '@/features/avatar/geometry'
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||
import { avatarExportFileName, generateJavaScriptEsmPackage } from '@/features/export/exporter'
|
||||
import {
|
||||
avatarDemoFileName,
|
||||
generateJavaScriptEsmPackage,
|
||||
generateReactVitePackage,
|
||||
} from '@/features/export/exporter'
|
||||
import {
|
||||
serializeAvatarSnapshot,
|
||||
serializePixelSnapshot,
|
||||
@ -1569,10 +1573,12 @@ export function useStudioController() {
|
||||
)
|
||||
}
|
||||
const downloadAvatarExport = () => {
|
||||
if (!runtimeDefinitionResult.ok || exportFormat !== 'javascript') return
|
||||
if (!runtimeDefinitionResult.ok) return
|
||||
downloadBlob(
|
||||
generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name),
|
||||
avatarExportFileName(activeAvatar.name, 'zip')
|
||||
exportFormat === 'javascript'
|
||||
? generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name)
|
||||
: generateReactVitePackage(runtimeDefinitionResult.value, activeAvatar.name),
|
||||
avatarDemoFileName(activeAvatar.name, exportFormat)
|
||||
)
|
||||
}
|
||||
const downloadAvatarRuntimeDefinition = () => {
|
||||
|
||||
@ -141,6 +141,7 @@ const english: Record<string, string> = {
|
||||
'Fix the highlighted keys in the Expressions or Animations editors.',
|
||||
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
|
||||
'Copier le JSON formaté': 'Copy formatted JSON',
|
||||
'Copier le JSON': 'Copy JSON',
|
||||
'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.',
|
||||
'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
|
||||
Installation: 'Installation',
|
||||
@ -149,6 +150,7 @@ const english: Record<string, string> = {
|
||||
'Aperçu avec le package React': 'Preview using the React package',
|
||||
Preview: 'Preview',
|
||||
'Définition prête à tester': 'Definition ready to test',
|
||||
'Démarrage rapide': 'Quick start',
|
||||
'Preview de la définition exportée': 'Exported definition preview',
|
||||
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
||||
'Test the animations and expressions actually included in the .avatar.json file.',
|
||||
@ -167,6 +169,7 @@ const english: Record<string, string> = {
|
||||
'Expression de départ': 'Starting expression',
|
||||
'Aucune animation sélectionnée': 'No animation selected',
|
||||
'Guide d’utilisation': 'Usage guide',
|
||||
'Voir le guide complet': 'View full usage guide',
|
||||
'Guide d’utilisation de l’avatar React': 'React avatar usage guide',
|
||||
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript avatar usage guide',
|
||||
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
||||
@ -305,7 +308,15 @@ const english: Record<string, string> = {
|
||||
'Intégration ESM avec le package avatar-web': 'ESM integration with the avatar-web package',
|
||||
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
|
||||
'The ZIP contains the exported JSON, an index.html demo and its README. The demo loads avatar-web from a CDN.',
|
||||
'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.':
|
||||
'The ZIP contains the exported JSON and a ready-to-run Vite React TypeScript project. Start it with npm install, then npm run dev.',
|
||||
'Télécharger l’intégration ESM (.zip)': 'Download ESM integration (.zip)',
|
||||
'Télécharger la démo React (.zip)': 'Download React demo (.zip)',
|
||||
'Télécharger la démo ESM (.zip)': 'Download ESM demo (.zip)',
|
||||
'Télécharger le JSON': 'Download JSON',
|
||||
'Utilisation minimale': 'Minimal usage',
|
||||
'Utiliser cet avatar': 'Use this avatar',
|
||||
'Prêt à exporter': 'Ready to export',
|
||||
Snapshot: 'Snapshot',
|
||||
'Mode photo': 'Photo Mode',
|
||||
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
|
||||
|
||||
@ -110,6 +110,7 @@ export const chinese: Record<string, string> = {
|
||||
'请在“表情”或“动画”编辑器中修正标记的键。',
|
||||
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
|
||||
'Copier le JSON formaté': '复制格式化的 JSON',
|
||||
'Copier le JSON': '复制 JSON',
|
||||
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
|
||||
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
|
||||
Installation: '安装',
|
||||
@ -118,6 +119,7 @@ export const chinese: Record<string, string> = {
|
||||
'Aperçu avec le package React': '使用 React 包预览',
|
||||
Preview: '预览',
|
||||
'Définition prête à tester': '定义已可测试',
|
||||
'Démarrage rapide': '快速开始',
|
||||
'Preview de la définition exportée': '导出定义预览',
|
||||
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
||||
'测试 .avatar.json 文件中实际包含的动画和表情。',
|
||||
@ -136,6 +138,7 @@ export const chinese: Record<string, string> = {
|
||||
'Expression de départ': '起始表情',
|
||||
'Aucune animation sélectionnée': '未选择动画',
|
||||
'Guide d’utilisation': '使用指南',
|
||||
'Voir le guide complet': '查看完整使用指南',
|
||||
'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
|
||||
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
|
||||
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
||||
@ -266,7 +269,15 @@ export const chinese: Record<string, string> = {
|
||||
'Intégration ESM avec le package avatar-web': '使用 avatar-web 包的 ESM 集成',
|
||||
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
|
||||
'ZIP 包含导出的 JSON、index.html 演示和 README。演示会从 CDN 加载 avatar-web。',
|
||||
'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.':
|
||||
'ZIP 包含导出的 JSON 和一个可直接运行的 Vite React TypeScript 项目。先运行 npm install,再运行 npm run dev。',
|
||||
'Télécharger l’intégration ESM (.zip)': '下载 ESM 集成(.zip)',
|
||||
'Télécharger la démo React (.zip)': '下载 React 演示(.zip)',
|
||||
'Télécharger la démo ESM (.zip)': '下载 ESM 演示(.zip)',
|
||||
'Télécharger le JSON': '下载 JSON',
|
||||
'Utilisation minimale': '最简用法',
|
||||
'Utiliser cet avatar': '使用此头像',
|
||||
'Prêt à exporter': '可以导出',
|
||||
Snapshot: '快照',
|
||||
'Mode photo': '照片模式',
|
||||
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user