Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.4.0] — 2026-09-26

### Added

- `initialPhase`, the time of the cycle where the bot starts, in seconds. The
component reads it once, on mount: the clock, the block and the pose start at
that time. Bots mounted together with different values do not animate in step.
A frozen bot (`frozenAt`) ignores it.
- `BotEngine.setState` takes an optional `posePhase`. It moves the pose of the new
state forward on its own clock, and keeps the transition timing. With `0`, the
default, the output does not change.

## [0.3.0] — 2026-09-04

### Added
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,13 @@ editor, Tailwind, export dialogs, video encoder, or application translations.
Solid runtime. The package supports SolidJS 2, starting with `2.0.0-rc.0`.

Props: `size`, `shape`, `color`, `expression`, `paper`, `frozenAt`, `cycle`,
`follow`, `gaze`, `ariaLabel`, `block`, `state`, `playing`, `elapsed`, `fps`, and
`autoPause`. Each playback value has a matching `on...Change` callback. See
[BloubBot.tsx](src/components/BloubBot.tsx) for the details.
`follow`, `gaze`, `ariaLabel`, `block`, `state`, `playing`, `elapsed`, `fps`,
`autoPause`, and `initialPhase`. Each playback value has a matching `on...Change`
callback. See [BloubBot.tsx](src/components/BloubBot.tsx) for the details.

`initialPhase` starts the bot at that time of its cycle, in seconds. The component
reads it once, on mount. Give each bot in a list a different value, and the bots
do not breathe in step.

