feat(export): add ready-to-run avatar demos
This commit is contained in:
parent
2ba720e059
commit
843da29718
@ -447,6 +447,13 @@ input {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-format-section {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0;
|
||||||
|
padding: 4px 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-format-grid > button {
|
.export-format-grid > button {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -521,14 +528,6 @@ input {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
|
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 {
|
.runtime-copy-status {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
@ -538,9 +537,10 @@ input {
|
|||||||
.runtime-copy-status[role='alert'] {
|
.runtime-copy-status[role='alert'] {
|
||||||
color: var(--destructive);
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
.runtime-example-card {
|
.runtime-quick-start-card,
|
||||||
|
.runtime-export-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.runtime-export-description {
|
.runtime-export-description {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -548,32 +548,95 @@ input {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.runtime-example-heading {
|
.runtime-quick-start-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
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;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 2px;
|
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);
|
color: var(--muted-foreground);
|
||||||
font-size: 10px;
|
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-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.runtime-example-actions {
|
.runtime-export-actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
gap: 8px;
|
||||||
justify-content: flex-start;
|
}
|
||||||
gap: 6px;
|
.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 {
|
.runtime-token-comment {
|
||||||
color: #a6adbb;
|
color: #a6adbb;
|
||||||
@ -3175,12 +3238,12 @@ p {
|
|||||||
.runtime-preview-option-grid {
|
.runtime-preview-option-grid {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
.runtime-example-heading {
|
.runtime-quick-start-heading {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.runtime-example-actions {
|
.runtime-quick-start-heading > button {
|
||||||
justify-content: flex-start;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createAvatar } from '@/features/avatar/avatars'
|
import { createAvatar } from '@/features/avatar/avatars'
|
||||||
import { parse } from '@babel/parser'
|
import { parse } from '@babel/parser'
|
||||||
import {
|
import {
|
||||||
|
avatarDemoFileName,
|
||||||
createAvatarExportPayload,
|
createAvatarExportPayload,
|
||||||
generateJavaScriptAvatarHtml,
|
generateJavaScriptAvatarHtml,
|
||||||
generateJavaScriptAvatarModule,
|
generateJavaScriptAvatarModule,
|
||||||
@ -10,6 +11,8 @@ import {
|
|||||||
generateReactAvatarComponent,
|
generateReactAvatarComponent,
|
||||||
generateReactAvatarPackage,
|
generateReactAvatarPackage,
|
||||||
generateReactAvatarRuntime,
|
generateReactAvatarRuntime,
|
||||||
|
generateReactViteMain,
|
||||||
|
generateReactVitePackage,
|
||||||
} from '@/features/export/exporter'
|
} from '@/features/export/exporter'
|
||||||
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||||
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
|
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
|
||||||
@ -42,6 +45,11 @@ describe('avatar export', () => {
|
|||||||
)
|
)
|
||||||
const payload = createAvatarExportPayload(avatar, initialExpressions, animations)
|
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', () => {
|
it('includes only the selected animations and their referenced expressions', () => {
|
||||||
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
|
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
|
||||||
expect(Object.keys(payload.expressions).sort()).toEqual(
|
expect(Object.keys(payload.expressions).sort()).toEqual(
|
||||||
@ -172,6 +180,50 @@ describe('avatar export', () => {
|
|||||||
expect(contents).not.toContain('AvatarProceduralEngine')
|
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', () => {
|
it('generates a typed React component backed by the local runtime', () => {
|
||||||
const source = generateReactAvatarComponent(payload)
|
const source = generateReactAvatarComponent(payload)
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import type { AvatarSequence } from '../animation/sequences'
|
|||||||
import { standaloneEngineSource } from './standaloneEngine.generated'
|
import { standaloneEngineSource } from './standaloneEngine.generated'
|
||||||
import { createStoredZip } from './storedZip'
|
import { createStoredZip } from './storedZip'
|
||||||
|
|
||||||
|
const avatarRuntimePackageVersion = '0.1.0'
|
||||||
|
|
||||||
export type AvatarExportAnimation = Pick<
|
export type AvatarExportAnimation = Pick<
|
||||||
AvatarSequence,
|
AvatarSequence,
|
||||||
'name' | 'description' | 'playbackMode' | 'blink'
|
'name' | 'description' | 'playbackMode' | 'blink'
|
||||||
@ -398,7 +400,7 @@ export const generateJavaScriptEsmHtml = (
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script type="module">
|
<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 definition = await fetch('./${definitionFileName}').then(response => response.json())
|
||||||
const avatar = createAvatar('#avatar', { definition, size: '100%' })
|
const avatar = createAvatar('#avatar', { definition, size: '100%' })
|
||||||
@ -488,6 +490,210 @@ const avatarComponentName = (name: string) => {
|
|||||||
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
|
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 const generateReactAvatarRuntime = () => `
|
||||||
export type AvatarData<AnimationName extends string = string> = Readonly<{
|
export type AvatarData<AnimationName extends string = string> = Readonly<{
|
||||||
version: number
|
version: number
|
||||||
@ -665,3 +871,6 @@ export const generateReactAvatarPackage = (payload: AvatarExportPayload) => {
|
|||||||
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
|
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
|
||||||
return `${avatarExportSlug(name)}-avatar.${extension}`
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpen,
|
ArrowRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
@ -71,6 +71,29 @@ import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePrevie
|
|||||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||||
import type { StudioController } from '@/features/studio/useStudioController'
|
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 }) {
|
export function StudioInspector({ controller }: { controller: StudioController }) {
|
||||||
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
@ -1637,7 +1660,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
</div>
|
</div>
|
||||||
</InspectorCard>
|
</InspectorCard>
|
||||||
|
|
||||||
<InspectorCard>
|
<section className="export-format-section" aria-label={t('Format')}>
|
||||||
<PanelTitle
|
<PanelTitle
|
||||||
title="Format"
|
title="Format"
|
||||||
subtitle="Choisis l’intégration correspondant à ton projet."
|
subtitle="Choisis l’intégration correspondant à ton projet."
|
||||||
@ -1668,7 +1691,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</InspectorCard>
|
</section>
|
||||||
|
|
||||||
<InspectorCard>
|
<InspectorCard>
|
||||||
<div className="preset-header export-animation-header">
|
<div className="preset-header export-animation-header">
|
||||||
@ -1780,10 +1803,41 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
</InspectorCard>
|
</InspectorCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<InspectorCard className="runtime-example-card">
|
<InspectorCard className="runtime-quick-start-card">
|
||||||
<div className="runtime-example-heading">
|
<div className="runtime-quick-start-heading">
|
||||||
<div>
|
<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>
|
<strong>
|
||||||
{selectedExportAnimations.length} {t('animations')} ·{' '}
|
{selectedExportAnimations.length} {t('animations')} ·{' '}
|
||||||
{runtimeDefinitionResult.ok
|
{runtimeDefinitionResult.ok
|
||||||
@ -1792,30 +1846,14 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
{t('expressions')}
|
{t('expressions')}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</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">
|
<p className="runtime-export-description">
|
||||||
{t(
|
{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>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</InspectorCard>
|
|
||||||
|
|
||||||
<div className="runtime-export-actions">
|
<div className="runtime-export-actions">
|
||||||
<Button
|
<Button
|
||||||
@ -1831,23 +1869,37 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
onClick={() => void copyAvatarRuntimeDefinition()}
|
onClick={downloadAvatarExport}
|
||||||
>
|
>
|
||||||
<Copy />
|
<Download />
|
||||||
{t('Copier le JSON formaté')}
|
{t(
|
||||||
|
exportFormat === 'javascript'
|
||||||
|
? 'Télécharger la démo ESM (.zip)'
|
||||||
|
: 'Télécharger la démo React (.zip)'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{exportFormat === 'javascript' && (
|
<div className="runtime-export-secondary-actions">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
onClick={downloadAvatarExport}
|
onClick={() => setRuntimePreviewOpen(true)}
|
||||||
>
|
>
|
||||||
<Download />
|
<Play />
|
||||||
{t('Télécharger l’intégration ESM (.zip)')}
|
{t('Preview')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
|
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
{t('Copier le JSON')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</InspectorCard>
|
||||||
{runtimeCopyStatus !== 'idle' && (
|
{runtimeCopyStatus !== 'idle' && (
|
||||||
<p
|
<p
|
||||||
className="runtime-copy-status"
|
className="runtime-copy-status"
|
||||||
|
|||||||
@ -83,7 +83,11 @@ import {
|
|||||||
} from '@/features/avatar/geometry'
|
} from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
import { avatarExportFileName, generateJavaScriptEsmPackage } from '@/features/export/exporter'
|
import {
|
||||||
|
avatarDemoFileName,
|
||||||
|
generateJavaScriptEsmPackage,
|
||||||
|
generateReactVitePackage,
|
||||||
|
} from '@/features/export/exporter'
|
||||||
import {
|
import {
|
||||||
serializeAvatarSnapshot,
|
serializeAvatarSnapshot,
|
||||||
serializePixelSnapshot,
|
serializePixelSnapshot,
|
||||||
@ -1569,10 +1573,12 @@ export function useStudioController() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const downloadAvatarExport = () => {
|
const downloadAvatarExport = () => {
|
||||||
if (!runtimeDefinitionResult.ok || exportFormat !== 'javascript') return
|
if (!runtimeDefinitionResult.ok) return
|
||||||
downloadBlob(
|
downloadBlob(
|
||||||
generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name),
|
exportFormat === 'javascript'
|
||||||
avatarExportFileName(activeAvatar.name, 'zip')
|
? generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name)
|
||||||
|
: generateReactVitePackage(runtimeDefinitionResult.value, activeAvatar.name),
|
||||||
|
avatarDemoFileName(activeAvatar.name, exportFormat)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const downloadAvatarRuntimeDefinition = () => {
|
const downloadAvatarRuntimeDefinition = () => {
|
||||||
|
|||||||
@ -141,6 +141,7 @@ const english: Record<string, string> = {
|
|||||||
'Fix the highlighted keys in the Expressions or Animations editors.',
|
'Fix the highlighted keys in the Expressions or Animations editors.',
|
||||||
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
|
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
|
||||||
'Copier le JSON formaté': 'Copy formatted JSON',
|
'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.',
|
'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.',
|
'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
|
||||||
Installation: 'Installation',
|
Installation: 'Installation',
|
||||||
@ -149,6 +150,7 @@ const english: Record<string, string> = {
|
|||||||
'Aperçu avec le package React': 'Preview using the React package',
|
'Aperçu avec le package React': 'Preview using the React package',
|
||||||
Preview: 'Preview',
|
Preview: 'Preview',
|
||||||
'Définition prête à tester': 'Definition ready to test',
|
'Définition prête à tester': 'Definition ready to test',
|
||||||
|
'Démarrage rapide': 'Quick start',
|
||||||
'Preview de la définition exportée': 'Exported definition preview',
|
'Preview de la définition exportée': 'Exported definition preview',
|
||||||
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
'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.',
|
'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',
|
'Expression de départ': 'Starting expression',
|
||||||
'Aucune animation sélectionnée': 'No animation selected',
|
'Aucune animation sélectionnée': 'No animation selected',
|
||||||
'Guide d’utilisation': 'Usage guide',
|
'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 React': 'React avatar usage guide',
|
||||||
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 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é.':
|
'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',
|
'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.':
|
'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.',
|
'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 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',
|
Snapshot: 'Snapshot',
|
||||||
'Mode photo': 'Photo Mode',
|
'Mode photo': 'Photo Mode',
|
||||||
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
|
'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 定义',
|
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
|
||||||
'Copier le JSON formaté': '复制格式化的 JSON',
|
'Copier le JSON formaté': '复制格式化的 JSON',
|
||||||
|
'Copier le JSON': '复制 JSON',
|
||||||
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
|
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
|
||||||
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
|
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
|
||||||
Installation: '安装',
|
Installation: '安装',
|
||||||
@ -118,6 +119,7 @@ export const chinese: Record<string, string> = {
|
|||||||
'Aperçu avec le package React': '使用 React 包预览',
|
'Aperçu avec le package React': '使用 React 包预览',
|
||||||
Preview: '预览',
|
Preview: '预览',
|
||||||
'Définition prête à tester': '定义已可测试',
|
'Définition prête à tester': '定义已可测试',
|
||||||
|
'Démarrage rapide': '快速开始',
|
||||||
'Preview de la définition exportée': '导出定义预览',
|
'Preview de la définition exportée': '导出定义预览',
|
||||||
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
||||||
'测试 .avatar.json 文件中实际包含的动画和表情。',
|
'测试 .avatar.json 文件中实际包含的动画和表情。',
|
||||||
@ -136,6 +138,7 @@ export const chinese: Record<string, string> = {
|
|||||||
'Expression de départ': '起始表情',
|
'Expression de départ': '起始表情',
|
||||||
'Aucune animation sélectionnée': '未选择动画',
|
'Aucune animation sélectionnée': '未选择动画',
|
||||||
'Guide d’utilisation': '使用指南',
|
'Guide d’utilisation': '使用指南',
|
||||||
|
'Voir le guide complet': '查看完整使用指南',
|
||||||
'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
|
'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
|
||||||
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
|
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
|
||||||
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
'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 集成',
|
'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.':
|
'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。',
|
'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 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: '快照',
|
Snapshot: '快照',
|
||||||
'Mode photo': '照片模式',
|
'Mode photo': '照片模式',
|
||||||
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user