bible-strong-avatar-lab/packages/avatar-core/src/avatarDefinition.ts
PhilippeMaillot 767f5191e9 feat(studio): independent body-part colors and double-click to edit body
- Add an optional per-node color so each shape added to the body can be
  colored independently from the body color.
- Surface the color picker in the body construction panel, with a reset to
  inherit the body color.
- Paint secondary primitive fills as motion values so they stay in sync with
  the geometry while the camera rotates, across avatar switches and on the
  exported snapshot / runtime renderers.
- Preserve the node color through export and import of .avatar.json.
- Double-click the 3D model to enter body construction editing (when not
  already editing).
2026-09-08 16:53:42 +02:00

490 lines
16 KiB
TypeScript

import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js'
import avatarDefinitionSchema from './avatarDefinition.schema.json'
import type { SurfaceType } from './surfaces'
export const AVATAR_DEFINITION_MAX_BYTES = 262_144
export const AVATAR_DEFINITION_MAX_DEPTH = 32
const MAX_JSON_STRING_LENGTH = 512
export const SEMANTIC_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
export type SemanticKeyIssueCode =
'missing_semantic_key' | 'invalid_semantic_key' | 'reserved_semantic_key'
export const getSemanticKeyIssue = (
semanticKey: string | undefined,
kind: 'expression' | 'animation'
): SemanticKeyIssueCode | undefined => {
if (!semanticKey) return 'missing_semantic_key'
if (!SEMANTIC_KEY_PATTERN.test(semanticKey) || semanticKey.length > 64) {
return 'invalid_semantic_key'
}
if (kind === 'expression' && semanticKey === 'neutral') return 'reserved_semantic_key'
return undefined
}
export type SemanticKey = string
export type ExpressionKey = SemanticKey
export type AnimationKey = SemanticKey
export type HexColor = `#${string}`
export type AvatarColorsDefinition = {
body: HexColor
eyes: HexColor
}
export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
type: TType
width: number
height: number
depth: number
roundness: number
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor'>
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
export type AvatarBodyNodeDefinition = {
surface: BodyNodeSurfaceDefinition
position: [number, number, number]
rotation: [number, number, number]
/** Optional independent color; when omitted the node inherits the body color. */
color?: HexColor
}
export type AvatarBodyDefinition = {
primary: PrimarySurfaceDefinition
nodes: AvatarBodyNodeDefinition[]
}
export type AvatarExpressionDefinition = {
head: { x: number; y: number; z: number }
eyes: {
left: { width: number; height: number; x: number; y: number; angle: number }
right: { width: number; height: number; x: number; y: number; angle: number }
spacing: number
}
perspective: number
motion: {
eyes: 'none' | 'microSaccades' | 'shake'
body: 'none' | 'slowDrift' | 'shake'
}
colors?: Partial<AvatarColorsDefinition>
}
export type AvatarAnimationStepDefinition = {
expression: ExpressionKey
holdMs: number
transitionMs: number
transition: 'spring' | 'smooth' | 'snappy'
}
export type AvatarAnimationDefinition = {
playbackMode: 'loop' | 'once' | 'pingPong'
steps: AvatarAnimationStepDefinition[]
blink: {
enabled: boolean
initialDelayMs: number
minIntervalMs: number
maxIntervalMs: number
durationMs: number
}
metadata?: {
label?: string
description?: string
group?: string
}
}
export type AvatarDefinition = {
schema: 'bible-strong/avatar-definition'
schemaVersion: 1
name?: string
body: AvatarBodyDefinition
colors: AvatarColorsDefinition
expressions: Record<ExpressionKey, AvatarExpressionDefinition>
expressionOrder: ExpressionKey[]
animations: Record<AnimationKey, AvatarAnimationDefinition>
animationOrder: AnimationKey[]
/** @deprecated Accepted for pre-release JSON compatibility; it no longer adds animations. */
standardAnimationSet?: 1
}
export type AvatarDefinitionError = {
path: string
code: string
message: string
}
export type ValidationResult<T> =
{ ok: true; value: Readonly<T> } | { ok: false; errors: readonly AvatarDefinitionError[] }
const ajv = new Ajv2020({ allErrors: true, strict: true })
const validateSchema = ajv.compile<AvatarDefinition>(avatarDefinitionSchema)
const escapePointer = (value: string) => value.replaceAll('~', '~0').replaceAll('/', '~1')
const childPointer = (path: string, segment: string | number) =>
`${path}/${escapePointer(String(segment))}`
const stringLength = (value: string) => [...value].length
const stringLimitAt = (path: string) => {
if (
/^\/(?:expressionOrder|animationOrder)\/\d+$/.test(path) ||
/^\/animations\/[^/]+\/steps\/\d+\/expression$/.test(path) ||
/^\/animations\/[^/]+\/metadata\/group$/.test(path)
) {
return 64
}
if (path === '/name' || /^\/animations\/[^/]+\/metadata\/label$/.test(path)) return 120
return MAX_JSON_STRING_LENGTH
}
const schemaErrorPath = (error: ErrorObject) => {
const propertyName = (error as ErrorObject & { propertyName?: string }).propertyName
if (propertyName !== undefined) return childPointer(error.instancePath, propertyName)
if (error.keyword === 'required') {
return childPointer(error.instancePath, String(error.params.missingProperty))
}
if (error.keyword === 'additionalProperties') {
return childPointer(error.instancePath, String(error.params.additionalProperty))
}
if (error.keyword === 'propertyNames') {
return childPointer(error.instancePath, String(error.params.propertyName))
}
return error.instancePath
}
const schemaErrorCode = (error: ErrorObject) => {
if (error.instancePath === '/schemaVersion' && error.keyword === 'const') {
return 'unsupported_version'
}
return error.keyword
}
const schemaErrors = (errors: ErrorObject[] | null | undefined): AvatarDefinitionError[] =>
(errors ?? []).map(error => ({
path: schemaErrorPath(error),
code: schemaErrorCode(error),
message: error.message ?? 'Invalid avatar definition',
}))
const inspectMaterializedValue = (value: unknown): AvatarDefinitionError[] => {
const errors: AvatarDefinitionError[] = []
const ancestors = new WeakSet<object>()
const visit = (current: unknown, path: string) => {
if (typeof current === 'number' && !Number.isFinite(current)) {
errors.push({ path, code: 'non_finite_number', message: 'Number must be finite' })
return
}
if (current === null || typeof current !== 'object') return
if (ancestors.has(current)) {
errors.push({ path, code: 'cyclic_value', message: 'Avatar definition must not be cyclic' })
return
}
ancestors.add(current)
if (!Array.isArray(current)) {
const prototype = Object.getPrototypeOf(current)
if (prototype !== Object.prototype && prototype !== null) {
errors.push({ path, code: 'non_plain_object', message: 'Expected a plain object' })
ancestors.delete(current)
return
}
}
Object.entries(current).forEach(([key, child]) => visit(child, childPointer(path, key)))
ancestors.delete(current)
}
visit(value, '')
return errors
}
const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] => {
const errors: AvatarDefinitionError[] = []
const expressionKeys = Object.keys(definition.expressions)
const animationKeys = Object.keys(definition.animations)
const checkCompleteOrder = (
order: string[],
keys: string[],
path: '/expressionOrder' | '/animationOrder'
) => {
const ordered = new Set(order)
const knownKeys = new Set(keys)
keys.forEach(key => {
if (!ordered.has(key)) {
errors.push({
path,
code: 'incomplete_order',
message: `Order is missing key '${key}'`,
})
}
})
order.forEach((key, index) => {
if (!knownKeys.has(key)) {
errors.push({
path: childPointer(path, index),
code: 'unknown_order_key',
message: `Unknown key '${key}'`,
})
}
})
}
checkCompleteOrder(definition.expressionOrder, expressionKeys, '/expressionOrder')
checkCompleteOrder(definition.animationOrder, animationKeys, '/animationOrder')
if (definition.expressionOrder[0] !== 'neutral') {
errors.push({
path: '/expressionOrder/0',
code: 'neutral_not_first',
message: "'neutral' must be the first expression-order entry",
})
}
Object.entries(definition.animations).forEach(([animationKey, animation]) => {
if (animation.blink.minIntervalMs > animation.blink.maxIntervalMs) {
errors.push({
path: `/animations/${escapePointer(animationKey)}/blink/minIntervalMs`,
code: 'invalid_interval_range',
message: 'minIntervalMs must be less than or equal to maxIntervalMs',
})
}
animation.steps.forEach((step, index) => {
if (!(step.expression in definition.expressions)) {
errors.push({
path: `/animations/${escapePointer(animationKey)}/steps/${index}/expression`,
code: 'unknown_expression',
message: `Unknown expression '${step.expression}'`,
})
}
})
})
return errors
}
const cloneAndFreeze = <T>(value: T): Readonly<T> => {
if (value === null || typeof value !== 'object') return value
const clone: unknown = Array.isArray(value)
? value.map(item => cloneAndFreeze(item))
: Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneAndFreeze(item)]))
return Object.freeze(clone) as Readonly<T>
}
export const validateAvatarDefinition = (value: unknown): ValidationResult<AvatarDefinition> => {
const structuralErrors = inspectMaterializedValue(value)
if (structuralErrors.length) return { ok: false, errors: structuralErrors }
if (!validateSchema(value)) return { ok: false, errors: schemaErrors(validateSchema.errors) }
const errors = semanticErrors(value)
return errors.length ? { ok: false, errors } : { ok: true, value: cloneAndFreeze(value) }
}
class JsonTextError extends Error {
constructor(
readonly path: string,
readonly code: string,
message: string
) {
super(message)
}
}
class BoundedJsonParser {
private index = 0
constructor(private readonly source: string) {}
parse(): unknown {
this.skipWhitespace()
const value = this.parseValue('', 1)
this.skipWhitespace()
if (this.index !== this.source.length)
this.fail('', 'invalid_json', 'Unexpected trailing input')
return value
}
private fail(path: string, code: string, message: string): never {
throw new JsonTextError(path, code, `${message} at character ${this.index}`)
}
private skipWhitespace() {
while (
(this.source[this.index] === ' ' ||
this.source[this.index] === '\n' ||
this.source[this.index] === '\r' ||
this.source[this.index] === '\t') &&
this.index < this.source.length
) {
this.index += 1
}
}
private parseValue(path: string, depth: number): unknown {
this.skipWhitespace()
const character = this.source[this.index]
if (character === '{' || character === '[') {
if (depth > AVATAR_DEFINITION_MAX_DEPTH) {
this.fail(path, 'max_depth', `JSON nesting depth exceeds ${AVATAR_DEFINITION_MAX_DEPTH}`)
}
return character === '{' ? this.parseObject(path, depth) : this.parseArray(path, depth)
}
if (character === '"') return this.parseString(path)
if (character === '-' || (character >= '0' && character <= '9')) return this.parseNumber(path)
if (this.source.startsWith('true', this.index)) return this.parseLiteral('true', true)
if (this.source.startsWith('false', this.index)) return this.parseLiteral('false', false)
if (this.source.startsWith('null', this.index)) return this.parseLiteral('null', null)
this.fail(path, 'invalid_json', 'Expected a JSON value')
}
private parseObject(path: string, depth: number) {
this.index += 1
this.skipWhitespace()
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>
const keys = new Set<string>()
if (this.source[this.index] === '}') {
this.index += 1
return result
}
while (this.index < this.source.length) {
if (this.source[this.index] !== '"') this.fail(path, 'invalid_json', 'Expected an object key')
const key = this.parseString(path)
const keyPath = childPointer(path, key)
if ((path === '/expressions' || path === '/animations') && stringLength(key) > 64) {
this.fail(keyPath, 'string_too_long', 'Semantic key exceeds 64 characters')
}
if (keys.has(key)) this.fail(keyPath, 'duplicate_key', `Duplicate object member '${key}'`)
keys.add(key)
this.skipWhitespace()
if (this.source[this.index] !== ':') this.fail(keyPath, 'invalid_json', "Expected ':'")
this.index += 1
result[key] = this.parseValue(keyPath, depth + 1)
this.skipWhitespace()
const separator = this.source[this.index]
if (separator === '}') {
this.index += 1
return result
}
if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or '}'")
this.index += 1
this.skipWhitespace()
}
this.fail(path, 'invalid_json', 'Unterminated object')
}
private parseArray(path: string, depth: number) {
this.index += 1
this.skipWhitespace()
const result: unknown[] = []
if (this.source[this.index] === ']') {
this.index += 1
return result
}
while (this.index < this.source.length) {
result.push(this.parseValue(childPointer(path, result.length), depth + 1))
this.skipWhitespace()
const separator = this.source[this.index]
if (separator === ']') {
this.index += 1
return result
}
if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or ']'")
this.index += 1
}
this.fail(path, 'invalid_json', 'Unterminated array')
}
private parseString(path: string): string {
const start = this.index
this.index += 1
let escaped = false
while (this.index < this.source.length) {
const character = this.source[this.index]
if (!escaped && character === '"') {
this.index += 1
let value: string
try {
value = JSON.parse(this.source.slice(start, this.index)) as string
} catch {
this.fail(path, 'invalid_json', 'Invalid JSON string')
}
const limit = stringLimitAt(path)
if (stringLength(value) > limit) {
this.fail(path, 'string_too_long', `JSON string exceeds ${limit} characters`)
}
return value
}
if (!escaped && character.charCodeAt(0) < 0x20) {
this.fail(path, 'invalid_json', 'Unescaped control character')
}
if (!escaped && character === '\\') escaped = true
else escaped = false
this.index += 1
if (this.index - start > MAX_JSON_STRING_LENGTH * 12 + 2) {
this.fail(
path,
'string_too_long',
`JSON string exceeds ${MAX_JSON_STRING_LENGTH} characters`
)
}
}
this.fail(path, 'invalid_json', 'Unterminated string')
}
private parseNumber(path: string): number {
const remaining = this.source.slice(this.index)
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(remaining)
if (!match) this.fail(path, 'invalid_json', 'Invalid number')
this.index += match[0].length
const number = Number(match[0])
if (!Number.isFinite(number)) this.fail(path, 'non_finite_number', 'Number must be finite')
return number
}
private parseLiteral<T>(source: string, value: T): T {
this.index += source.length
return value
}
}
export const parseAvatarDefinition = (text: string): ValidationResult<AvatarDefinition> => {
if (new TextEncoder().encode(text).byteLength > AVATAR_DEFINITION_MAX_BYTES) {
return {
ok: false,
errors: [
{
path: '',
code: 'max_bytes',
message: `JSON input exceeds ${AVATAR_DEFINITION_MAX_BYTES} UTF-8 bytes`,
},
],
}
}
try {
return validateAvatarDefinition(new BoundedJsonParser(text).parse())
} catch (error) {
if (error instanceof JsonTextError) {
return {
ok: false,
errors: [{ path: error.path, code: error.code, message: error.message }],
}
}
return {
ok: false,
errors: [{ path: '', code: 'invalid_json', message: 'Invalid JSON input' }],
}
}
}
export const avatarDefinitionFileName = (name: string) => {
const base =
name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'avatar'
return `${base}.avatar.json`
}