An animated bot costs a style recalculation, a layout and a paint per drawn
frame, so its price is set by how often it draws and by how many of them are on
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@norbert_bodziony/bloub",
"private": false,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"description": "SVG recreation of the x.ai bot avatar. One shape morphing through 14 states, measured off the reference video frame by frame.",
"license": "MIT",
Expand Down
23 changes: 19 additions & 4 deletions src/bot/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export class BotEngine {
private departFige: Pose | null = null
private tCur = 0
private tPrev = 0
/**
* Avance de la POSE de l'etat courant (et de l'etat quitte) sur son horloge : la pose se
* lit a `now - tCur + posePhase`, le fondu reste date sur `tCur`. Cf. `setState`.
*/
private posePhase = 0
private previousPosePhase = 0
private blinkAt = -10
private pts: Point[] = []
private shape: number[] | null = null
Expand Down Expand Up @@ -355,6 +361,8 @@ export class BotEngine {
this.departFige = null
this.tCur = now
this.tPrev = now
this.posePhase = 0
this.previousPosePhase = 0
this.blinkAt = -10
}

Expand All @@ -370,7 +378,7 @@ export class BotEngine {
if (this.departFige) return this.departFige
if (!this.prev) return null
const prevDef = STATE_BY_ID.get(this.prev)!
return this.posed(prevDef, Math.max(0, now - this.tPrev), shape, expr)
return this.posed(prevDef, Math.max(0, now - this.tPrev + this.previousPosePhase), shape, expr)
}

/**
Expand All @@ -382,7 +390,7 @@ export class BotEngine {
const def = STATE_BY_ID.get(this.cur)!
const shape = this.shapeAtTime(now)
const expr = this.exprAtTime(now)
const pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr)
const pose = this.posed(def, Math.max(0, now - this.tCur + this.posePhase), shape, expr)
const since = now - this.tCur
if (since >= def.morph) return pose
const origine = this.origine(now, shape, expr)
Expand All @@ -407,14 +415,21 @@ export class BotEngine {
* exactement l'image affichee. La lecture d'un montage, dont les blocs durent au moins
* le plus long fondu (`MIN_BLOCK`), ne fige donc jamais rien et rend au bit ce qu'elle
* rendait.
*
* `posePhase` avance la pose du nouvel etat sans toucher au fondu : un bot qui entre dans
* un etat au milieu de son animation (cf. `initialPhase` du composant) morphe depuis
* l'image a l'ecran, sur la duree normale, vers une pose deja en cours. A 0 — la valeur
* par defaut — le rendu est celui d'avant, au bit pres.
*/
setState(id: StateId, now: number) {
setState(id: StateId, now: number, posePhase = 0) {
if (id === this.cur) return
const morph = STATE_BY_ID.get(this.cur)!.morph
const enPleinFondu = this.prev !== null && now - this.tCur < morph
this.departFige = enPleinFondu ? this.poseComposee(now) : null
this.prev = this.cur
this.tPrev = this.tCur
this.previousPosePhase = this.posePhase
this.posePhase = posePhase
this.cur = id
this.tCur = now
// Dans la video, chaque changement de forme est masque par un clignement.
Expand All @@ -426,7 +441,7 @@ export class BotEngine {
const def = STATE_BY_ID.get(this.cur)!
const shape = this.shapeAtTime(now)
const expr = this.exprAtTime(now)
let pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr)
let pose = this.posed(def, Math.max(0, now - this.tCur + this.posePhase), shape, expr)
let decalage = this.decalageAtTime(now, this.cur)

// --- transition -------------------------------------------------------
Expand Down
35 changes: 34 additions & 1 deletion src/components/BloubBot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import { render } from '@solidjs/web'
import { describe, expect, it, vi } from 'vitest'
import BloubBot, { type BloubBotRef } from './BloubBot'
import { defaultCycle } from '@/bot/cycles'
import { blockAt, defaultCycle } from '@/bot/cycles'
import { BotEngine } from '@/bot/engine'
import { DEFAULT_EXPRESSION, EXPRESSION_BY_ID } from '@/bot/expressions'
import { RAYON } from '@/bot/repere'
import { DEFAULT_SHAPE, SHAPE_BY_ID } from '@/bot/skins'

/**
* Le composant ne connait du temps que `requestAnimationFrame` : la remplacer
Expand Down Expand Up @@ -132,4 +136,33 @@ describe('BloubBot Solid', () => {
dispose()
vi.unstubAllGlobals()
})

it('initialPhase fait partir le bot de cette date du montage', async () => {
horlogeManuelle()
const blocs = defaultCycle().blocks
const phase = blocs[0]!.duration + 0.4
const { index, elapsed } = blockAt(blocs, phase)
const onBlockChange = vi.fn()
const host = document.createElement('div')
const dispose = render(
() => <BloubBot cycle={blocs} playing initialPhase={phase} onBlockChange={onBlockChange} />,
host
)
await Promise.resolve()

// Le moteur de reference : l'etat du bloc, commence `elapsed` secondes avant la date.
const reference = new BotEngine(
RAYON,
blocs[index]!.state,
SHAPE_BY_ID.get(DEFAULT_SHAPE)!.radii,
EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION)!
)
reference.reset(blocs[index]!.state, phase - elapsed)
expect(index).toBeGreaterThan(0)
expect(host.querySelector('mask path')?.getAttribute('d')).toBe(reference.sample(phase).bodyPath)
expect(onBlockChange).not.toHaveBeenCalled()

dispose()
vi.unstubAllGlobals()
})
})
49 changes: 40 additions & 9 deletions src/components/BloubBot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export interface BloubBotProps {
* `renderAt` (l'export, lui, passe par `frozenAt` et n'a jamais de boucle).
*/
autoPause?: boolean
/**
* Date de depart du bot dans son montage, en secondes. Lue une fois, au montage :
* l'horloge, le bloc et la pose partent de cette date, donc plusieurs bots montes
* ensemble ne respirent pas a l'unisson. Ignoree par un bot fige (`frozenAt`).
*/
initialPhase?: number
onBlockChange?: (value: number) => void
onStateChange?: (value: StateId) => void
onPlayingChange?: (value: boolean) => void
Expand All @@ -96,10 +102,22 @@ export default function BloubBot(rawProps: BloubBotProps) {
rawProps
)

const [internalBlock, setInternalBlock] = createSignal(0)
const [internalState, setInternalState] = createSignal<StateId>('idle')
/*
* Le depart, lu une seule fois : c'est une date, pas un reglage suivi. Tout est lu sous
* `untrack`, sinon le corps du composant lirait des props hors de toute portee reactive.
*/
const start = untrack(() => {
const phased = props.initialPhase !== undefined && props.frozenAt === undefined
const phase = phased ? props.initialPhase! : 0
const position = phased ? blockAt(props.cycle, phase) : { index: 0, elapsed: 0 }
const state: StateId = phased ? (props.cycle[position.index]?.state ?? 'idle') : 'idle'
return { phased, phase, ...position, state }
})

const [internalBlock, setInternalBlock] = createSignal(start.index)
const [internalState, setInternalState] = createSignal<StateId>(start.state)
const [internalPlaying] = createSignal(false)
const [internalElapsed, setInternalElapsed] = createSignal(0)
const [internalElapsed, setInternalElapsed] = createSignal(start.elapsed)

const block = () => rawProps.block ?? internalBlock()
const state = () => rawProps.state ?? internalState()
Expand All @@ -125,8 +143,13 @@ export default function BloubBot(rawProps: BloubBotProps) {
const ink = createMemo(() => COLOR_BY_ID.get(props.color)?.hex ?? '#0a0a0c')
const expression = createMemo(() => EXPRESSION_BY_ID.get(props.expression) ?? null)

const engine = untrack(() => new BotEngine(RAYON, state(), shapeRadii(), expression()))
const [frame, setFrame] = createSignal<BotFrame>(untrack(() => engine.sample(props.frozenAt ?? 0)), {
const engine = untrack(() => {
const created = new BotEngine(RAYON, state(), shapeRadii(), expression())
// l'etat du bloc de depart a commence `elapsed` secondes avant la date de depart
if (start.phased) created.reset(state(), start.phase - start.elapsed)
return created
})
const [frame, setFrame] = createSignal<BotFrame>(untrack(() => engine.sample(props.frozenAt ?? start.phase)), {
equals: false
})
const uid = createUniqueId()
Expand All @@ -138,12 +161,12 @@ export default function BloubBot(rawProps: BloubBotProps) {
let drawn = -Infinity
let nextAt = Infinity
let last = 0
let clock = 0
let clock = start.phase
let blockStart = 0
let pendingOffset = 0
let lastRenderedBlock = -1

function apply(index: number, from = 0) {
function apply(index: number, from = 0, posePhase = 0) {
const current = props.cycle[index]
if (!current) {
nextAt = Infinity
Expand All @@ -152,7 +175,7 @@ export default function BloubBot(rawProps: BloubBotProps) {
blockStart = clock - from
setElapsed(from)
setState(current.state)
engine.setState(current.state, clock)
engine.setState(current.state, clock, posePhase)
nextAt = playing() ? blockStart + current.duration : Infinity
}

Expand Down Expand Up @@ -307,7 +330,15 @@ export default function BloubBot(rawProps: BloubBotProps) {
}
const index = Math.min(block(), blocks.length - 1)
if (index !== block()) goToBlock(index)
else nextAt = playing() ? blockStart + blocks[index]!.duration : Infinity
else if (blocks[index]!.state !== engine.state) {
/*
* Meme bloc, autre etat : le montage a change sous le bot (un avatar qui passe de
* `idle` a `think`, par exemple). Le bloc repart decale de la date de depart, pour
* que des bots montes ensemble restent desynchronises apres le changement.
*/
const offset = start.phase % blocks[index]!.duration
apply(index, offset, offset)
} else nextAt = playing() ? blockStart + blocks[index]!.duration : Infinity
},
{ defer: true }
)
Expand Down
Loading