# Export 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). ## Nothing is reserved for the bar, and nothing should be `--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. ## The bar is pinned to the WINDOW, like the montage bar `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. ## It's a `transition`, not an `animation`: the main trap 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. ## It only animates on arrival, never on a view change 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. ## The live SVG is serialised, not rebuilt 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 `` and no ``, 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 `` 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. ## The eyes export OPAQUE, in `paper`, and that's a win A paper-coloured `` 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. ## The export frame is tighter than the screen's, and shared by all shapes ±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. ## The animation is an SVG, and that is what makes it smooth 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-box` and `transform-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 in `liveliness` are 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. ## One PNG size, and no GIF 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. ## The animated GIF exists only for platform reach 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. ## The SVG is copied as TEXT, the image as a blob `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. ## Exporting a whole cycle is a different problem 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.