bible-strong-avatar-lab/src/app/studio-utils.ts
smontlouis 398c7f53e2 feat(studio): refine responsive workspace navigation
Add a resizable stacked layout and move avatar selection into a dedicated grid page.
2026-08-13 22:47:52 +02:00

141 lines
4.6 KiB
TypeScript

import { useEffect, useEffectEvent } from 'react'
import { type StudioLanguage } from '@/i18n'
import {
applyAvatarEyeDefaults,
defaultAvatarEyes,
type AvatarColors,
type AvatarEyeDefaults,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export'
export type ExportFormat = 'react' | 'javascript'
export type SnapshotFormat = 'svg' | 'png'
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
export const downloadBlob = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.append(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
export type Side = 'Left' | 'Right'
export type NumericProps = {
label: string
value: number
min?: number
max?: number
step?: number
unit?: string
onChange: (value: number) => void
onActiveChange?: (active: boolean) => void
}
export type Highlight = 'head' | 'left' | 'right' | 'both' | null
export const RETARGET_BLEND_MS = 120
export const INSPECTOR_FRAME_MS = 1000 / 24
export const AMBIENT_FRAME_MS = 1000 / 30
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
export const emptyBodyNodes: BodyNode[] = []
const previewGeometryCache = new WeakMap<
Expression,
WeakMap<
SurfaceConfig,
WeakMap<BodyNode[], { positionKey: string; geometry: ReturnType<typeof renderAvatar> }>
>
>()
export const poseWithAvatarEyes = (expression: Expression, eyes: AvatarEyeDefaults) =>
poseFromExpression(applyAvatarEyeDefaults(expression, eyes))
export const getPreviewGeometry = (
expression: Expression,
surface: SurfaceConfig,
bodyNodes: BodyNode[],
eyes: AvatarEyeDefaults = defaultAvatarEyes
) => {
let surfaceCache = previewGeometryCache.get(expression)
if (!surfaceCache) {
surfaceCache = new WeakMap()
previewGeometryCache.set(expression, surfaceCache)
}
let bodyCache = surfaceCache.get(surface)
if (!bodyCache) {
bodyCache = new WeakMap()
surfaceCache.set(surface, bodyCache)
}
const positionKey = JSON.stringify(eyes)
const cached = bodyCache.get(bodyNodes)
if (cached?.positionKey === positionKey) return cached.geometry
const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, {
includeWire: false,
bodyNodes,
})
bodyCache.set(bodyNodes, { positionKey, geometry })
return geometry
}
export const bounded = (value: number, min?: number, max?: number) =>
Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value))
export const useEscapeToCancel = (cancel: () => void) => {
const cancelEvent = useEffectEvent(cancel)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') cancelEvent()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
}
export const scaleSurface = (
surface: SurfaceConfig,
size: number,
minimums: Pick<SurfaceConfig, 'width' | 'height' | 'depth'>
) => {
const currentSize = Math.max(surface.width, surface.height, surface.depth) || 1
const factor = size / currentSize
return {
...surface,
width: bounded(surface.width * factor, minimums.width, 300),
height: bounded(surface.height * factor, minimums.height, 300),
depth: bounded(surface.depth * factor, minimums.depth, 300),
}
}
export const formatSeconds = (milliseconds: number, language: StudioLanguage) =>
`${new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : language === 'zh-CN' ? 'zh-CN' : 'en-US', {
maximumFractionDigits: 1,
}).format(milliseconds / 1000)} s`
export const parseHexColor = (color: string) => {
const value = color.replace('#', '')
return [0, 2, 4].map(offset => Number.parseInt(value.slice(offset, offset + 2), 16))
}
export const interpolateHexColor = (from: string, to: string, progress: number) => {
const fromChannels = parseHexColor(from)
const toChannels = parseHexColor(to)
return `#${fromChannels
.map((channel, index) =>
Math.round(channel + (toChannels[index] - channel) * progress)
.toString(16)
.padStart(2, '0')
)
.join('')}`
}
export const resolveColors = (expression: Expression, colors: AvatarColors): AvatarColors => ({
body: expression.bodyColor ?? colors.body,
eyes: expression.eyeColor ?? colors.eyes,
})