feat: add Memento headset avatar

This commit is contained in:
Наер Михаил Александрович 2026-08-26 20:01:36 +03:00
parent e73ce55fb6
commit 734a99e4fb
21 changed files with 528 additions and 81 deletions

View File

@ -868,6 +868,37 @@
"eyes": "none",
"body": "none"
}
},
"front-facing": {
"head": {
"x": 0,
"y": 0,
"z": 0
},
"eyes": {
"left": {
"width": 20,
"height": 50,
"curvature": 0,
"x": 0,
"y": -7,
"angle": 0
},
"right": {
"width": 20,
"height": 50,
"curvature": 0,
"x": 0,
"y": -7,
"angle": 0
},
"spacing": 35
},
"perspective": 1,
"motion": {
"eyes": "none",
"body": "none"
}
}
},
"expressionOrder": [
@ -899,7 +930,8 @@
"shy-downward",
"angry-brows",
"uneasy-left",
"joyful-arc"
"joyful-arc",
"front-facing"
],
"animations": {
"sleeping": {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="24" fill="none" viewBox="-1 0 26 24"><path fill="currentColor" fill-rule="nonzero" d="M12 1.875C17.7 1.875 22.45 6.4 22.45 12.5h-2C20.45 7.44 16.73 3.875 12 3.875S3.55 7.44 3.55 12.5h-2C1.55 6.4 6.3 1.875 12 1.875ZM3.5 8.25v8.5a4.25 4.25 0 0 1 0-8.5ZM20.5 8.25a4.25 4.25 0 0 1 0 8.5v-8.5ZM22.5 13.5C22.5 18.8 19 22 13.5 22a1 1 0 1 1 0-2c4.25 0 7-2.25 7-5.5 0-1.1.9-2 2-2Z"/></svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -7,9 +7,11 @@ import {
defaultAvatarEyes,
type AvatarColors,
type AvatarEyeDefaults,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
import { poseFromExpression, type Expression } from '@/features/avatar/geometry'
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo'
@ -51,7 +53,7 @@ const previewGeometryCache = new WeakMap<
Expression,
WeakMap<
SurfaceConfig,
WeakMap<BodyNode[], { positionKey: string; geometry: ReturnType<typeof renderAvatar> }>
WeakMap<BodyNode[], { positionKey: string; geometry: ReturnType<typeof renderAvatarByStyle> }>
>
>()
@ -62,7 +64,8 @@ export const getPreviewGeometry = (
expression: Expression,
surface: SurfaceConfig,
bodyNodes: BodyNode[],
eyes: AvatarEyeDefaults = defaultAvatarEyes
eyes: AvatarEyeDefaults = defaultAvatarEyes,
renderStyle: AvatarRenderStyle = { type: 'vector' }
) => {
let surfaceCache = previewGeometryCache.get(expression)
if (!surfaceCache) {
@ -74,13 +77,19 @@ export const getPreviewGeometry = (
bodyCache = new WeakMap()
surfaceCache.set(surface, bodyCache)
}
const positionKey = JSON.stringify(eyes)
const positionKey = JSON.stringify([eyes, renderStyle])
const cached = bodyCache.get(bodyNodes)
if (cached?.positionKey === positionKey) return cached.geometry
const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, {
includeWire: false,
bodyNodes,
})
const geometry = renderAvatarByStyle(
poseWithAvatarEyes(expression, eyes),
surface,
1,
renderStyle,
{
includeWire: false,
bodyNodes,
}
)
bodyCache.set(bodyNodes, { positionKey, geometry })
return geometry
}

View File

@ -1219,6 +1219,11 @@ input {
stroke-linecap: round;
stroke-linejoin: round;
}
.avatar-headset {
fill: #111316 !important;
opacity: 1;
stroke: none !important;
}
.avatar-eye,
.preview-eye {
fill: var(--avatar-eye-color, #ffffff);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="24" fill="none" viewBox="-1 0 26 24"><path fill="currentColor" fill-rule="nonzero" d="M12 1.875C17.7 1.875 22.45 6.4 22.45 12.5h-2C20.45 7.44 16.73 3.875 12 3.875S3.55 7.44 3.55 12.5h-2C1.55 6.4 6.3 1.875 12 1.875ZM3.5 8.25v8.5a4.25 4.25 0 0 1 0-8.5ZM20.5 8.25a4.25 4.25 0 0 1 0 8.5v-8.5ZM22.5 13.5C22.5 18.8 19 22 13.5 22a1 1 0 1 1 0-2c4.25 0 7-2.25 7-5.5 0-1.1.9-2 2-2Z"/></svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -486,8 +486,9 @@ describe('Studio to avatar definition conversion', () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressionOrder).toHaveLength(31)
expect(result.value.expressionOrder).toHaveLength(32)
expect(result.value.expressionOrder[0]).toBe('neutral')
expect(result.value.expressionOrder).toContain('front-facing')
expect(result.value.animationOrder).toHaveLength(25)
expect(result.value.animationOrder).toContain('idle')
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([

View File

@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react'
import { LiveExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { poseFromExpression } from '@/features/avatar/geometry'
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
import {
createRenderedColors,
createRenderedScene,
} from '@/features/rendering/renderedScene'
import { loadStudioDocument } from '@/features/studio/studioDocument'
it('keeps the headset visible and transformed in a live animation preview', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')!
const expression = studio.expressions.find(item => item.semanticKey === 'front-facing')!
const geometry = renderAvatarByStyle(
poseFromExpression(expression),
avatar.body.primary,
1,
avatar.renderStyle,
{ bodyNodes: avatar.body.nodes }
)
const scene = createRenderedScene(geometry)
const view = render(
<LiveExpressionPreview
scene={scene}
colors={createRenderedColors(avatar.colors)}
baseBodyColor={avatar.colors.body}
bodyNodes={avatar.body.nodes}
renderStyle={avatar.renderStyle}
id="animated-headset"
/>
)
const headset = view.container.querySelector('.avatar-headset')
expect(headset).not.toBeNull()
expect(headset?.getAttribute('d')).toBe(geometry.frontPaths.at(-1))
expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1))
})

View File

@ -31,7 +31,12 @@ export type PixelRenderStyle = {
type: 'pixel'
resolution: number
}
export type VectorRenderStyle = { type: 'vector'; filled?: boolean }
export type VectorAccessory = 'headset'
export type VectorRenderStyle = {
type: 'vector'
filled?: boolean
accessory?: VectorAccessory
}
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
export type AvatarEyeDefaults = Pick<
Expression,
@ -68,6 +73,11 @@ export const defaultAvatarColors: AvatarColors = {
}
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true }
export const headsetAvatarRenderStyle: VectorRenderStyle = {
type: 'vector',
filled: true,
accessory: 'headset',
}
const filledAvatarEyeColor = '#111316'
export const defaultPixelRenderStyle: PixelRenderStyle = {
type: 'pixel',
@ -154,6 +164,7 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
export const simpleBundledAvatarIds = [
'primitive-ghost',
'primitive-ghost-headset',
'primitive-apple-bite',
'primitive-lock',
'oneworks-cat',
@ -225,6 +236,7 @@ const primitiveBundledAvatarSpecs = [
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
['primitive-ghost-headset', 'Memento · Headset', 'ghost', deslopAccentColors.orange, 0.9],
['primitive-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9],
] as const
@ -843,9 +855,11 @@ const primitiveBundledAvatars: StudioAvatar[] = [
}
: { ...defaultAvatarEyes },
renderStyle:
surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle },
id === 'primitive-ghost-headset'
? { ...headsetAvatarRenderStyle }
: surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle },
...(surface === 'ghost'
? { behavior: mementoBehavior() }
: surface === 'apple-bite'
@ -931,12 +945,14 @@ const filledBundledAvatarIds = new Set([
...Object.keys(oneWorksBundledAnimalColors),
...Object.keys(mementoAnimalColors),
'primitive-ghost',
'primitive-ghost-headset',
'primitive-apple-bite',
'primitive-lock',
])
const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors))
const filledPrimitiveAvatarColors: Record<string, AvatarColors> = {
'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
'primitive-ghost-headset': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor },
}
@ -975,7 +991,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
)
if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) {
const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => {
if (avatar.id === 'primitive-ghost') {
if (['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id)) {
return { ...avatar, behavior: mementoBehavior(avatar.behavior) }
}
if (filledBundledAvatarIds.has(avatar.id)) {
@ -1106,7 +1122,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
? { ...avatarWithCurrentNodes, name: bundled.name }
: avatarWithCurrentNodes
const avatarWithCurrentEyes =
avatarWithCurrentName.id === 'primitive-ghost' &&
['primitive-ghost', 'primitive-ghost-headset'].includes(avatarWithCurrentName.id) &&
[defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes(
avatarWithCurrentName.eyes.positionYLeft
) &&
@ -1121,21 +1137,25 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
: avatarWithCurrentEyes
const avatarWithFixedColorBehavior =
avatarWithAppleBiteEyes.id === 'primitive-ghost'
const avatarWithFixedColorBehavior = ['primitive-ghost', 'primitive-ghost-headset'].includes(
avatarWithAppleBiteEyes.id
)
? {
...avatarWithAppleBiteEyes,
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
}
: filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id)
? {
...avatarWithAppleBiteEyes,
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior),
}
: filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id)
? {
...avatarWithAppleBiteEyes,
behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior),
}
: avatarWithAppleBiteEyes
const avatarWithCurrentRenderStyle = filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
: avatarWithFixedColorBehavior
: avatarWithAppleBiteEyes
const avatarWithCurrentRenderStyle =
avatarWithFixedColorBehavior.id === 'primitive-ghost-headset'
? { ...avatarWithFixedColorBehavior, renderStyle: { ...headsetAvatarRenderStyle } }
: filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
: avatarWithFixedColorBehavior
const bundledFilledColors =
mementoAnimalColors[avatarWithCurrentRenderStyle.id] ??
oneWorksBundledAnimalColors[avatarWithCurrentRenderStyle.id] ??
@ -1215,9 +1235,18 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
: fallback
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as { type?: unknown; filled?: unknown; resolution?: unknown } | null
const candidate = value as {
type?: unknown
filled?: unknown
accessory?: unknown
resolution?: unknown
} | null
if (candidate?.type === 'vector') {
return candidate.filled === true ? { type: 'vector', filled: true } : { type: 'vector' }
return {
type: 'vector',
...(candidate.filled === true ? { filled: true } : {}),
...(candidate.accessory === 'headset' ? { accessory: 'headset' as const } : {}),
}
}
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
return { ...defaultAvatarRenderStyle }

View File

@ -38,6 +38,7 @@ import {
LivePixelAvatarCanvas,
StaticPixelAvatarCanvas,
} from '@/features/rendering/components/PixelAvatarCanvas'
import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
@ -82,7 +83,7 @@ export function ExpressionPreview({
renderStyle: AvatarRenderStyle
id: string
}) {
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes, renderStyle)
const resolvedColors = resolveColors(expression, colors)
const outlineWidth = avatarBodyOutlineWidth()
const filled = renderStyle.type === 'vector' && renderStyle.filled === true
@ -129,6 +130,7 @@ export function ExpressionPreview({
<path
className="preview-head"
d={pathValue}
transform={geometry.pathTransforms?.back[index]}
key={index}
style={{
fill: filled ? color : bodyFill,
@ -167,8 +169,9 @@ export function ExpressionPreview({
const color = nodeColor(geometry.frontNodeIds[index])
return (
<path
className="preview-head"
className={`preview-head${geometry.headsetFrontIndex === index ? ' avatar-headset' : ''}`}
d={pathValue}
transform={geometry.pathTransforms?.front[index]}
key={`front-${index}`}
style={{
fill: filled ? color : bodyFill,
@ -234,8 +237,10 @@ export function LiveExpressionPreview({
{scene.backPaths.map((pathValue, index) => {
const color = nodeColor(scene.backNodeIds.current[index])
return (
<motion.path
<SvgTransformPath
className="preview-head"
d={pathValue}
svgTransform={scene.backTransforms[index]}
strokeLinecap="round"
strokeLinejoin="round"
style={{
@ -272,8 +277,10 @@ export function LiveExpressionPreview({
{scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index])
return (
<motion.path
<SvgTransformPath
className={`preview-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''}`}
d={pathValue}
svgTransform={scene.frontTransforms[index]}
strokeLinecap="round"
strokeLinejoin="round"
style={{

View File

@ -169,6 +169,29 @@ export const initialExpressions: Expression[] = [
eyeMotion: 'none' as const,
bodyMotion: 'none' as const,
},
{
id: 'expression-front-facing',
semanticKey: 'front-facing',
headX: 0,
headY: 0,
headZ: 0,
widthLeft: 20,
widthRight: 20,
heightLeft: 50,
heightRight: 50,
curvatureLeft: 0,
curvatureRight: 0,
spacing: 35,
positionXLeft: 0,
positionXRight: 0,
positionYLeft: -7,
positionYRight: -7,
leftAngle: 0,
rightAngle: 0,
perspective: 1,
eyeMotion: 'none' as const,
bodyMotion: 'none' as const,
},
]
export const defaultExpression: Expression = {

View File

@ -7,6 +7,8 @@ import {
snapshotFileName,
} from '@/features/export/snapshotExporter'
import { surfacePresets } from '@/features/avatar/surfaces'
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
import { loadStudioDocument } from '@/features/studio/studioDocument'
describe('avatar snapshot export', () => {
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
@ -29,6 +31,29 @@ describe('avatar snapshot export', () => {
expect(svg).not.toContain('width="300" height="300" fill=')
})
it('exports the Memento headset as a transformed translucent front layer', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')!
const headsetGeometry = renderAvatarByStyle(
poseFromExpression({ ...defaultExpression, headY: 18, headZ: 6 }),
avatar.body.primary,
1,
avatar.renderStyle,
{ bodyNodes: avatar.body.nodes }
)
const headsetScene = createRenderedScene(headsetGeometry)
const svg = serializeAvatarSnapshot('Memento · Headset', headsetScene, avatar.colors, {
background: 'transparent',
colorFrom: '#ffffff',
colorTo: '#000000',
size: 1024,
})
expect(svg).toContain('fill="#111316" opacity="1"')
expect(svg).toContain(`transform="${headsetGeometry.pathTransforms?.front.at(-1)}"`)
expect(svg).not.toContain('stroke=')
})
it('applies logo framing without changing the rendered avatar scene', () => {
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
background: 'solid',

View File

@ -28,8 +28,10 @@ const escapeXml = (value: string) =>
return entities[character]
})
const path = (value: string, fill: string, opacity = 1) =>
value ? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"/>` : ''
const path = (value: string, fill: string, opacity = 1, transform?: string) =>
value
? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"${transform ? ` transform="${escapeXml(transform)}"` : ''}/>`
: ''
const backgroundMarkup = (options: SnapshotOptions) => {
if (options.background === 'transparent') return ''
@ -62,21 +64,30 @@ export const serializeAvatarSnapshot = (
options.composition ?? defaultSnapshotComposition
)
const headPath = scene.headPath.get()
const backPaths = scene.backPaths.flatMap(item => {
const backPaths = scene.backPaths.flatMap((item, index) => {
const value = item.get()
return value ? [value] : []
return value ? [{ value, index }] : []
})
const frontPaths = scene.frontPaths.flatMap(item => {
const frontPaths = scene.frontPaths.flatMap((item, index) => {
const value = item.get()
return value ? [value] : []
return value ? [{ value, index }] : []
})
const offsetX = scene.offsetX.get()
const offsetY = scene.offsetY.get()
const body = [
...backPaths.map(value => path(value, colors.body)),
path(headPath, colors.body),
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}</g>`,
...frontPaths.map(value => path(value, colors.body)),
...backPaths.map(({ value, index }) =>
path(value, colors.body, 1, scene.backTransforms[index].get())
),
path(headPath, colors.body, 1, scene.headTransform.get()),
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get(), scene.leftTransform.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get(), scene.rightTransform.get())}</g>`,
...frontPaths.map(({ value, index }) =>
path(
value,
scene.headsetFrontIndex.current === index ? '#111316' : colors.body,
1,
scene.frontTransforms[index].get()
)
),
].join('')
return `<?xml version="1.0" encoding="UTF-8"?>

View File

@ -9,6 +9,7 @@ import {
paintRenderedScene,
} from '@/features/rendering/renderedScene'
import { surfacePresets } from '@/features/avatar/surfaces'
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
import type { BodyNode } from '@/features/avatar/body'
@ -33,6 +34,41 @@ const overlap = (first: ReturnType<typeof pathBounds>, second: ReturnType<typeof
})
describe('rendered avatar scene', () => {
it('keeps the Memento headset in front of the face and moves it with the pose', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')!
const neutralPose = poseFromExpression(defaultExpression)
const turnedPose = poseFromExpression({ ...defaultExpression, headY: 22, headZ: -8 })
const neutral = renderAvatarByStyle(
neutralPose,
avatar.body.primary as SurfaceConfig,
1,
avatar.renderStyle,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
const turned = renderAvatarByStyle(
turnedPose,
avatar.body.primary as SurfaceConfig,
1,
avatar.renderStyle,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
expect(avatar.name).toBe('Memento · Headset')
expect(avatar.colors).toEqual({ body: '#ff9d45', eyes: '#111316' })
expect(avatar.renderStyle).toEqual({ type: 'vector', filled: true, accessory: 'headset' })
expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1)
expect(neutral.frontPaths.at(-1)).toContain('M12 1.875C17.7 1.875')
expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1))
const scene = createRenderedScene(neutral)
paintRenderedScene(scene, turned)
expect(scene.headsetFrontIndex.current).toBe(turned.frontPaths.length - 1)
expect(scene.frontTransforms[scene.headsetFrontIndex.current!].get()).toBe(
turned.pathTransforms?.front.at(-1)
)
})
it('keeps layer identity and hit mapping behind the scene seam', () => {
const node = createBodyNode('sphere', 0)
const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {

View File

@ -0,0 +1,106 @@
import headsetSvg from '@/assets/headset-filled.svg?raw'
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import {
renderAvatar,
type AvatarGeometry,
type AvatarPose,
type RenderAvatarOptions,
} from '@/features/avatar/geometry'
import type { SurfaceConfig } from '@/features/avatar/surfaces'
type Matrix = readonly [number, number, number, number, number, number]
export type AvatarPathTransforms = {
back: string[]
head: string
left: string
right: string
front: string[]
}
export type StudioAvatarGeometry = AvatarGeometry & {
pathTransforms?: AvatarPathTransforms
headsetFrontIndex?: number
}
const pathValues = [...headsetSvg.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)].map(match => match[1])
if (pathValues.length !== 1) {
throw new Error(`Expected one path in headset-filled.svg, received ${pathValues.length}`)
}
const [headsetPath] = pathValues
export const identityTransform = 'matrix(1 0 0 1 0 0)'
const identity: Matrix = [1, 0, 0, 1, 0, 0]
const translate = (x: number, y: number): Matrix => [1, 0, 0, 1, x, y]
const scale = (x: number, y: number): Matrix => [x, 0, 0, y, 0, 0]
const rotate = (degrees: number): Matrix => {
const angle = (degrees * Math.PI) / 180
const cosine = Math.cos(angle)
const sine = Math.sin(angle)
return [cosine, sine, -sine, cosine, 0, 0]
}
const skewX = (degrees: number): Matrix => [1, 0, Math.tan((degrees * Math.PI) / 180), 1, 0, 0]
const skewY = (degrees: number): Matrix => [1, Math.tan((degrees * Math.PI) / 180), 0, 1, 0, 0]
const multiply = (left: Matrix, right: Matrix): Matrix => [
left[0] * right[0] + left[2] * right[1],
left[1] * right[0] + left[3] * right[1],
left[0] * right[2] + left[2] * right[3],
left[1] * right[2] + left[3] * right[3],
left[0] * right[4] + left[2] * right[5] + left[4],
left[1] * right[4] + left[3] * right[5] + left[5],
]
const compose = (...matrices: Matrix[]) => matrices.reduce(multiply, identity)
const clamp = (value: number, minimum: number, maximum: number) =>
Math.min(maximum, Math.max(minimum, value))
const matrixValue = (matrix: Matrix) =>
`matrix(${matrix.map(value => Number(value.toFixed(5))).join(' ')})`
const headsetTransform = (pose: AvatarPose, surface: SurfaceConfig) => {
const expression = pose.expression
const horizontalTurn = clamp(expression.headY / 25, -1, 1)
const verticalTurn = clamp(expression.headX / 25, -1, 1)
const perspective = clamp(expression.perspective, 0.6, 1.4)
const horizontalScale = Math.max(0.38, Math.cos((expression.headY * Math.PI) / 180))
const verticalScale = Math.max(0.46, Math.cos((expression.headX * Math.PI) / 180))
return matrixValue(
compose(
rotate(expression.headZ),
skewY(verticalTurn * 3.2),
skewX(-horizontalTurn * 4.2),
scale(horizontalScale * perspective, verticalScale * perspective),
scale((surface.width * 1.42) / 24, (surface.height * 1.28) / 24),
translate(-12, -13.25)
)
)
}
export const renderAvatarByStyle = (
pose: AvatarPose,
surface: SurfaceConfig,
blink: number,
renderStyle: AvatarRenderStyle,
options: RenderAvatarOptions = {}
): StudioAvatarGeometry => {
const geometry = renderAvatar(pose, surface, blink, options)
if (renderStyle.type !== 'vector' || renderStyle.accessory !== 'headset') return geometry
return {
...geometry,
frontPaths: [...geometry.frontPaths, headsetPath],
frontNodeIds: [...geometry.frontNodeIds, null],
headsetFrontIndex: geometry.frontPaths.length,
pathTransforms: {
back: geometry.backPaths.map(() => identityTransform),
head: identityTransform,
left: identityTransform,
right: identityTransform,
front: [...geometry.frontPaths.map(() => identityTransform), headsetTransform(pose, surface)],
},
}
}

View File

@ -1,6 +1,7 @@
import { RotateCcw } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import type { MotionValue } from 'motion'
import { Button } from '@/components/ui/button'
import { useStudioLanguage } from '@/i18n'
@ -226,6 +227,23 @@ export function RotationGizmo({
type TransformAxis = 'x' | 'y' | 'z'
export function SvgTransformPath({
svgTransform,
...props
}: Omit<React.ComponentProps<typeof motion.path>, 'transform'> & {
svgTransform: MotionValue<string>
}) {
const ref = useRef<SVGPathElement>(null)
useLayoutEffect(() => {
const applyTransform = (value: string) => ref.current?.setAttribute('transform', value)
applyTransform(svgTransform.get())
return svgTransform.on('change', applyTransform)
}, [svgTransform])
return <motion.path {...props} ref={ref} />
}
export function BodyNodeGizmo({
svgRef,
pose,
@ -555,7 +573,9 @@ export function AvatarCanvas({
const {
wirePaths,
backPaths,
backTransforms,
frontPaths,
frontTransforms,
backNodeIds,
frontNodeIds,
headPath,
@ -823,9 +843,10 @@ export function AvatarCanvas({
</defs>
<motion.g style={{ x: offsetX, y: offsetY }}>
{backPaths.map((pathValue, index) => (
<motion.path
<SvgTransformPath
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={pathValue}
svgTransform={backTransforms[index]}
key={index}
style={nodeColorStyle(backNodeIds.current[index])}
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
@ -858,9 +879,10 @@ export function AvatarCanvas({
/>
</g>
{frontPaths.map((pathValue, index) => (
<motion.path
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
<SvgTransformPath
className={`avatar-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''} ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={pathValue}
svgTransform={frontTransforms[index]}
key={index}
style={nodeColorStyle(frontNodeIds.current[index])}
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}

View File

@ -2,17 +2,23 @@ import { motionValue, type MotionValue } from 'motion'
import type { AvatarColors } from '../avatar/avatars'
import { MAX_BODY_NODES } from '../avatar/body'
import type { AvatarGeometry } from '../avatar/geometry'
import { identityTransform, type StudioAvatarGeometry } from './avatarAccessories'
export type RenderedScene = {
headPath: MotionValue<string>
headTransform: MotionValue<string>
backPaths: MotionValue<string>[]
backTransforms: MotionValue<string>[]
frontPaths: MotionValue<string>[]
frontTransforms: MotionValue<string>[]
headsetFrontIndex: { current: number | null }
backNodeIds: { current: (string | null)[] }
frontNodeIds: { current: (string | null)[] }
nodeOrderVersion: MotionValue<number>
leftPath: MotionValue<string>
rightPath: MotionValue<string>
leftTransform: MotionValue<string>
rightTransform: MotionValue<string>
leftOpacity: MotionValue<number>
rightOpacity: MotionValue<number>
offsetX: MotionValue<number>
@ -25,21 +31,31 @@ export type RenderedColors = {
eyes: MotionValue<string>
}
const bodyPathSlots = MAX_BODY_NODES + 2
const bodyPathSlots = MAX_BODY_NODES + 3
export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({
export const createRenderedScene = (geometry: StudioAvatarGeometry): RenderedScene => ({
headPath: motionValue(geometry.headPath),
headTransform: motionValue(geometry.pathTransforms?.head ?? identityTransform),
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
motionValue(geometry.backPaths[index] ?? '')
),
backTransforms: Array.from({ length: bodyPathSlots }, (_, index) =>
motionValue(geometry.pathTransforms?.back[index] ?? identityTransform)
),
frontPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
motionValue(geometry.frontPaths[index] ?? '')
),
frontTransforms: Array.from({ length: bodyPathSlots }, (_, index) =>
motionValue(geometry.pathTransforms?.front[index] ?? identityTransform)
),
headsetFrontIndex: { current: geometry.headsetFrontIndex ?? null },
backNodeIds: { current: geometry.backNodeIds },
frontNodeIds: { current: geometry.frontNodeIds },
nodeOrderVersion: motionValue(0),
leftPath: motionValue(geometry.leftPath),
rightPath: motionValue(geometry.rightPath),
leftTransform: motionValue(geometry.pathTransforms?.left ?? identityTransform),
rightTransform: motionValue(geometry.pathTransforms?.right ?? identityTransform),
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
rightOpacity: motionValue(geometry.rightVisible ? 1 : 0),
offsetX: motionValue(0),
@ -62,8 +78,9 @@ export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y
scene.offsetY.set(offset.y)
}
export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
export const paintRenderedScene = (scene: RenderedScene, geometry: StudioAvatarGeometry) => {
scene.headPath.set(geometry.headPath)
scene.headTransform.set(geometry.pathTransforms?.head ?? identityTransform)
const nodeOrderChanged =
scene.backNodeIds.current.length !== geometry.backNodeIds.length ||
scene.frontNodeIds.current.length !== geometry.frontNodeIds.length ||
@ -71,11 +88,20 @@ export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometr
scene.frontNodeIds.current.some((id, index) => id !== geometry.frontNodeIds[index])
scene.backNodeIds.current = geometry.backNodeIds
scene.frontNodeIds.current = geometry.frontNodeIds
scene.headsetFrontIndex.current = geometry.headsetFrontIndex ?? null
if (nodeOrderChanged) scene.nodeOrderVersion.set(scene.nodeOrderVersion.get() + 1)
scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
scene.backTransforms.forEach((transform, index) =>
transform.set(geometry.pathTransforms?.back[index] ?? identityTransform)
)
scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
scene.frontTransforms.forEach((transform, index) =>
transform.set(geometry.pathTransforms?.front[index] ?? identityTransform)
)
scene.leftPath.set(geometry.leftPath)
scene.rightPath.set(geometry.rightPath)
scene.leftTransform.set(geometry.pathTransforms?.left ?? identityTransform)
scene.rightTransform.set(geometry.pathTransforms?.right ?? identityTransform)
scene.leftOpacity.set(geometry.leftVisible ? 1 : 0)
scene.rightOpacity.set(geometry.rightVisible ? 1 : 0)
scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))

View File

@ -30,6 +30,7 @@ describe('Studio document', () => {
const storage = (value: string | null = null) => ({ getItem: () => value })
const bundledAvatarNames = [
'Memento',
'Memento · Headset',
'Apple Bite',
'Lock',
'Siamese',
@ -79,7 +80,8 @@ describe('Studio document', () => {
.filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
.every(avatar => avatar.colors.body === avatar.colors.eyes)
).toBe(true)
expect(document.library.avatars.slice(0, 13).map(avatar => avatar.body.primary.type)).toEqual([
expect(document.library.avatars.slice(0, 14).map(avatar => avatar.body.primary.type)).toEqual([
'ghost',
'ghost',
'apple-bite',
'lock',
@ -224,7 +226,7 @@ describe('Studio document', () => {
])
expect(
document.library.avatars
.filter(avatar => avatar.id !== 'primitive-ghost')
.filter(avatar => !['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id))
.every(avatar => avatar.colors.body !== '#ff9d45')
).toBe(true)
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
@ -242,7 +244,7 @@ describe('Studio document', () => {
.filter(expression => expression.bodyColor || expression.eyeColor)
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
).toEqual([['angry-brows', '#ba3636', '#610000']])
expect(document.expressions).toHaveLength(28)
expect(document.expressions).toHaveLength(29)
expect(document.sequences).toHaveLength(24)
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
expect(
@ -467,6 +469,39 @@ describe('Studio document', () => {
expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc')
})
it('adds the front-facing expression to existing base and avatar libraries', () => {
const fallback = loadStudioDocument(storage())
const withoutFront = fallback.expressions.filter(
expression => expression.semanticKey !== 'front-facing'
)
const localDocument = {
...fallback,
expressions: withoutFront,
library: {
...fallback.library,
avatars: fallback.library.avatars.map(avatar =>
avatar.behavior
? { ...avatar, behavior: { ...avatar.behavior, expressions: withoutFront } }
: avatar
),
},
}
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
const front = restored.expressions.find(expression => expression.semanticKey === 'front-facing')
expect(front).toMatchObject({ headX: 0, headY: 0, headZ: 0 })
expect(front?.widthLeft).toBe(front?.widthRight)
expect(front?.heightLeft).toBe(front?.heightRight)
expect(
restored.library.avatars
.filter(avatar => avatar.behavior)
.every(avatar =>
avatar.behavior?.expressions.some(expression => expression.semanticKey === 'front-facing')
)
).toBe(true)
})
it('adds the joyful animation to an existing local project', () => {
const fallback = loadStudioDocument(storage())
const localDocument = {

View File

@ -14,10 +14,10 @@ import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorksp
import { defaultExpression } from '@/features/avatar/presets'
import {
avatarBodyOutlineWidth,
useAvatarBodyColorOverride,
useRenderedSceneNodeOrder,
} from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
import type { StudioController } from '@/features/studio/useStudioController'
@ -35,14 +35,9 @@ function LiveAvatarPreview({
useRenderedSceneNodeOrder(scene)
const outlineWidth = avatarBodyOutlineWidth()
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
const fixedSkinColors =
avatar.behavior?.expressions.every(
expression => !expression.bodyColor && !expression.eyeColor
) ?? false
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, avatar.colors.body)
const bodyColor = fixedSkinColors && !bodyColorIsOverridden ? avatar.colors.body : colors.body
const eyeColor = fixedSkinColors ? avatar.colors.eyes : colors.eyes
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
const bodyColor = expressionBodyColor ? colors.body : avatar.colors.body
const eyeColor = expressionBodyColor ? colors.eyes : avatar.colors.eyes
const inheritBodyColor = Boolean(expressionBodyColor)
const nodeColor = (id: string | null | undefined) =>
inheritBodyColor
? bodyColor
@ -76,9 +71,10 @@ function LiveAvatarPreview({
{scene.backPaths.map((pathValue, index) => {
const color = nodeColor(scene.backNodeIds.current[index])
return (
<motion.path
<SvgTransformPath
className="preview-head"
d={pathValue}
svgTransform={scene.backTransforms[index]}
key={`back-${index}`}
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
/>
@ -110,9 +106,10 @@ function LiveAvatarPreview({
{scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index])
return (
<motion.path
className="preview-head"
<SvgTransformPath
className={`preview-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''}`}
d={pathValue}
svgTransform={scene.frontTransforms[index]}
key={`front-${index}`}
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
/>

View File

@ -1276,6 +1276,29 @@
"perspective": 1,
"eyeMotion": "none",
"bodyMotion": "none"
},
{
"id": "expression-front-facing",
"semanticKey": "front-facing",
"headX": 0,
"headY": 0,
"headZ": 0,
"widthLeft": 20,
"widthRight": 20,
"heightLeft": 50,
"heightRight": 50,
"curvatureLeft": 0,
"curvatureRight": 0,
"spacing": 35,
"positionXLeft": 0,
"positionXRight": 0,
"positionYLeft": -7,
"positionYRight": -7,
"leftAngle": 0,
"rightAngle": 0,
"perspective": 1,
"eyeMotion": "none",
"bodyMotion": "none"
}
],
"sequences": [

View File

@ -175,6 +175,7 @@ const restoreSignatureExpressions = (
addMissing = true
) => {
const signatureKeys = new Set(['joyful-arc'])
const additiveKeys = new Set(['joyful-arc', 'front-facing'])
const fallbackByKey = new Map(
fallback
.filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey))
@ -206,7 +207,7 @@ const restoreSignatureExpressions = (
? fallback.filter(
expression =>
expression.semanticKey &&
signatureKeys.has(expression.semanticKey) &&
additiveKeys.has(expression.semanticKey) &&
!existingKeys.has(expression.semanticKey)
)
: []
@ -214,6 +215,14 @@ const restoreSignatureExpressions = (
return changed ? restored : expressions
}
const restoreFrontFacingExpression = (expressions: Expression[], fallback: Expression[]) => {
if (expressions.some(expression => expression.semanticKey === 'front-facing')) {
return expressions
}
const frontFacing = fallback.find(expression => expression.semanticKey === 'front-facing')
return frontFacing ? [...expressions, { ...frontFacing }] : expressions
}
const restoreAvatarSignatureBehavior = (
library: AvatarLibrary,
fallbackExpressions: Expression[],
@ -222,10 +231,9 @@ const restoreAvatarSignatureBehavior = (
let changed = false
const avatars = library.avatars.map(avatar => {
if (!avatar.behavior) return avatar
const expressions = restoreSignatureExpressions(
avatar.behavior.expressions,
fallbackExpressions,
false
const expressions = restoreFrontFacingExpression(
restoreSignatureExpressions(avatar.behavior.expressions, fallbackExpressions, false),
fallbackExpressions
)
const availableExpressionIds = new Set(expressions.map(expression => expression.id))
const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful')

View File

@ -80,7 +80,6 @@ import {
import {
expressionFields,
poseFromExpression,
renderAvatar,
type AvatarPose,
type Expression,
} from '@/features/avatar/geometry'
@ -119,6 +118,7 @@ import {
paintRenderedScene,
} from '@/features/rendering/renderedScene'
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
import {
createStudioDocumentStore,
loadStudioDocument,
@ -309,10 +309,11 @@ export function useStudioController() {
const pose = poseFromExpression(initialExpression)
return {
pose,
geometry: renderAvatar(
geometry: renderAvatarByStyle(
poseWithAvatarEyes(initialExpression, initialAvatar.eyes),
surface,
1,
initialAvatar.renderStyle,
{ bodyNodes }
),
}
@ -411,11 +412,17 @@ export function useStudioController() {
const renderPose = avatar
? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes)
: poseFromExpression(renderedExpression)
const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), {
includeWire: showWireRef.current || highlightRef.current === 'head',
bodyNodes: bodyNodesRef.current,
eyeOffset,
})
const geometry = renderAvatarByStyle(
renderPose,
surfaceRef.current,
blink ?? blinkValue.get(),
avatar?.renderStyle ?? { type: 'vector' },
{
includeWire: showWireRef.current || highlightRef.current === 'head',
bodyNodes: bodyNodesRef.current,
eyeOffset,
}
)
paintRenderedScene(renderedScene, geometry)
paintRenderedOffset(
renderedScene,
@ -1325,6 +1332,7 @@ export function useStudioController() {
const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => {
const now = performance.now()
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
const updateInspector =
shouldSyncCanvasPreviewToReact(bodyEditing, target) &&
now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS
@ -1340,12 +1348,13 @@ export function useStudioController() {
paintRenderedRotationGizmo(renderedRotationGizmo, next)
paintRenderedScene(
renderedScene,
renderAvatar(
renderAvatarByStyle(
poseFromExpression(
resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target)
),
surfaceRef.current,
blinkValue.get(),
avatar?.renderStyle ?? { type: 'vector' },
{
includeWire: showWireRef.current || highlightRef.current === 'head',
bodyNodes: bodyNodesRef.current,
@ -1354,7 +1363,6 @@ export function useStudioController() {
)
return
}
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (avatar) setDisplayColors(resolveColors(next, avatar.colors))
paintPose(poseFromExpression(next))
}