A built-in scrubber + exporter for the Three.js mascot defined in
hoarde-mascot.js. Lets you freeze a frame, A/B compare options, export
single transparent PNGs, and record the whole cycle (or one state) as a
numbered PNG sequence that ffmpeg can turn into animated WebP / WebM-alpha
/ ProRes for re-use anywhere.
The mascot runs on the live /hoarde/ page and exposes the same tuner
behind a URL flag. The dedicated studio page bundles only the mascot in a
clean box with no surrounding hero content:
| URL | What you get |
|---|---|
/hoarde-mascot-studio/ |
Mascot + tuner, no site chrome. Use this for tuning. |
/hoarde-mascot-studio/?freeze=eureka:1.5 |
Studio page rendering a frozen frame at t=1.5s of eureka. |
/hoarde/?mascot-edit=1 |
Live /hoarde/ page with the tuner mounted overlay-style. |
/hoarde/?freeze=stateId:t |
Live page with the mascot frozen at one frame, intro/bob disabled (headless-capture friendly). |
/hoarde/?mascot-edit=1&freeze=... |
Both — frozen frame with tuner mounted so you can scrub from there. |
The studio page is marked noindex, nofollow and excluded from the site
nav / sitemap / RSS.
A gear icon (⚙) in the top-right of the mascot canvas opens a panel that floats fixed in the viewport corner. Each fieldset:
pause, scrub t (slider rebounds to the state's durationSec), and watch the resolved frame index.current, wire-fill, full-recipe, state-wire, wire-over-solid) without reloading. Default is wire-over-solid.mouseFollow checkbox, mouseFollowYaw/Pitch sliders, idleBob checkbox + four bob params.rotationX/Y/Z sliders that take over the pivot when any of them is non-zero (clearing all three releases the override).extrudeDepth, curveSegments, extrudeSteps, bevel toggle + size/thickness/segments. Changes trigger a debounced 150ms rebuild of all extruded glyphs.size px (0 = use canvas), bg (text input, transparent or #hex), wire boost (1–10, multiplies opacity on additive layers only for the export render), blending (additive (live) vs normal (source-over) — switches additive layers to NormalBlending so the PNG composites cleanly on arbitrary backgrounds).fps, range (full cycle or a single state), and the record sequence button.Action buttons at the bottom:
?freeze=<state>:<t> to your clipboard, so you can paste it into the URL to reload at the same frame.Double-click any slider to snap it back to its as-designed default
(captured the first time the panel opens). The slider's title attribute
shows the default value.
?freeze=stateId:t in the URL.size px (e.g. 1024 for a slide asset), leave bg empty for transparent.wire boost + blending (see "Transparency notes" below).Filename pattern: hoarde-mascot-{state}-t{t}-{blend}-x{boost}.png so A/B
exports don't overwrite each other.
fps out (5–60). 30 is a sensible default; 15 cuts file size in half with little perceptual loss on most state animations.range:
full cycle — every state, in order, including the loop-outro spin. About 25 seconds of animation.size, bg, wire boost, blending first; they apply per frame.Full-cycle exports skip the intro fade-in on purpose. On the live page, the fade-in only plays on first load — every loop-wrap goes straight into a fully-revealed booting state. Recording the fade would make the resulting video unloopable (faded frame 0 vs. fully-revealed last frame). The recorder forces
introDuration = 0so frame 0 is already revealed and the last frame of the spin lands you cleanly back at frame 0.
Output destination is chosen automatically:
fflate is lazy-loaded from esm.sh on first ZIP use.The status line at the bottom of the fieldset reports recording X / N
during the run, then done — N frames plus the ffmpeg recipe for that
exact sequence.
While recording, the live animation loop is paused. Anything you change in the tuner during a recording is ignored — the recorder snapshots the options at start.
Replace BASE_%05d.png with the actual filename pattern (the status line
prints it). Replace 30 with whatever fps out you used.
%05d is ffmpeg's printf-style placeholder for "decimal integer,
zero-padded to 5 digits" — i.e. it matches the 00000, 00001, … suffix
the recorder writes. BASE is the rest of the filename. For frames named
hoarde-mascot-full-30fps-add-x1_0_00000.png,
hoarde-mascot-full-30fps-add-x1_0_00001.png, …, the input pattern is
hoarde-mascot-full-30fps-add-x1_0_%05d.png.
Universal browser support (Safari 14+ included), small files, alpha kept.
The simplest path is img2webp from the webp package — it's purpose-built
for sequence-to-animated-webp and uses shell glob expansion instead of a
printf pattern:
brew install webp # one-time
img2webp -lossless -loop 0 -d 33 BASE_*.png -o out.webp
# -d N = milliseconds per frame; 33 ≈ 30 fps. Use $((1000 / FPS)) for others.
# Add -q 75 -m 6 instead of -lossless for ~10× smaller lossy output.
If you want to stay in ffmpeg, note that Homebrew's default ffmpeg
formula is built without libwebp, so libwebp_anim fails with
Unknown encoder. Either install webp and use img2webp (above), or
swap to a fuller build:
brew uninstall ffmpeg
brew tap homebrew-ffmpeg/ffmpeg
brew install homebrew-ffmpeg/ffmpeg/ffmpeg
That tap ships ffmpeg with libwebp + libwebp_anim enabled, and these work:
ffmpeg -framerate 30 -i BASE_%05d.png \
-c:v libwebp_anim -lossless 1 -loop 0 \
out.webp
ffmpeg -framerate 30 -i BASE_%05d.png \
-c:v libwebp_anim -lossless 0 -q:v 75 -loop 0 \
out.webp
# WebM-alpha (VP9) — Chrome/Firefox/Edge <video>. Safari can't play it.
ffmpeg -framerate 30 -i BASE_%05d.png \
-c:v libvpx-vp9 -pix_fmt yuva420p \
-b:v 0 -crf 30 \
out.webm
# ProRes 4444 for video editors (Final Cut, Premiere, DaVinci Resolve).
ffmpeg -framerate 30 -i BASE_%05d.png \
-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le \
out.mov
# APNG — bulletproof <img> embed in any browser. Files are large.
ffmpeg -framerate 30 -i BASE_%05d.png \
-plays 0 \
out.apng
<!-- Animated WebP (recommended) — drops in anywhere a PNG would. -->
<img src="/assets/img/mascot-eureka.webp"
alt="HOARDE agent reaching a proof"
width="360" height="203">
<!-- WebM with PNG fallback (browser picks what it can play). -->
<video autoplay loop muted playsinline width="360" height="203">
<source src="/assets/img/mascot-eureka.webm" type="video/webm">
<img src="/assets/img/mascot-eureka.png" alt="HOARDE agent">
</video>
<!-- <picture> with WebP + APNG fallback. -->
<picture>
<source srcset="/assets/img/mascot.webp" type="image/webp">
<img src="/assets/img/mascot.apng"
alt="HOARDE agent" width="360" height="203">
</picture>
The bracket wire layers use THREE.AdditiveBlending at very low opacities
(~0.015–0.03 after bracketOpacityFactor). On the live page over the
dark --bg-gradient, additive blending writes
framebuffer_rgb = dst_rgb + src_rgb — the wire color is added to the
dark page background, ignoring its own alpha for RGB purposes.
When exported to PNG with alpha: true, the framebuffer starts at
(0,0,0,0). Additive contributes RGB but barely registers in the alpha
channel. The PNG ends up with bright-ish RGB and tiny alpha. When you
composite that PNG normally (source-over: result = src + dst·(1−srcA)),
the destination dominates and the brackets read dim.
Two knobs fix this:
MeshStandardMaterial solid layer.NormalBlending for the export render. Result composites cleanly via
source-over on any background, at the cost of looking slightly
different from the live page (which is additive-on-dark by design).The eye is not always solid — wire-fill, full-recipe, and
state-wire build the eye entirely from additive layers. In those
variants the eye also gets boosted; that's usually fine because the eye's
additive opacities aren't dim-factored (no bracketOpacityFactor), so it
reaches readable levels at lower boost values than the brackets.
hoarde-mascot.js — the mascot class, tuner UI, export pipeline, freeze-flag parsing.../css/hoarde-mascot.css — gear button + floating tuner panel styles.../../hoarde-mascot-studio.njk — the dedicated studio page (/hoarde-mascot-studio/).../../hoarde.njk — the live /hoarde/ page, where the mascot lives in the hero. Pulls in hoarde-mascot.css for the same tuner support behind ?mascot-edit=1.