feat: add dedicated Memento avatar

This commit is contained in:
Наер Михаил Александрович 2026-08-24 16:41:33 +03:00
parent 175691ab32
commit be77e778d1
7 changed files with 609 additions and 12 deletions

View File

@ -66,7 +66,17 @@
"required": ["type", "width", "height", "depth", "roundness"], "required": ["type", "width", "height", "depth", "roundness"],
"properties": { "properties": {
"type": { "type": {
"enum": ["sphere", "mickey", "cursor", "cube", "capsule", "cylinder", "cone", "diamond"] "enum": [
"sphere",
"mickey",
"cursor",
"cube",
"capsule",
"cylinder",
"cone",
"diamond",
"ghost"
]
}, },
"width": { "$ref": "#/$defs/dimension" }, "width": { "$ref": "#/$defs/dimension" },
"height": { "$ref": "#/$defs/dimension" }, "height": { "$ref": "#/$defs/dimension" },
@ -122,6 +132,7 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["surface", "position", "rotation"], "required": ["surface", "position", "rotation"],
"properties": { "properties": {
"layer": { "enum": ["auto", "front", "back"] },
"surface": { "$ref": "#/$defs/nodeSurface" }, "surface": { "$ref": "#/$defs/nodeSurface" },
"position": { "$ref": "#/$defs/position" }, "position": { "$ref": "#/$defs/position" },
"rotation": { "$ref": "#/$defs/rotation" } "rotation": { "$ref": "#/$defs/rotation" }

View File

@ -43,11 +43,12 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
baseRoundness?: number baseRoundness?: number
} }
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor'> export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost'>
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType> export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType> export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
export type AvatarBodyNodeDefinition = { export type AvatarBodyNodeDefinition = {
layer?: 'auto' | 'front' | 'back'
surface: BodyNodeSurfaceDefinition surface: BodyNodeSurfaceDefinition
position: [number, number, number] position: [number, number, number]
rotation: [number, number, number] rotation: [number, number, number]

View File

@ -1117,6 +1117,87 @@ const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => {
return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)])) return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)]))
} }
const projectedGhostPath = (pose: AvatarPose, surface: SurfaceConfig) => {
const halfWidth = surface.width / 2
const halfHeight = surface.height / 2
const notchCenter = surface.width / 6
const notchHalfWidth = surface.width * 0.018
const sideStartY = -halfHeight * 0.08
const lobeStartY = halfHeight * 0.5
const notchTopY = halfHeight * 0.4
const bottomY = halfHeight
const point = (x: number, y: number) => projectLocalPoint(pose, [x, y, 0])
const format = ([x, y]: Point3) => `${x.toFixed(2)} ${y.toFixed(2)}`
const move = (x: number, y: number) => `M${format(point(x, y))}`
const line = (x: number, y: number) => `L${format(point(x, y))}`
const cubicTo = (
firstX: number,
firstY: number,
secondX: number,
secondY: number,
endX: number,
endY: number
) =>
`C${format(point(firstX, firstY))} ${format(point(secondX, secondY))} ${format(point(endX, endY))}`
return [
move(-halfWidth, sideStartY),
cubicTo(-halfWidth, -halfHeight * 0.62, -halfWidth * 0.56, -halfHeight, 0, -halfHeight),
cubicTo(halfWidth * 0.56, -halfHeight, halfWidth, -halfHeight * 0.62, halfWidth, sideStartY),
line(halfWidth, lobeStartY),
cubicTo(halfWidth, bottomY * 0.82, halfWidth * 0.82, bottomY, halfWidth * 0.66, bottomY),
cubicTo(
halfWidth * 0.49,
bottomY,
notchCenter + notchHalfWidth,
bottomY * 0.82,
notchCenter + notchHalfWidth,
lobeStartY
),
line(notchCenter + notchHalfWidth, notchTopY),
cubicTo(
notchCenter + notchHalfWidth,
notchTopY - 6,
notchCenter - notchHalfWidth,
notchTopY - 6,
notchCenter - notchHalfWidth,
notchTopY
),
line(notchCenter - notchHalfWidth, lobeStartY),
cubicTo(notchCenter - notchHalfWidth, bottomY * 0.82, halfWidth * 0.17, bottomY, 0, bottomY),
cubicTo(
-halfWidth * 0.17,
bottomY,
-notchCenter + notchHalfWidth,
bottomY * 0.82,
-notchCenter + notchHalfWidth,
lobeStartY
),
line(-notchCenter + notchHalfWidth, notchTopY),
cubicTo(
-notchCenter + notchHalfWidth,
notchTopY - 6,
-notchCenter - notchHalfWidth,
notchTopY - 6,
-notchCenter - notchHalfWidth,
notchTopY
),
line(-notchCenter - notchHalfWidth, lobeStartY),
cubicTo(
-notchCenter - notchHalfWidth,
bottomY * 0.82,
-halfWidth * 0.49,
bottomY,
-halfWidth * 0.66,
bottomY
),
cubicTo(-halfWidth * 0.82, bottomY, -halfWidth, bottomY * 0.82, -halfWidth, lobeStartY),
line(-halfWidth, sideStartY),
'Z',
].join('')
}
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => { const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'sphere' || surface.type === 'mickey') { if (surface.type === 'sphere' || surface.type === 'mickey') {
const exactPath = projectedEllipsoidPath(pose, surface) const exactPath = projectedEllipsoidPath(pose, surface)
@ -1133,6 +1214,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'cone') return projectedConePath(pose, surface) if (surface.type === 'cone') return projectedConePath(pose, surface)
if (surface.type === 'cube') return projectedCubePath(pose, surface) if (surface.type === 'cube') return projectedCubePath(pose, surface)
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface) if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
const key = surfaceCacheKey(surface) const key = surfaceCacheKey(surface)
let localSamples = headSamplesCache.get(key) let localSamples = headSamplesCache.get(key)
@ -1222,7 +1304,10 @@ const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
id: node.id, id: node.id,
path: accessoryPath(pose, node), path: accessoryPath(pose, node),
depth, depth,
front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO, front:
node.layer === 'front' ||
(node.layer !== 'back' &&
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
} }
}) })
.sort((left, right) => left.depth - right.depth) .sort((left, right) => left.depth - right.depth)

