The Personnaliser view's export bar. src/ui/export.ts is pure and tested (frame,
catalogue, filename); src/ui/capture.ts is the DOM layer (canvas, clipboard);
src/components/ExportBar.vue is presentational and only emits a format, like
Customizer.vue. Everything still images is dependency-free: XMLSerializer and a canvas
are enough. Only the cycle video pulls a library, and only on demand (see below).
--timeline is already subtracted from the avatar column's height in both views
(otherwise the centred avatar would change place when switching tabs), so the band
under the ball is already empty here. Adding an --export variable would duplicate
a calculation that already exists.
position: fixed with the montage bar's own left: 4.5rem / right: 24.5rem, and
it centres its content. That is what stops it moving: anchored inside the avatar
column it rode the grid's interpolation, and coming back from the settings the
closing left panel moves that column's centre by 320 px, so the button slid
into place instead of being there. The montage bar never slides for exactly this
reason. The Personnaliser view has only one grid layout, so this position is a
constant.
Vertically it is aimed at the bottom of the avatar's box, not the column's: the
silhouette fills only about 65 % of its box's side (the viewBox keeps room for the
animated states' rings, absent at rest), so aiming at the column left 176 px of
measured dead space between the ball and the button. The min() in the top calc is
the one from .avatar's max-w, same box, so the bar follows it as it shrinks
on a short window instead of landing on top of it.
An animation replays on every mount: on reload, and on every view change. A
transition doesn't run on an element's first computed style, so the bar is born
visible and stays quiet. That is exactly how .panneau works.
Corollary: the bar is mounted during the arrival, merely hidden (--cachee plus
inert, because an opacity: 0 element is still clickable and focusable). Without a
start state present on screen there would be nothing to interpolate from.
The one reveal is after the intro, delayed in App.vue (400 ms) so the avatar reaches
its place first. Switching views does not fade it in: it is pinned to the window,
so it is born already in position, and an element that doesn't move has no business
announcing itself. That is the montage bar's rule too: it doesn't animate between the
settings and the animations, because it is already there.
This works because the bot's SVG is already self-contained: no var(--...), no
Tailwind class, every shape carries its fill as a literal hex. Rebuilding a second
render beside it would be a second source of drawing, and it would drift.
Two things that make the raster safe: there is no <foreignObject> and no <image>,
the only elements that would taint the canvas and break toBlob; and width/height
are set explicitly, because without an intrinsic size Firefox refuses to rasterise an
SVG loaded into an <img> and the canvas comes out empty.
Measured on the output at 512²: 71.4 % transparent, 26.7 % body, 1.5 % paper for the
eyes, the rest antialiasing.
A paper-coloured <path> sits under the body (BloubBot.vue), so the exported image
keeps light eyes on a dark background: real holes would make them disappear there.
Don't "fix" this into transparency.
±125 against ±158. Not a per-shape crop: skins.ts normalises radii so that "all
shapes weigh the same to the eye" (the squircle peaks at 1.15 on its diagonal), and
cropping each one separately would put them all back at the same size and break that
tuning. A test checks the frame contains every shape, so adding a wider one moves
the frame on its own instead of getting clipped.
A flipbook format (WebP, GIF, APNG) is capped by its frame count. The blink lasts
0.18 s (BLINK_DUR), so at 20 fps it gets three or four frames and reads as a stutter.
An animated SVG doesn't have frames: the browser interpolates between keyframes, so
the motion is smooth at the display's refresh rate however few keyframes we emit. That is
the whole reason for this choice: an animated WebP was built first, measured, and dropped
for being both choppier and 10× heavier (167 kB against 16 kB).
Only the eyes are animated, and that is measured. At rest the silhouette moves by
1.17 units on a radius of 100 over three seconds, about a pixel and a half at export
size. So the body is emitted once, static, and all the weight of a bot animation is in its
gaze. liveliness says the same thing in words: "toute la vie passe par le regard et les
clignements".
The drawing is not rebuilt. svgAnime takes the SVG BloubBot already rendered for
the first keyframe and swaps each eye's transform attribute for an animated class. One
source of drawing, same rule as the still export.
Three things that are not optional:
transform-box: view-boxandtransform-origin: 0 0. A CSS transform on an SVG element rotates around its bounding-box centre, not the user-space origin, so without these the eye flies to the other side of the ball.animation-direction: alternate. The gaze drift is not periodic: the periods inlivelinessare deliberately coprime so the movement never visibly repeats, so a plain loop would jump at the seam. Played forwards then backwards it rejoins itself exactly, and a blink in reverse is still a blink. This is the one thing the bitmap export could never have.- The eyes are the only shapes in the mask carrying a
transform(the body has none), which is what makes document order enough to identify them.
Keyframes are emitted at 30/s: a keyframe is one matrix of text, so density is nearly free, and it follows the blink's asymmetric curve faithfully. Measured on the output: eye area 921 → 193 → 933 px across the blink, sampled every 40 ms against 33 ms keyframes, the values fall between keyframes, which is the interpolation showing.
1024 covers every profile-picture spec (Discord 128, X 400, GitHub 500, Slack 512) and downscales cleanly. Offering 2048 as well made the user decide something that isn't theirs to decide; whoever wants bigger takes the SVG, which has no size.
GIF is ruled out for the still export by its 1-bit transparency: a staircase edge where PNG has 8 bits of alpha.
Discord and Slack accept a GIF as an animated avatar and an SVG nowhere, which is its whole reason to exist. Everywhere else the animated SVG is better on every axis.
It is the only export that asks a question, and only because GIF alpha is one bit: the
ball's antialiased rim has to be thresholded at 50 %, which comes out as a staircase. A solid
background smooths that rim back out because it has something to blend into, at the cost of
baking the colour in. Neither choice wins everywhere, so a dialog offers both, white first
(GifDialog.vue, native radios so the browser gives grouping and arrow keys). Exporting at
320 px while an avatar displays at 40-128 px softens the transparent edge further, since the
browser's downscale re-smooths it.
With a background, the eyes must take its exact colour. They are holes filled with
paper, so left at the site's off-white they show up as a slightly darker ring inside a white
frame. sequenceDuBot therefore passes the matte through as paper, measured: eyes at
255,255,255 on a white background against 249,249,249 on a transparent one.
gifAnime deduces transparency from the pixels rather than being told: flattened frames
must not declare a transparent index, and above all must not be disposed to background between
frames, which would flash the background. Transparent frames dispose to background (2),
opaque ones stay in place (1). Both cases are locked by a test.
Its LZW encoder is hand-rolled (no dependency), and it holds the one trap of the format:
the encoder writes its dictionary entry right after emitting a code, while the decoder only
writes its own when it reads the next code. The decoder is therefore permanently one entry
behind, so the encoder must widen its codes one step late: suivant > (1 << taille), never
===. Getting that wrong desynchronises the two and every reader rejects the file, with
nothing in the structure to show why. There is a round-trip test decoding the stream back to
its exact pixels, because structural assertions cannot catch this.
The palette is exact rather than dithered: the bot uses very few distinct colours (71.4 %
transparent, 26.7 % body, 1.5 % paper, the rest antialiasing), so index 0 is reserved for
transparency and the rest fit. Transparent frames are the ones disposed to background
(2 << 2), the GIF equivalent of "do not blend": without it their transparent areas keep the
previous frame and the ball drags a trail. Opaque frames stay in place, as above. Delays are in hundredths of a second, which caps the useful rate,
and never below 2 since 0 and 1 are handled inconsistently by readers.
Measured on the output: 147 kB, 60 frames at 320², decoded back by Chrome with a transparent corner and 2033 pixels changing between frames.
writeText is what Figma and Illustrator paste back as editable vector; as
image/svg+xml it would come back flattened. And the PNG blob goes into the
ClipboardItem as a promise: Safari requires write to originate from the user's
gesture, and any await slipped in before it loses that gesture.
Copying an image is offered only where the browser can write one
(ClipboardItem.supports); copying the SVG goes through writeText and works
everywhere.
The Personnaliser bar exports the avatar; the montage bar exports the cycle, and almost none of the still-export reasoning carries over.
The frame must be the screen's ±158, not the tight export frame. The margin the tight
frame removes is exactly the one holding the animated states' rings, which reach 1.4 × the
ball radius: 140, past the tight frame's 125. Nothing clamps those radii at runtime: it is
the hand-tuned RINGS/SWOOSH tables in decor.ts that keep them under 158. A test locks
the relationship.
No animated SVG here. It worked at rest only because the body was static. Over a cycle the body path changes every frame and weighs 2.5 kB, so six hundred frames would be 1.5 MB before counting the arcs.
frozenAt does not walk the montage, and seek is not enough either. apply() dates
the engine with clock, which only advances in the rAF loop and therefore stays at 0 while
frozen: every state change would register at t=0 and the cross-fades at block joints would
be wrong. Hence rendAt(t) on BloubBot: it resolves the block with blockAt and calls
setState with the block's absolute offset, reproducing tick() without a clock. Proven
by measurement: across a joint the silhouette moves 17.6 → 7.0 → 2.1 → 0.36 → 0.02 over
~0.3 s, the decay of an exponential ease-out. A broken morph would show one single jump.
Frames are never accumulated. A thirty-second cycle is 624 frames, 255 MB of raw pixels
if kept. The MP4 path streams frame-by-frame into the encoder (awaiting each add is what
applies backpressure); the GIF path renders the sequence twice, once to count colours and
once to encode, which is free because the render is deterministic.
The GIF palette is chosen by frequency, not first-come. Measured: a cycle exceeds 255 colours, so a palette filled in encounter order would gorge itself on the first frames' antialiasing and dump every ring colour into one slot. Unseen colours resolve to the nearest kept entry, cached so each distinct colour costs one search.
Video is always opaque: VideoEncoder refuses alpha: 'keep' for H.264 and VP9 alike,
so the dialog offers a background choice for the GIF only, and doesn't show the group at all
for MP4 rather than showing it disabled.
Resolution and frame rate are per format, and mixing them up was a real bug. The MP4 first inherited the GIF's 320 px / 20 fps, settings justified for the GIF by file weight, which a video simply does not share, since it compresses motion instead of storing each frame. Measured at 93 kbps, the export had the definition of a thumbnail: that is what made it look like a GIF. The video is now 1024 px at 30 fps.
The quality knob is the QUANTIZER, not the bitrate, and that is genuinely counter-intuitive.
mediabunny's named levels (QUALITY_HIGH and friends) do not set a bitrate when the browser
can encode at a fixed quantizer, which Chrome has done since 117 for all these codecs. They
resolve to bitrateMode: 'quantizer', and the bitrate the library computes only picks the AVC
level in the codec string. Captured off the real encoder: the config arrived with no bitrate
field at all and a QP of 22 on every frame. Raising a bitrate would have changed nothing.
Measured edge error against the source, on one frame of the bot: QP 22 → 3.06, QP 16 → 1.75,
QP 10 → 0.58. video.ts therefore passes an explicit new Quality({ quantizer, bitrate }); the
bitrate rides along only as a fallback for browsers without per-frame quantizer support.
Two dead ends, so nobody re-walks them: 4:2:0 chroma subsampling is not the culprit on a black bot (grey has no chrominance to subsample) though it does bleed on a coloured one (42 MAE measured on a red/green edge), and the only real 4:4:4 reachable here is AV1's High profile, software-only. And hardware acceleration does not degrade quality at equal settings; what it does is never expose 4:4:4.
Measured on the default cycle (31.2 s): MP4 936 frames at 1024², 5.48 MB in ~4 s; GIF 624 frames
at 320², 1.5 MB in ~16 s. Real-time recording via MediaRecorder would have been dependency-free
but takes the full 31.2 s, which is why the encoder won.