View File

@ -1,7 +1,7 @@
import type { Point3 } from './geometry' import type { Point3 } from './geometry'
export type SurfaceType = export type SurfaceType =
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' | 'ghost'
export type SurfaceConfig = { export type SurfaceConfig = {
type: SurfaceType type: SurfaceType
@ -44,6 +44,7 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
baseRoundness: 0.45, baseRoundness: 0.45,
}, },
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 }, diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
ghost: { type: 'ghost', width: 250, height: 245, depth: 190, roundness: 1 },
} }
export const surfaceLabels: Record<SurfaceType, string> = { export const surfaceLabels: Record<SurfaceType, string> = {
@ -55,6 +56,7 @@ export const surfaceLabels: Record<SurfaceType, string> = {
cylinder: 'Cylindre', cylinder: 'Cylindre',
cone: 'Cône', cone: 'Cône',
diamond: 'Diamant', diamond: 'Diamant',
ghost: 'Ghost',
} }
const signedPower = (value: number, exponent: number) => const signedPower = (value: number, exponent: number) =>
@ -336,6 +338,7 @@ export const surfacePointAt = (
case 'diamond': case 'diamond':
return diamond(config, longitude, latitude) return diamond(config, longitude, latitude)
case 'capsule': case 'capsule':
case 'ghost':
return capsule(config, longitude, latitude) return capsule(config, longitude, latitude)
case 'cone': { case 'cone': {
const progress = (latitude + Math.PI / 2) / Math.PI const progress = (latitude + Math.PI / 2) / Math.PI
@ -534,7 +537,8 @@ export const surfaceFrontSampleAt = (
case 'cube': case 'cube':
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal) return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
case 'capsule': { case 'capsule':
case 'ghost': {
const capRadiusY = Math.min(radiusX, radiusY) const capRadiusY = Math.min(radiusX, radiusY)
const straightHalf = Math.max(0, radiusY - capRadiusY) const straightHalf = Math.max(0, radiusY - capRadiusY)
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y

View File

@ -4,6 +4,7 @@ import { surfacePresets } from './surfaces'
import type { Expression } from './geometry' import type { Expression } from './geometry'
import { isBodyMotion, isEyeMotion } from './ambientMotion' import { isBodyMotion, isEyeMotion } from './ambientMotion'
import { import {
createInitialSequences,
normalizeSequencesForExpressions, normalizeSequencesForExpressions,
parseSequences, parseSequences,
type AvatarSequence, type AvatarSequence,
@ -45,7 +46,25 @@ export type AvatarEyeDefaults = Pick<
| 'leftAngle' | 'leftAngle'
| 'rightAngle' | 'rightAngle'
> >
export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' } export const deslopAccentColors = {
red: '#ff5558',
orange: '#ff9d45',
yellow: '#ffda1a',
green: '#59e075',
mint: '#1adec9',
teal: '#1ad7e3',
cyan: '#50d7fe',
blue: '#1a9cff',
indigo: '#7c89ff',
purple: '#df48f3',
pink: '#ff4b6f',
brown: '#be9675',
} as const
export const defaultAvatarColors: AvatarColors = {
body: deslopAccentColors.indigo,
eyes: deslopAccentColors.indigo,
}
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' } export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
export const defaultPixelRenderStyle: PixelRenderStyle = { export const defaultPixelRenderStyle: PixelRenderStyle = {
type: 'pixel', type: 'pixel',
@ -79,6 +98,336 @@ const parseColors = (value: unknown): AvatarColors => {
} }
} }
const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: AvatarColors }> = {
'primitive-cube': {
from: { body: deslopAccentColors.orange, eyes: deslopAccentColors.orange },
to: { body: deslopAccentColors.brown, eyes: deslopAccentColors.brown },
},
'primitive-ghost': {
from: { body: deslopAccentColors.blue, eyes: deslopAccentColors.blue },
to: { body: deslopAccentColors.orange, eyes: deslopAccentColors.orange },
},
strobi: {
from: { body: '#5b7fe5', eyes: '#111316' },
to: { body: deslopAccentColors.indigo, eyes: deslopAccentColors.cyan },
},
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed': {
from: { body: '#e6855c', eyes: '#ffffff' },
to: { body: deslopAccentColors.orange, eyes: deslopAccentColors.yellow },
},
'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8': {
from: { body: '#ffcf24', eyes: '#000000' },
to: { body: deslopAccentColors.yellow, eyes: deslopAccentColors.orange },
},
'avatar-1786600724626': {
from: { body: '#55b6c3', eyes: '#111316' },
to: { body: deslopAccentColors.teal, eyes: deslopAccentColors.mint },
},
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd': {
from: { body: '#000000', eyes: '#ffffff' },
to: { body: deslopAccentColors.purple, eyes: deslopAccentColors.pink },
},
'avatar-1b2ee9c6-a6c5-4054-87e7-fec24f285269': {
from: { body: '#e69a5c', eyes: '#111316' },
to: { body: deslopAccentColors.brown, eyes: deslopAccentColors.orange },
},
'avatar-b6362e59-81a3-4334-a399-a721b23cf553': {
from: { body: '#ffc2e9', eyes: '#3e4e65' },
to: { body: deslopAccentColors.pink, eyes: deslopAccentColors.purple },
},
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0': {
from: { body: '#c9cbcf', eyes: '#111316' },
to: { body: deslopAccentColors.cyan, eyes: deslopAccentColors.blue },
},
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b': {
from: { body: '#e65c5c', eyes: '#111316' },
to: { body: deslopAccentColors.red, eyes: deslopAccentColors.orange },
},
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e': {
from: { body: '#dbe2f5', eyes: '#111316' },
to: { body: deslopAccentColors.blue, eyes: deslopAccentColors.indigo },
},
}
export const simpleBundledAvatarIds = [
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
'avatar-1786600724626',
'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8',
'primitive-sphere',
'primitive-cube',
'primitive-capsule',
'primitive-cylinder',
'primitive-cone',
'primitive-diamond',
'primitive-ghost',
'primitive-willy',
] as const
const simpleBundledAvatarOrder = new Map(simpleBundledAvatarIds.map((id, index) => [id, index]))
const retiredBundledAvatarIds = new Set([
'strobi',
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed',
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd',
'avatar-1b2ee9c6-a6c5-4054-87e7-fec24f285269',
'avatar-b6362e59-81a3-4334-a399-a721b23cf553',
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0',
])
const primitiveBundledAvatarSpecs = [
['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1],
['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78],
['primitive-capsule', 'Capsule', 'capsule', deslopAccentColors.green, 1],
['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82],
['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82],
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 1],
] as const
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
expressions: (behavior?.expressions ?? initialExpressions).map(expression => {
const fixedColorExpression = { ...expression }
delete fixedColorExpression.bodyColor
delete fixedColorExpression.eyeColor
return fixedColorExpression
}),
sequences: (behavior?.sequences ?? createInitialSequences()).map(sequence => ({
...sequence,
steps: sequence.steps.map(step => ({ ...step })),
blink: { ...sequence.blink },
})),
})
const primitiveBundledAvatars: StudioAvatar[] = [
...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({
id,
name,
body: {
primary: {
...surfacePresets[surface],
width: surfacePresets[surface].width * scale,
height: surfacePresets[surface].height * scale,
depth: surfacePresets[surface].depth * scale,
},
nodes: [],
},
colors: { body: color, eyes: color },
eyes:
surface === 'ghost'
? {
...defaultAvatarEyes,
positionYLeft: defaultAvatarEyes.positionYLeft - 20,
positionYRight: defaultAvatarEyes.positionYRight - 20,
}
: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}),
})),
{
id: 'primitive-willy',
name: 'Willy',
body: {
primary: {
...surfacePresets.capsule,
width: 122,
height: 190,
depth: 115,
},
nodes: [
{
id: 'willy-left-sphere',
name: 'Left cap',
surface: {
...surfacePresets.cone,
width: 112,
height: 58,
depth: 100,
morphRoundness: 1.2,
tipRoundness: 2,
baseRoundness: 2,
},
position: [-47, 95, -22],
rotation: [0, 0, 0],
},
{
id: 'willy-right-sphere',
name: 'Right cap',
surface: {
...surfacePresets.cone,
width: 112,
height: 58,
depth: 100,
morphRoundness: 1.2,
tipRoundness: 2,
baseRoundness: 2,
},
position: [47, 95, -22],
rotation: [0, 0, 0],
},
{
id: 'willy-cap',
name: 'Rounded cap',
layer: 'front',
surface: {
...surfacePresets.cone,
width: 178,
height: 86,
depth: 156,
morphRoundness: 1.2,
tipRoundness: 2,
baseRoundness: 2,
},
position: [0, -94, 0],
rotation: [0, 0, 0],
},
],
},
colors: { body: deslopAccentColors.red, eyes: deslopAccentColors.red },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
},
]
const originalSimpleBundledAvatarIds = new Set<string>(simpleBundledAvatarIds.slice(0, 4))
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
...avatar,
body: {
primary: { ...avatar.body.primary },
nodes: avatar.body.nodes.map(node => ({
...node,
surface: { ...node.surface },
position: [...node.position],
rotation: [...node.rotation],
})),
},
colors: { ...avatar.colors },
eyes: { ...avatar.eyes },
renderStyle: { ...avatar.renderStyle },
...(avatar.behavior ? { behavior: mementoBehavior(avatar.behavior) } : {}),
})
export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLibrary => {
if (!library.avatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) return library
const resizedAvatars = library.avatars.map(avatar => {
const bundled = primitiveBundledAvatars.find(candidate => candidate.id === avatar.id)
if (!bundled) return avatar
const migratedNodes = avatar.body.nodes.map(node => {
const bundledNode = bundled.body.nodes.find(candidate => candidate.id === node.id)
if (!bundledNode || avatar.id !== 'primitive-willy') return node
const isLegacyLowerSphere =
(node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') &&
node.surface.type === 'sphere' &&
node.surface.width === 108 &&
node.surface.height === 96 &&
node.surface.depth === 100
const isLegacyTopCap =
node.id === 'willy-cap' &&
((node.surface.width === 150 &&
node.surface.height === 72 &&
node.surface.depth === 135 &&
node.position[2] === 12) ||
(node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148))
if (!isLegacyLowerSphere && !isLegacyTopCap) return node
return {
...bundledNode,
surface: { ...bundledNode.surface },
position: [...bundledNode.position] as typeof bundledNode.position,
rotation: [...bundledNode.rotation] as typeof bundledNode.rotation,
}
})
const existingNodeIds = new Set(migratedNodes.map(node => node.id))
const missingBundledNodes = bundled.body.nodes.filter(node => !existingNodeIds.has(node.id))
const avatarWithCurrentNodes = missingBundledNodes.length
? {
...avatar,
body: {
...avatar.body,
nodes: [
...migratedNodes,
...missingBundledNodes.map(node => ({
...node,
surface: { ...node.surface },
position: [...node.position] as typeof node.position,
rotation: [...node.rotation] as typeof node.rotation,
})),
],
},
}
: migratedNodes.some((node, index) => node !== avatar.body.nodes[index])
? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } }
: avatar
const avatarWithCurrentName =
avatarWithCurrentNodes.id === 'primitive-ghost' && avatarWithCurrentNodes.name === 'Ghost'
? { ...avatarWithCurrentNodes, name: bundled.name }
: avatarWithCurrentNodes
const avatarWithCurrentEyes =
avatarWithCurrentName.id === 'primitive-ghost' &&
[defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes(
avatarWithCurrentName.eyes.positionYLeft
) &&
[defaultAvatarEyes.positionYRight, defaultAvatarEyes.positionYRight - 12].includes(
avatarWithCurrentName.eyes.positionYRight
)
? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } }
: avatarWithCurrentName
const avatarWithFixedColorBehavior =
avatarWithCurrentEyes.id === 'primitive-ghost'
? { ...avatarWithCurrentEyes, behavior: mementoBehavior(avatarWithCurrentEyes.behavior) }
: avatarWithCurrentEyes
const original = surfacePresets[bundled.body.primary.type]
const primary = avatarWithFixedColorBehavior.body.primary
const stillUsesOriginalSize =
primary.type === original.type &&
primary.width === original.width &&
primary.height === original.height &&
primary.depth === original.depth
const stillUsesPreviousWillyBody =
avatar.id === 'primitive-willy' &&
primary.type === 'capsule' &&
primary.width === 122 &&
primary.height === 210 &&
primary.depth === 115
if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody) return avatarWithFixedColorBehavior
return {
...avatarWithFixedColorBehavior,
body: { ...avatarWithFixedColorBehavior.body, primary: { ...bundled.body.primary } },
}
})
const existingIds = new Set(resizedAvatars.map(avatar => avatar.id))
const avatars = [
...resizedAvatars,
...primitiveBundledAvatars
.filter(avatar => !existingIds.has(avatar.id))
.map(clonePrimitiveBundledAvatar),
].sort((left, right) => {
const leftOrder = simpleBundledAvatarOrder.get(
left.id as (typeof simpleBundledAvatarIds)[number]
)
const rightOrder = simpleBundledAvatarOrder.get(
right.id as (typeof simpleBundledAvatarIds)[number]
)
if (leftOrder === undefined && rightOrder === undefined) return 0
if (leftOrder === undefined) return 1
if (rightOrder === undefined) return -1
return leftOrder - rightOrder
})
return { ...library, avatars }
}
export const migrateBundledAvatarColors = (id: string, colors: AvatarColors): AvatarColors => {
const migration = bundledAvatarColorMigrations[id]
if (
!migration ||
colors.body.toLowerCase() !== migration.from.body.toLowerCase() ||
colors.eyes.toLowerCase() !== migration.from.eyes.toLowerCase()
) {
return colors
}
return { ...migration.to }
}
const finiteBounded = (value: unknown, fallback: number, min: number, max: number) => const finiteBounded = (value: unknown, fallback: number, min: number, max: number) =>
typeof value === 'number' && Number.isFinite(value) typeof value === 'number' && Number.isFinite(value)
? Math.min(max, Math.max(min, value)) ? Math.min(max, Math.max(min, value))
@ -263,26 +612,45 @@ export const parseAvatarLibrary = (
const parsed = value as Partial<AvatarLibrary> | null const parsed = value as Partial<AvatarLibrary> | null
if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback
const seenIds = new Set<string>() const seenIds = new Set<string>()
const avatars = parsed.avatars const parsedAvatars = parsed.avatars
.filter(avatar => { .filter(avatar => {
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string') if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
return false return false
if (retiredBundledAvatarIds.has(avatar.id)) return false
if (seenIds.has(avatar.id)) return false if (seenIds.has(avatar.id)) return false
seenIds.add(avatar.id) seenIds.add(avatar.id)
return true return true
}) })
.map(avatar => { .map(avatar => {
const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior) const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior)
const migratedColors = migrateBundledAvatarColors(avatar.id, parseColors(avatar.colors))
const colors = simpleBundledAvatarOrder.has(
avatar.id as (typeof simpleBundledAvatarIds)[number]
)
? { body: migratedColors.body, eyes: migratedColors.body }
: migratedColors
return { return {
id: avatar.id, id: avatar.id,
name: avatar.name, name: avatar.name,
body: parseAvatarBody(avatar.body, surfacePresets.sphere), body: parseAvatarBody(avatar.body, surfacePresets.sphere),
colors: parseColors(avatar.colors), colors,
eyes: parseAvatarEyeDefaults(avatar.eyes), eyes: parseAvatarEyeDefaults(avatar.eyes),
renderStyle: parseAvatarRenderStyle(avatar.renderStyle), renderStyle: parseAvatarRenderStyle(avatar.renderStyle),
...(behavior ? { behavior } : {}), ...(behavior ? { behavior } : {}),
} }
}) })
const avatars = parsedAvatars.sort((left, right) => {
const leftOrder = simpleBundledAvatarOrder.get(
left.id as (typeof simpleBundledAvatarIds)[number]
)
const rightOrder = simpleBundledAvatarOrder.get(
right.id as (typeof simpleBundledAvatarIds)[number]
)
if (leftOrder === undefined && rightOrder === undefined) return 0
if (leftOrder === undefined) return 1
if (rightOrder === undefined) return -1
return leftOrder - rightOrder
})
if (!avatars.length) return fallback if (!avatars.length) return fallback
const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId) const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId)
? parsed.activeAvatarId! ? parsed.activeAvatarId!

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import { createAvatar } from '@/features/avatar/avatars' import { createAvatar, defaultAvatarEyes } from '@/features/avatar/avatars'
import { createInitialSequences } from '@/features/animation/sequences' import { createInitialSequences } from '@/features/animation/sequences'
import { initialExpressions } from '@/features/avatar/presets' import { initialExpressions } from '@/features/avatar/presets'
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition' import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
@ -32,11 +32,64 @@ describe('Studio document', () => {
it('loads the bundled Studio snapshot when no local project exists', () => { it('loads the bundled Studio snapshot when no local project exists', () => {
const document = loadStudioDocument(storage()) const document = loadStudioDocument(storage())
expect(document.library.avatars).toHaveLength(10) expect(document.library.avatars).toHaveLength(12)
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id) expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
expect(document.library.avatars[0].name).toBe('Strobi') expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
'Onee',
'Cubee',
'Nova',
'Citrus',
'Sphere',
'Cube',
'Capsule',
'Cylinder',
'Cone',
'Diamond',
'Memento',
'Willy',
])
expect(
document.library.avatars.every(avatar => avatar.colors.body === avatar.colors.eyes)
).toBe(true)
expect(document.library.avatars.slice(4).map(avatar => avatar.body.primary.type)).toEqual([
'sphere',
'cube',
'capsule',
'cylinder',
'cone',
'diamond',
'ghost',
'capsule',
])
expect(document.library.avatars.at(-1)?.body.nodes).toHaveLength(3)
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
expect(
document.library.avatars
.filter(avatar => avatar.id !== 'primitive-ghost')
.every(avatar => avatar.colors.body !== '#ff9d45')
).toBe(true)
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20)
expect(
memento?.behavior?.expressions.every(
expression => !expression.bodyColor && !expression.eyeColor
)
).toBe(true)
expect(document.expressions).toHaveLength(27) expect(document.expressions).toHaveLength(27)
expect(document.sequences).toHaveLength(23) expect(document.sequences).toHaveLength(23)
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
expect(
document.sequences
.find(sequence => sequence.id === 'angry')
?.steps.map(step => step.expressionId)
).toEqual(['expression-07', 'expression-3d2bed26-f97c-477d-922f-77600cb10e92'])
expect(document.sequences.find(sequence => sequence.id === 'scared')?.steps).toHaveLength(2)
expect(
document.sequences
.find(sequence => sequence.id === 'scared')
?.steps.map(step => step.expressionId)
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true) expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true) expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
expect(document.playback).toEqual({ stateId: 'proud', playing: true }) expect(document.playback).toEqual({ stateId: 'proud', playing: true })
@ -55,6 +108,81 @@ describe('Studio document', () => {
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument) expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
}) })
it('restores standalone built-in animations from the temporary idle pairs', () => {
const fallback = loadStudioDocument(storage())
const pairedDocument: StudioDocument = {
...fallback,
sequences: fallback.sequences.map(sequence =>
sequence.id === 'idle'
? sequence
: {
...sequence,
id: `idle-${sequence.id}`,
semanticKey: `idle-${sequence.id}`,
name: `idle + ${sequence.name}`,
}
),
playback: { stateId: 'idle-proud', playing: true },
}
const restored = loadStudioDocument(storage(JSON.stringify(pairedDocument)))
expect(restored.sequences.map(sequence => sequence.id)).toEqual(
fallback.sequences.map(sequence => sequence.id)
)
expect(restored.playback).toEqual({ stateId: 'proud', playing: true })
})
it('removes retired bundled avatars from a locally saved project', () => {
const fallback = loadStudioDocument(storage())
const retired = createAvatar('Freddy')
retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed'
const localDocument: StudioDocument = {
...fallback,
library: {
activeAvatarId: retired.id,
avatars: [retired, ...fallback.library.avatars],
},
}
const document = loadStudioDocument(storage(JSON.stringify(localDocument)))
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
'Onee',
'Cubee',
'Nova',
'Citrus',
'Sphere',
'Cube',
'Capsule',
'Cylinder',
'Cone',
'Diamond',
'Memento',
'Willy',
])
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
})
it('renames the bundled ghost character in an existing local project', () => {
const fallback = loadStudioDocument(storage())
const legacy = structuredClone(fallback)
const character = legacy.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
if (!character) throw new Error('Bundled ghost character not found')
character.name = 'Ghost'
character.eyes.positionYLeft = defaultAvatarEyes.positionYLeft
character.eyes.positionYRight = defaultAvatarEyes.positionYRight
const document = loadStudioDocument(storage(JSON.stringify(legacy)))
expect(document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.name).toBe(
'Memento'
)
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.eyes.positionYLeft
).toBe(defaultAvatarEyes.positionYLeft - 20)
})
it('restores bundled semantic keys in a legacy local project', () => { it('restores bundled semantic keys in a legacy local project', () => {
const fallback = loadStudioDocument(storage()) const fallback = loadStudioDocument(storage())
const legacy = structuredClone(fallback) const legacy = structuredClone(fallback)