How much of an animation can you throw away?

About 97% of it. One second of a 30-bone walk cycle at 60 fps is 72,005 bytes of raw float32 and 2,229 bytes after constant-channel removal, cubic keyframe reduction and quantisation — 32x, with 7.2 mm of worst-case joint error on a 1.8 m character. Measured on a walk cycle, a snappy jump and a jitt

How much of an animation can you throw away?

About 97% of it: one second of a 30-bone walk cycle at 60 fps costs 72,005 bytes as raw float32 and 2,229 bytes after constant-channel removal, cubic keyframe reduction and quantisation — 32.3x smaller, with a worst-case joint error of 7.24 mm on a 1.8 m character. Of the 37,800 keyframes in a 3-second clip, 1,116 were worth keeping. The single biggest win costs nothing and is lossless: 89% of the channels in a normal humanoid rig never change value at all.

Hardware: Apple M3, 16 GB, macOS 26.4.1 (build 25E253). Node v23.5.0, built-ins only — node:zlib and Buffer, no engine, no packages, no network. Byte counts and error figures are exact and reproduce from the generator; the nanosecond timings are medians of nine runs on one laptop under normal desktop load and move about 10% between runs.

The short answer

  • Delete the channels that never move first. On the walk clip, 177 of 180 position/scale channels and 10 of 30 rotation tracks were constant — 187 of 210 tracks, 89.0%. Storing one value instead of 180 took the clip from 216,016 to 69,764 bytes, 3.10x, with zero error.
  • Then drop 96–97% of the remaining keyframes. At a 1 mm tolerance, linear reduction kept 1,501 of 37,800 keys and cubic kept 1,116 — 24,068 and 17,486 bytes.
  • A better interpolator is worth about a third — on smooth motion only. On the walk, cubic Hermite keys were 1.38x fewer bytes than linear at the same tolerance and lower actual error. On the snappy jump the same swap bought 1.06x, and at a 0.1 mm tolerance cubic was worse than linear.
  • The snappy jump did not compress much worse than the smooth walk, which is not what we expected. Per animated track per second the walk kept 19.0 keys of 60 and the jump 18.1. It costs more per second of clip (3,053 B/s against 2,229) mostly because cubic curves cannot help it.
  • gzip still helps after all of that: 1.83x. That contradicts our own earlier result on packed network packets, and the reason is size, not format.

What exactly was generated?

A 30-bone humanoid rig — hips, spine, chest, neck, head, two arm chains, two leg chains, four finger bones, a prop, a jaw, two eyes and a cape — with real bone lengths in metres for a 1.8 m character. Each bone stores local position (3 floats), a rotation quaternion (4) and scale (3): 10 floats per bone per frame, 300 per frame, 72,005 bytes per second at 60 fps including a 16-byte header. That is what an exporter produces before anyone optimises it.

Three clips, all deterministic:

  • walk — 3 s, 180 frames. A 1 Hz gait: root travelling forward at 1.4 m/s with a vertical bob at twice the step frequency, limb rotations as phase-offset sinusoids, and a knee angle built with max(0, sin(…)) so the curve has a genuine corner at knee lock.
  • jump — 2 s, 120 frames. Piecewise by design: 0.30 s idle, a smoothstep crouch, a 0.06 s linear launch, a parabolic 0.56 s airborne arc, a 0.08 s linear impact, an eased recovery, then idle. The derivative is discontinuous at launch and at impact, and the root carries a volume-preserving squash and stretch so the scale channels are not idle.
  • mocap — the walk plus deterministic per-frame jitter (±0.12° on rotations, ±0.4 mm on the root) standing in for captured data.

Both matter because they fail differently. Smooth motion is what curve fitting is for; sharp motion is where an over-eager fitter rounds off the frames the animator cared most about.

How many channels are constant?

Almost all of them, and this is the part most exporters get wrong.

Clip Constant scalar channels Constant rotation tracks Raw bytes After removal
walk 177 / 180 10 / 30 216,016 69,764
jump 175 / 180 10 / 30 144,016 48,516
mocap 177 / 180 10 / 30 216,016 69,764

Bone positions are fixed by the skeleton — only the root translates — and scale is 1 unless someone animates it. Of 180 position and scale channels, exactly three move on the walk and five on the jump. Ten rotation tracks — shoulders, fingers, prop, jaw, eyes — are never touched.

3.10x, exactly zero error, from a min/max comparison per channel. If your build step does nothing else, do this one. It is also why the numbers below are so large — everything that follows works on the 11% of the file that contains motion.

How many keyframes are actually redundant?

A key is redundant if reconstructing it from its neighbours lands within a tolerance. We fit by refinement — start with the first and last frame only, reconstruct the whole channel, insert the frame with the worst error, repeat until every frame is inside the tolerance — using the same procedure for linear and cubic so the comparison is fair.

Tolerance is a distance in millimetres; rotation tracks get an angular tolerance derived from it by assuming a 0.35 m lever arm, so 1 mm becomes 0.164°. Error is reported where it matters: maximum world-space joint displacement over every joint and every frame, after forward kinematics.

Walk clip, 3 seconds, constant-channel removal always on:

Interp Tolerance Keys kept Bytes Bytes/s gzip Max joint error
linear 0.1 mm 3,365 55,544 18,515 15,077 0.32 mm
linear 0.5 mm 2,068 33,878 11,293 10,446 1.85 mm
linear 1 mm 1,501 24,068 8,023 8,132 4.69 mm
linear 2 mm 1,126 17,762 5,921 6,447 9.14 mm
linear 5 mm 791 11,984 3,995 4,600 17.74 mm
linear 20 mm 460 6,350 2,117 2,589 69.45 mm
cubic 0.1 mm 2,675 44,336 14,779 12,979 0.34 mm
cubic 0.5 mm 1,410 22,682 7,561 8,298 1.74 mm
cubic 1 mm 1,116 17,486 5,829 6,736 3.78 mm
cubic 2 mm 860 13,214 4,405 5,241 8.83 mm
cubic 5 mm 663 10,004 3,335 3,952 13.96 mm
cubic 20 mm 411 5,468 1,823 2,270 70.80 mm

Two things. First, the world-space error is roughly 4–5x the per-channel tolerance — 1 mm produced 4.69 mm of joint displacement, because a fingertip inherits every approximation made at the hips, spine, chest, shoulder and elbow. Set a tolerance in your exporter and assume that is what players see, and you are out by a factor of five. Second, the curve is steep at the top and flat at the bottom: 0.1 mm to 1 mm cost 4.4 mm of error and saved 57% of the bytes, while 5 mm to 20 mm cost another 52 mm and saved 47% of what was left.

What error is actually visible?

This is a judgement, and we did not run a perceptual study. Here is the threshold we used and the arithmetic behind it, so you can move it.

A 1.8 m character drawn N pixels tall gives 1800/N millimetres per pixel:

Character height on screen 1 pixel is
48 px 37.50 mm
64 px 28.13 mm
128 px 14.06 mm
256 px 7.03 mm
512 px 3.52 mm
1080 px 1.67 mm

We chose "worst-case joint error under one screen pixel" as the threshold. Sub-pixel joint displacement cannot move a rendered silhouette by a whole pixel, and a moving character hides more than a static comparison does. Half a pixel would be safer; two pixels would probably still pass on a fast action; a slow close-up of a face is a different problem entirely.

At 256 px tall, one pixel is 7.03 mm, so a 1 mm tolerance (4.69 mm linear, 3.78 mm cubic) sits inside it and a 2 mm tolerance (9.14 mm) does not. At 64 px — a typical 2D sprite character — one pixel is 28.13 mm and a 5 mm tolerance fits with room to spare, which is 3,335 bytes per second instead of 5,829. The right tolerance is a function of how big the character is on screen, and nobody sets it that way. A background NPC and a cutscene hero should not be exported with the same number.

Does a better interpolator let you drop more keys?

On smooth motion, clearly yes. On sharp motion, barely.

Clip Linear @1 mm Cubic @1 mm Bytes ratio Linear error Cubic error
walk 24,068 B 17,486 B 1.38x 4.69 mm 3.78 mm
jump 16,194 B 15,336 B 1.06x 3.80 mm 3.30 mm
mocap 25,274 B 24,134 B 1.05x 5.03 mm 4.58 mm

Cubic Hermite with Catmull-Rom tangents wins on the walk twice over — 1.38x fewer bytes and lower measured error — because a sinusoid is very nearly a cubic over a short span and a straight line is not.

On the jump it is close to a wash, and at the tightest tolerance it is a loss: at 0.1 mm the jump needed 1,574 linear keys but 1,677 cubic keys. A cubic segment approaching a corner overshoots, so the fitter spends extra keys on both sides of the corner holding the overshoot down. Curve fitting is a discount on smoothness, and the frames an animator snapped deliberately are exactly the ones it cannot discount. The mocap clip fails the same way: jitter is a corner every frame.

Does a snappy jump compress worse than a smooth walk?

Less than we expected, and the reason is not the one we assumed.

Per animated track per second, at a 1 mm linear tolerance, the walk kept 19.0 keys of 60 (31.7%) and the jump kept 18.1 of 60 (30.1%). Those are the same number. The jump's advantage is that it spends a third of its running time doing nothing:

Jump phase Frames Keys Keys per frame
0.00–0.30 idle 18 25 1.4
0.30–0.48 crouch 11 160 14.5
0.48–0.54 launch 3 25 8.3
0.54–1.10 airborne 34 395 11.6
1.10–1.18 impact 5 49 9.8
1.18–1.45 recover 16 205 12.8
1.45–2.00 settle 33 44 1.3

The transitions cost 8–15 keys per frame against 1.4 in the idle stretches, and 51 of the 120 frames are nearly free. Averaged over the clip that lands in the same place as a walk cycle that is uniformly busy. Where the jump actually loses is in the interpolator: the walk gets a 1.38x discount from cubic curves and the jump gets 1.06x, so the final per-second cost is 2,229 B/s for the walk and 3,053 B/s for the jump, a 1.37x gap. Budget by transition density, not by how snappy the animation feels: a clip with three sharp events and long holds is cheap, and a continuously busy idle-fidget loop is not.

What do quantisation and gzip add on top?

Everything measured together, walk clip, 1 mm tolerance:

Technique Bytes Bytes/s gzip Max joint error
1. Raw float32, every channel every frame 216,016 72,005 31,818 0
2. + constant-channel removal (lossless) 69,764 23,255 18,712 0
3. Keyframe reduction only, linear 25,684 8,561 8,247 4.69 mm
4. Constant removal + linear keys 24,068 8,023 8,132 4.69 mm
5. Constant removal + cubic keys 17,486 5,829 6,736 3.78 mm
6. 5 + 16-bit pos/scale + smallest-three quat 6,688 2,229 3,650 7.24 mm
7. Constant removal + quantisation, no key reduction 25,034 8,345 13,314 7.26 mm

Quantisation is 16-bit fixed point over each channel's own min/max range for position and scale, plus the smallest-three trick for rotations: drop the largest quaternion component, store its index in 2 bits and the other three in 10 bits each — 4 bytes per rotation instead of 16. Row 6 is 2.6x smaller than row 5 and moves the error from 3.78 mm to 7.24 mm, still inside one pixel at 256 px.

Rows 4 and 7 are the interesting pair: at almost identical size (24,068 versus 25,034 bytes), keyframe reduction gave 4.69 mm of error and quantisation alone gave 7.26 mm. If you can only implement one, implement the keyframe pass.

gzip still helps, and it is not supposed to. In syncing game state we found gzip inflated every hand-packed binary packet. Here the fully quantised 6,688-byte stream gzips to 3,650, 1.83x. Splitting by record type explains it: constant records compress 3.21x (187 near-identical 8-byte records) and the animated records still compress 1.69x. gzip's 18–23 bytes of header are irrelevant against 6.7 KB, and quantised values in adjacent keys still share high bytes. The earlier result was about packet size, not about packing. Ship your clips gzipped.

Where it stops working is noise: the mocap clip gzipped to 5,217 bytes against the clean walk's 3,650 — 1.43x worse for motion a viewer would call identical. Filter capture data before you compress it.

What does the smaller file cost to decode?

More than you would like. This is the number nobody puts next to the compression ratio.

Sampling one full 30-bone pose (300 values) Time
Raw float32, lerp between two whole frames 243 ns
Linear keyframes, binary search + lerp 1,900 ns
Cubic keyframes, binary search + Hermite 2,029 ns
Quantised cubic keys, dequantise + Hermite 2,019 ns

The compressed formats cost 7.8–8.4x more CPU per pose than reading two raw frames, because a raw frame is one contiguous typed-array read and a keyframed pose is 210 independent binary searches into 210 short arrays. Quantisation is free on top. Whether that matters is arithmetic: 2,029 ns × 100 animated characters is 203 µs, or 1.2% of a 16.67 ms frame at 60 fps. Take the 32x.

One optimisation we expected to matter did not. Playback is sequential, so a cached per-track cursor should beat a binary search; over the 180 scalar channels it measured 374 ns with binary search and 359 ns with a cursor, 4% and inside the run-to-run noise. The tracks are short — a binary search over 13 keys is four comparisons, and there is nothing left to save.

What this means for a build

For a game with 200 clips averaging 2 seconds, the raw float32 export is a calculated 28.8 MB; the same clips through this pipeline are 0.89 MB, or 0.49 MB gzipped. Same shape of result as packing a sprite atlas and storing a tilemap: the boring lossless transformation does most of the work, and the clever lossy one is a knob to set per asset rather than per project. Our generative animation studio at animator.cstsolution.com exports clips at full rate for that reason — the tolerance belongs to your build, not to the tool that authored the motion.

Check it yourself

One file, no packages, no assets, nothing written to disk. Node 18 or newer. It takes about three seconds and reproduces every number above.

#!/usr/bin/env node
'use strict';
// Animation keyframe compression, measured. Node 23, built-ins only.
// Save as anim-compress.js and run: node anim-compress.js
const zlib = require('node:zlib');

// ---------------------------------------------------------------- rig
// 30-bone humanoid. rest = local offset from parent, in metres.
const RIG = [
  ['hips',        -1, [ 0,    0.95,  0   ]],
  ['spine',        0, [ 0,    0.12,  0   ]],
  ['chest',        1, [ 0,    0.16,  0   ]],
  ['neck',         2, [ 0,    0.20,  0   ]],
  ['head',         3, [ 0,    0.09,  0   ]],
  ['shoulder.L',   2, [ 0.05, 0.16,  0   ]],
  ['upperarm.L',   5, [ 0.13, 0,     0   ]],
  ['forearm.L',    6, [ 0.28, 0,     0   ]],
  ['hand.L',       7, [ 0.26, 0,     0   ]],
  ['shoulder.R',   2, [-0.05, 0.16,  0   ]],
  ['upperarm.R',   9, [-0.13, 0,     0   ]],
  ['forearm.R',   10, [-0.28, 0,     0   ]],
  ['hand.R',      11, [-0.26, 0,     0   ]],
  ['thigh.L',      0, [ 0.09,-0.06,  0   ]],
  ['shin.L',      13, [ 0,   -0.42,  0   ]],
  ['foot.L',      14, [ 0,   -0.40,  0   ]],
  ['toe.L',       15, [ 0,   -0.06,  0.12]],
  ['thigh.R',      0, [-0.09,-0.06,  0   ]],
  ['shin.R',      17, [ 0,   -0.42,  0   ]],
  ['foot.R',      18, [ 0,   -0.40,  0   ]],
  ['toe.R',       19, [ 0,   -0.06,  0.12]],
  ['finger1.L',    8, [ 0.06, 0,     0.02]],
  ['finger2.L',    8, [ 0.06, 0,    -0.02]],
  ['finger1.R',   12, [-0.06, 0,     0.02]],
  ['finger2.R',   12, [-0.06, 0,    -0.02]],
  ['prop_sword',  12, [-0.04, 0,     0   ]],
  ['jaw',          4, [ 0,   -0.03,  0.04]],
  ['eye.L',        4, [ 0.03, 0.04,  0.06]],
  ['eye.R',        4, [-0.03, 0.04,  0.06]],
  ['cape',         2, [ 0,    0.05, -0.08]],
];
const NB = RIG.length;
const FPS = 60;

// -------------------------------------------------------------- maths
const D2R = Math.PI / 180, R2D = 180 / Math.PI;
function euler(x, y, z) { // degrees, XYZ intrinsic -> quaternion [x,y,z,w]
  const cx = Math.cos(x * D2R / 2), sx = Math.sin(x * D2R / 2);
  const cy = Math.cos(y * D2R / 2), sy = Math.sin(y * D2R / 2);
  const cz = Math.cos(z * D2R / 2), sz = Math.sin(z * D2R / 2);
  return [sx*cy*cz + cx*sy*sz, cx*sy*cz - sx*cy*sz,
          cx*cy*sz + sx*sy*cz, cx*cy*cz - sx*sy*sz];
}
const qmul = (a, b) => [
  a[3]*b[0] + a[0]*b[3] + a[1]*b[2] - a[2]*b[1],
  a[3]*b[1] - a[0]*b[2] + a[1]*b[3] + a[2]*b[0],
  a[3]*b[2] + a[0]*b[1] - a[1]*b[0] + a[2]*b[3],
  a[3]*b[3] - a[0]*b[0] - a[1]*b[1] - a[2]*b[2]];
function qrot(q, v) {                       // rotate vec3 by quaternion
  const [x, y, z, w] = q, [a, b, c] = v;
  const ix =  w*a + y*c - z*b, iy =  w*b + z*a - x*c;
  const iz =  w*c + x*b - y*a, iw = -x*a - y*b - z*c;
  return [ix*w + iw*-x + iy*-z - iz*-y,
          iy*w + iw*-y + iz*-x - ix*-z,
          iz*w + iw*-z + ix*-y - iy*-x];
}
const qnorm = q => { const l = Math.hypot(q[0],q[1],q[2],q[3]) || 1;
  return [q[0]/l, q[1]/l, q[2]/l, q[3]/l]; };
const qdot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
const qangle = (a, b) => 2 * Math.acos(Math.min(1, Math.abs(qdot(a, b)))) * R2D;
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
const smooth = u => { u = clamp(u, 0, 1); return u*u*(3 - 2*u); };

// deterministic LCG, for the mocap-jitter clip only
function lcg(seed) { let s = seed >>> 0;
  return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 * 2 - 1; }; }

// ----------------------------------------------------------- clip gen
// A clip is Float64 arrays: pos[b][3][f], rot[b][f][4], scl[b][3][f]
function blankClip(frames) {
  return {
    frames,
    pos: RIG.map(r => [new Float64Array(frames).fill(r[2][0]),
                       new Float64Array(frames).fill(r[2][1]),
                       new Float64Array(frames).fill(r[2][2])]),
    scl: RIG.map(() => [new Float64Array(frames).fill(1),
                        new Float64Array(frames).fill(1),
                        new Float64Array(frames).fill(1)]),
    rot: RIG.map(() => new Array(frames).fill(null).map(() => [0, 0, 0, 1])),
  };
}
const IDX = Object.fromEntries(RIG.map((r, i) => [r[0], i]));
const setRot = (c, name, f, x, y, z) => { c.rot[IDX[name]][f] = euler(x, y, z); };

function walkClip(seconds, jitter) {
  const frames = Math.round(seconds * FPS), c = blankClip(frames);
  const rnd = lcg(20260905);
  for (let f = 0; f < frames; f++) {
    const t = f / FPS, p = 2 * Math.PI * 1.0 * t;   // 1 Hz cycle = 2 steps/s
    const j = jitter ? () => rnd() : () => 0;
    // root: constant forward travel + vertical bob + lateral sway
    c.pos[0][0][f] = 0.020 * Math.sin(p)          + j() * 0.0004;
    c.pos[0][1][f] = 0.95 + 0.025 * Math.sin(2*p) + j() * 0.0004;
    c.pos[0][2][f] = 1.40 * t                     + j() * 0.0004;
    setRot(c, 'hips',      f, -4 + 1.5*Math.sin(2*p) + j()*0.12, 3*Math.sin(p) + j()*0.12, 2*Math.sin(p));
    setRot(c, 'spine',     f, 2*Math.sin(2*p) + j()*0.12, -2*Math.sin(p), 0);
    setRot(c, 'chest',     f, 1.5*Math.sin(2*p), 4*Math.sin(p + Math.PI) + j()*0.12, 0);
    setRot(c, 'neck',      f, 0, -2*Math.sin(p) + j()*0.12, 0);
    setRot(c, 'head',      f, 1.5*Math.sin(2*p) + j()*0.12, 2*Math.sin(p), 0);
    setRot(c, 'upperarm.L',f, -8 + 25*Math.sin(p) + j()*0.12, 0, 8);
    setRot(c, 'upperarm.R',f, -8 + 25*Math.sin(p + Math.PI) + j()*0.12, 0, -8);
    setRot(c, 'forearm.L', f, -(20 + 15*Math.sin(p + 0.6)) + j()*0.12, 0, 0);
    setRot(c, 'forearm.R', f, -(20 + 15*Math.sin(p + 0.6 + Math.PI)) + j()*0.12, 0, 0);
    setRot(c, 'hand.L',    f, 5*Math.sin(p) + j()*0.12, 0, 0);
    setRot(c, 'hand.R',    f, 5*Math.sin(p + Math.PI) + j()*0.12, 0, 0);
    setRot(c, 'thigh.L',   f, 28*Math.sin(p) + j()*0.12, 0, 0);
    setRot(c, 'thigh.R',   f, 28*Math.sin(p + Math.PI) + j()*0.12, 0, 0);
    // knee lock: max() puts a genuine corner in the curve, as in real gait
    setRot(c, 'shin.L',    f, -(4 + 46*Math.max(0, Math.sin(p + 1.2))) + j()*0.12, 0, 0);
    setRot(c, 'shin.R',    f, -(4 + 46*Math.max(0, Math.sin(p + 1.2 + Math.PI))) + j()*0.12, 0, 0);
    setRot(c, 'foot.L',    f, 12*Math.sin(p + 2.0) + j()*0.12, 0, 0);
    setRot(c, 'foot.R',    f, 12*Math.sin(p + 2.0 + Math.PI) + j()*0.12, 0, 0);
    setRot(c, 'toe.L',     f, 30*Math.max(0, Math.sin(p + 2.6)), 0, 0);
    setRot(c, 'toe.R',     f, 30*Math.max(0, Math.sin(p + 2.6 + Math.PI)), 0, 0);
    setRot(c, 'cape',      f, 6*Math.sin(p - 0.8) + j()*0.12, 0, 3*Math.sin(2*p));
    // fingers, prop, jaw, eyes, shoulders: never animated (identity, constant)
  }
  return c;
}

// vertical offset of the root during the jump, in metres. Deliberately
// piecewise: the corners at launch and impact are the point of this clip.
function jumpY(t) {
  if (t < 0.30) return 0;
  if (t < 0.48) return -0.18 * smooth((t - 0.30) / 0.18);         // crouch
  if (t < 0.54) return -0.18 + 0.18 * ((t - 0.48) / 0.06);        // launch (linear, fast)
  if (t < 1.10) { const u = (t - 0.54) / 0.56;                    // airborne parabola
                  return 4 * 0.55 * u * (1 - u); }
  if (t < 1.18) return -0.22 * ((t - 1.10) / 0.08);               // impact
  if (t < 1.45) return -0.22 * (1 - smooth((t - 1.18) / 0.27));   // recover
  return 0;
}
function jumpClip(seconds) {
  const frames = Math.round(seconds * FPS), c = blankClip(frames);
  for (let f = 0; f < frames; f++) {
    const t = f / FPS, h = jumpY(t), hc = jumpY(Math.max(0, t - 0.08));
    const cr = clamp(-h / 0.22, 0, 1);            // crouch amount 0..1
    const air = clamp(h / 0.55, 0, 1);            // airborne amount 0..1
    c.pos[0][0][f] = 0;
    c.pos[0][1][f] = 0.95 + h;
    c.pos[0][2][f] = 0.9 * clamp((t - 0.48) / 0.62, 0, 1) * 1.0;  // forward hop
    // squash and stretch on the root, volume preserving
    const sy = 1 - 0.10 * cr + 0.07 * air;
    c.scl[0][1][f] = sy;
    c.scl[0][0][f] = c.scl[0][2][f] = 1 / Math.sqrt(sy);
    setRot(c, 'hips',       f, -4 - 22*cr + 6*air, 0, 0);
    setRot(c, 'spine',      f, 25*cr - 8*air, 0, 0);
    setRot(c, 'chest',      f, 10*cr - 5*air, 0, 0);
    setRot(c, 'neck',       f, -6*cr, 0, 0);
    setRot(c, 'head',       f, -8*cr + 4*air, 0, 0);
    setRot(c, 'upperarm.L', f, -70*cr + 100*air, 0, 8);
    setRot(c, 'upperarm.R', f, -70*cr + 100*air, 0, -8);
    setRot(c, 'forearm.L',  f, -(15 + 45*air), 0, 0);
    setRot(c, 'forearm.R',  f, -(15 + 45*air), 0, 0);
    setRot(c, 'hand.L',     f, -10*cr + 15*air, 0, 0);
    setRot(c, 'hand.R',     f, -10*cr + 15*air, 0, 0);
    setRot(c, 'thigh.L',    f, 55*cr + 35*air, 0, 0);
    setRot(c, 'thigh.R',    f, 55*cr + 35*air, 0, 0);
    setRot(c, 'shin.L',     f, -(70*cr + 60*air), 0, 0);
    setRot(c, 'shin.R',     f, -(70*cr + 60*air), 0, 0);
    setRot(c, 'foot.L',     f, 20*cr - 30*air, 0, 0);
    setRot(c, 'foot.R',     f, 20*cr - 30*air, 0, 0);
    setRot(c, 'toe.L',      f, 25*cr, 0, 0);
    setRot(c, 'toe.R',      f, 25*cr, 0, 0);
    setRot(c, 'cape',       f, 20 * clamp(-jumpY(0) + (hc - h) * 40, -1, 1), 0, 0);
  }
  return c;
}

// ------------------------------------------------------------ channels
// 30 bones x (3 pos + 3 scale) = 180 scalar channels
// 30 bones x 1 rotation track (quaternion, kept whole) = 30 tracks
// Baseline float32 layout counts 10 floats per bone per frame.
function channels(clip) {
  const sc = [];
  for (let b = 0; b < NB; b++) {
    for (let a = 0; a < 3; a++) sc.push({ id: b*6 + a,     kind: 'pos', bone: b, axis: a, v: clip.pos[b][a] });
    for (let a = 0; a < 3; a++) sc.push({ id: b*6 + 3 + a, kind: 'scl', bone: b, axis: a, v: clip.scl[b][a] });
  }
  // hemisphere-align rotation tracks so component curves are continuous
  const rt = [];
  for (let b = 0; b < NB; b++) {
    const q = clip.rot[b].map(x => x.slice());
    for (let f = 1; f < q.length; f++) if (qdot(q[f-1], q[f]) < 0) q[f] = q[f].map(x => -x);
    rt.push({ bone: b, q });
  }
  return { sc, rt };
}
const isConstScalar = v => { let lo = v[0], hi = v[0];
  for (const x of v) { if (x < lo) lo = x; if (x > hi) hi = x; }
  return hi - lo < 1e-7; };
const isConstQuat = q => q.every(x => qangle(x, q[0]) < 1e-4);

// ------------------------------------------------- reconstruct + fit
function reconScalar(vals, keys, cubic) {
  const n = vals.length, out = new Float64Array(n);
  const slope = i => {                       // non-uniform Catmull-Rom tangent
    if (i === 0) return (vals[keys[1]] - vals[keys[0]]) / (keys[1] - keys[0]);
    if (i === keys.length - 1) return (vals[keys[i]] - vals[keys[i-1]]) / (keys[i] - keys[i-1]);
    return (vals[keys[i+1]] - vals[keys[i-1]]) / (keys[i+1] - keys[i-1]);
  };
  for (let k = 0; k < keys.length - 1; k++) {
    const t0 = keys[k], t1 = keys[k+1], h = t1 - t0;
    const p0 = vals[t0], p1 = vals[t1];
    const m0 = cubic ? slope(k) * h : 0, m1 = cubic ? slope(k+1) * h : 0;
    for (let f = t0; f <= t1; f++) {
      const u = (f - t0) / h;
      out[f] = cubic
        ? (2*u*u*u - 3*u*u + 1)*p0 + (u*u*u - 2*u*u + u)*m0 +
          (-2*u*u*u + 3*u*u)*p1 + (u*u*u - u*u)*m1
        : p0 + (p1 - p0) * u;
    }
  }
  return out;
}
function reconQuat(q, keys, cubic) {
  const n = q.length, out = new Array(n);
  const comp = c => reconScalar(Float64Array.from(q, x => x[c]), keys, cubic);
  const [cx, cy, cz, cw] = [comp(0), comp(1), comp(2), comp(3)];
  for (let f = 0; f < n; f++) out[f] = qnorm([cx[f], cy[f], cz[f], cw[f]]);
  return out;
}
// fit-and-refine: start with {first,last}, insert the worst frame until under eps
function fit(n, err, eps) {
  if (n <= 2) return [...Array(n).keys()];
  let keys = [0, n - 1];
  for (;;) {
    const e = err(keys);
    let worst = -1, wi = -1;
    for (let f = 0; f < n; f++) if (e[f] > worst) { worst = e[f]; wi = f; }
    if (worst <= eps || keys.length >= n) return keys;
    let lo = 0; while (keys[lo] < wi) lo++;
    keys.splice(lo, 0, wi);
  }
}
const fitScalar = (vals, eps, cubic) => fit(vals.length, keys => {
  const r = reconScalar(vals, keys, cubic), e = new Float64Array(vals.length);
  for (let f = 0; f < vals.length; f++) e[f] = Math.abs(r[f] - vals[f]);
  return e;
}, eps);
const fitQuat = (q, epsDeg, cubic) => fit(q.length, keys => {
  const r = reconQuat(q, keys, cubic), e = new Float64Array(q.length);
  for (let f = 0; f < q.length; f++) e[f] = qangle(r[f], q[f]);
  return e;
}, epsDeg);

// ------------------------------------------------------ forward kinem.
function fkFrame(pos, rot, scl, f) {         // -> array of world positions
  const wp = new Array(NB), wq = new Array(NB);
  for (let b = 0; b < NB; b++) {
    const p = RIG[b][1];
    const lp = [pos[b][0][f], pos[b][1][f], pos[b][2][f]];
    const lq = rot[b][f];
    if (p < 0) { wp[b] = lp; wq[b] = lq; }
    else {
      const s = [scl[p][0][f], scl[p][1][f], scl[p][2][f]];
      const off = qrot(wq[p], [lp[0]*s[0], lp[1]*s[1], lp[2]*s[2]]);
      wp[b] = [wp[p][0] + off[0], wp[p][1] + off[1], wp[p][2] + off[2]];
      wq[b] = qmul(wq[p], lq);
    }
  }
  return { wp, wq };
}
function fkError(clip, dec) {                // max/mean joint error, metres
  let mx = 0, sum = 0, cnt = 0, mxDeg = 0;
  for (let f = 0; f < clip.frames; f++) {
    const A = fkFrame(clip.pos, clip.rot, clip.scl, f);
    const B = fkFrame(dec.pos, dec.rot, dec.scl, f);
    for (let b = 0; b < NB; b++) {
      const d = Math.hypot(A.wp[b][0]-B.wp[b][0], A.wp[b][1]-B.wp[b][1], A.wp[b][2]-B.wp[b][2]);
      if (d > mx) mx = d; sum += d; cnt++;
      const a = qangle(A.wq[b], B.wq[b]); if (a > mxDeg) mxDeg = a;
    }
  }
  return { max: mx, mean: sum / cnt, maxDeg: mxDeg };
}

// ------------------------------------------------------- quantisation
function quantScalar(vals, keys) {           // 16-bit fixed point over the used range
  let lo = Infinity, hi = -Infinity;
  for (const k of keys) { if (vals[k] < lo) lo = vals[k]; if (vals[k] > hi) hi = vals[k]; }
  const range = hi - lo;
  const enc = keys.map(k => range === 0 ? 0 : Math.round((vals[k] - lo) / range * 65535));
  const dec = enc.map(q => lo + (range === 0 ? 0 : q / 65535 * range));
  return { lo, range, enc, dec };
}
function quantQuat(q) {                      // smallest three: 2 + 10 + 10 + 10 = 32 bits
  const n = qnorm(q);
  let li = 0; for (let i = 1; i < 4; i++) if (Math.abs(n[i]) > Math.abs(n[li])) li = i;
  const sign = n[li] < 0 ? -1 : 1;
  const S = Math.SQRT1_2, out = [];
  for (let i = 0; i < 4; i++) if (i !== li)
    out.push(Math.round(clamp((n[i]*sign + S) / (2*S), 0, 1) * 1023));
  return { li, e: out };
}
function dequantQuat(p) {
  const S = Math.SQRT1_2, v = p.e.map(x => x / 1023 * 2*S - S);
  const r = []; let k = 0; let ss = 0;
  for (let i = 0; i < 4; i++) { if (i === p.li) r.push(null); else { r.push(v[k]); ss += v[k]*v[k]; k++; } }
  r[p.li] = Math.sqrt(Math.max(0, 1 - ss));
  return r;
}

// ----------------------------------------------------------- encoders
const HDR = 16;
function encodeRaw(clip) {                   // float32 per channel per frame
  const buf = Buffer.alloc(HDR + clip.frames * NB * 10 * 4);
  let o = HDR;
  for (let f = 0; f < clip.frames; f++) for (let b = 0; b < NB; b++) {
    for (let a = 0; a < 3; a++) { buf.writeFloatLE(clip.pos[b][a][f], o); o += 4; }
    for (let a = 0; a < 4; a++) { buf.writeFloatLE(clip.rot[b][f][a], o); o += 4; }
    for (let a = 0; a < 3; a++) { buf.writeFloatLE(clip.scl[b][a][f], o); o += 4; }
  }
  return buf;
}
// Track container. Scalar record: u16 id, u16 keyCount, [u16 frame + value]*.
// Constant record: u16 id, u16 count=1, one value, no frame index.
// Quat record: u16 bone, u16 keyCount, [u16 frame + quat]*.
function encodeTracks(plan, quant) {
  const parts = [Buffer.alloc(HDR)];
  for (const t of plan.sc) {
    const n = t.keys.length, konst = t.const;
    const vb = quant ? 2 : 4, extra = quant && !konst ? 8 : 0;
    const b = Buffer.alloc(4 + extra + (konst ? vb : n * (2 + vb)));
    b.writeUInt16LE(t.id, 0); b.writeUInt16LE(konst ? 1 : n, 2);
    let o = 4;
    if (quant && !konst) { b.writeFloatLE(t.q.lo, o); b.writeFloatLE(t.q.range, o+4); o += 8; }
    if (konst) { quant ? b.writeUInt16LE(0, o) : b.writeFloatLE(t.v[0], o); }
    else for (let i = 0; i < n; i++) {
      b.writeUInt16LE(t.keys[i], o); o += 2;
      if (quant) { b.writeUInt16LE(t.q.enc[i], o); o += 2; }
      else { b.writeFloatLE(t.v[t.keys[i]], o); o += 4; }
    }
    parts.push(b);
  }
  for (const t of plan.rt) {
    const n = t.keys.length, konst = t.const;
    const vb = quant ? 4 : 16;
    const b = Buffer.alloc(4 + (konst ? vb : n * (2 + vb)));
    b.writeUInt16LE(t.bone, 0); b.writeUInt16LE(konst ? 1 : n, 2);
    let o = 4;
    const wq = (qq) => { if (quant) { const p = quantQuat(qq);
        b.writeUInt32LE(((p.li << 30) | (p.e[0] << 20) | (p.e[1] << 10) | p.e[2]) >>> 0, o); o += 4; }
      else { for (let i = 0; i < 4; i++) { b.writeFloatLE(qq[i], o); o += 4; } } };
    if (konst) wq(t.q[0]);
    else for (const k of t.keys) { b.writeUInt16LE(k, o); o += 2; wq(t.q[k]); }
    parts.push(b);
  }
  return Buffer.concat(parts);
}

// build a compression plan at a given tolerance, and its decoded clip
function plan(clip, epsM, epsDeg, cubic, quant, dropConst) {
  const ch = channels(clip), n = clip.frames;
  const sc = [], rt = [];
  const dec = blankClip(n);
  for (const c of ch.sc) {
    const konst = dropConst && isConstScalar(c.v);
    let keys = konst ? [0] : (epsM === null ? [...Array(n).keys()]
                                            : fitScalar(c.v, epsM, cubic));
    const rec = { ...c, keys, const: konst };
    if (quant) rec.q = quantScalar(c.v, konst ? [0] : keys);
    sc.push(rec);
    // decode
    const arr = dec[c.kind === 'pos' ? 'pos' : 'scl'][c.bone][c.axis];
    if (konst) arr.fill(quant ? rec.q.dec[0] : c.v[0]);
    else {
      const src = quant ? (() => { const t = Float64Array.from(c.v);
          keys.forEach((k, i) => { t[k] = rec.q.dec[i]; }); return t; })()
        : c.v;
      const r = reconScalar(src, keys, cubic);
      for (let f = 0; f < n; f++) arr[f] = r[f];
    }
  }
  for (const t of ch.rt) {
    const konst = dropConst && isConstQuat(t.q);
    let keys = konst ? [0] : (epsDeg === null ? [...Array(n).keys()]
                                              : fitQuat(t.q, epsDeg, cubic));
    rt.push({ ...t, keys, const: konst });
    const src = quant ? t.q.map(x => dequantQuat(quantQuat(x))) : t.q;
    if (konst) { const q = qnorm(src[0]); for (let f = 0; f < n; f++) dec.rot[t.bone][f] = q; }
    else {
      const r = reconQuat(src, keys, cubic);
      for (let f = 0; f < n; f++) dec.rot[t.bone][f] = r[f];
    }
  }
  return { sc, rt, dec, keysScalar: sc.reduce((a, r) => a + (r.const ? 1 : r.keys.length), 0),
           keysQuat: rt.reduce((a, r) => a + (r.const ? 1 : r.keys.length), 0),
           constScalar: sc.filter(r => r.const).length, constQuat: rt.filter(r => r.const).length };
}

const gz = b => zlib.gzipSync(b, { level: 6 }).length;
const f2 = (x, d = 2) => x.toFixed(d);

// ============================================================ the report
const LEVER = 0.35;                       // metres, upper arm + forearm
const mmToDeg = mm => (mm / 1000 / LEVER) * 180 / Math.PI;
const CLIPS = { walk: walkClip(3, false), jump: jumpClip(2), mocap: walkClip(3, true) };

for (const [name, clip] of Object.entries(CLIPS)) {
  const ch = channels(clip);
  console.log(`${name.padEnd(6)} ${clip.frames} frames @${FPS}fps  ` +
    `${ch.sc.filter(c => isConstScalar(c.v)).length}/180 scalar channels constant, ` +
    `${ch.rt.filter(t => isConstQuat(t.q)).length}/30 rotation tracks constant`);
}

const EPS_MM = 1;
for (const [name, clip] of Object.entries(CLIPS)) {
  const secs = clip.frames / FPS, e = EPS_MM/1000, d = mmToDeg(EPS_MM);
  console.log(`\n### ${name} — ${secs.toFixed(2)} s, tolerance ${EPS_MM} mm / ${d.toFixed(3)} deg`);
  console.log('technique'.padEnd(42) + '  bytes     B/s    gzip   gzB/s  maxErr_mm  maxDeg');
  const show = (label, buf, err) => console.log(`${label.padEnd(42)} ${String(buf.length).padStart(6)} ` +
    `${(buf.length/secs).toFixed(0).padStart(7)} ${String(gz(buf)).padStart(7)} ` +
    `${(gz(buf)/secs).toFixed(0).padStart(7)} ${(err ? (err.max*1000).toFixed(2) : '0').padStart(9)} ` +
    `${(err ? err.maxDeg.toFixed(3) : '0').padStart(7)}`);
  show('1 raw float32, every channel every frame', encodeRaw(clip), null);
  const P = {
    2: [null, null, false, false, true],  3: [e, d, false, false, false],
    4: [e, d, false, false, true],        5: [e, d, true, false, true],
    6: [e, d, true, true, true],          7: [null, null, false, true, true],
  };
  const L = { 2: '2 + constant-channel removal (lossless)', 3: '3 keyframe reduction only, linear',
              4: '4 constant removal + linear keyframes',   5: '5 constant removal + cubic keyframes',
              6: '6 5 + 16-bit pos/scale + smallest-3 quat', 7: '7 constant removal + quantisation only' };
  const plans = {};
  for (const k of Object.keys(P)) {
    const p = plan(clip, ...P[k]); plans[k] = p;
    show(L[k], encodeTracks(p, P[k][3]), fkError(clip, p.dec));
  }
  const at = (180 - plans[4].constScalar) + (30 - plans[4].constQuat);
  const kd = p => (p.keysScalar + p.keysQuat - p.constScalar - p.constQuat) / at / secs;
  console.log(`   ${at} animated tracks; keys kept per animated track per second: ` +
    `linear ${kd(plans[4]).toFixed(1)}/60 (${(kd(plans[4])/60*100).toFixed(1)}%), ` +
    `cubic ${kd(plans[5]).toFixed(1)}/60 (${(kd(plans[5])/60*100).toFixed(1)}%)`);
}

console.log('\n### walk — tolerance sweep (constant removal always on)');
console.log('interp  eps_mm  eps_deg    keys   bytes    B/s    gzip  maxErr_mm  maxDeg');
for (const cubic of [false, true]) for (const mm of [0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 50]) {
  const clip = CLIPS.walk, p = plan(clip, mm/1000, mmToDeg(mm), cubic, false, true);
  const b = encodeTracks(p, false), err = fkError(clip, p.dec);
  console.log(`${cubic?'cubic':'lin  '} ${String(mm).padStart(7)} ${mmToDeg(mm).toFixed(3).padStart(8)} ` +
    `${String(p.keysScalar+p.keysQuat).padStart(7)} ${String(b.length).padStart(7)} ` +
    `${(b.length/3).toFixed(0).padStart(6)} ${String(gz(b)).padStart(7)} ` +
    `${(err.max*1000).toFixed(2).padStart(10)} ${err.maxDeg.toFixed(3).padStart(7)}`);
}

console.log('\n### jump — does a cubic curve help at a tight tolerance?');
for (const mm of [0.1, 1]) for (const cubic of [false, true]) {
  const p = plan(CLIPS.jump, mm/1000, mmToDeg(mm), cubic, false, true);
  const b = encodeTracks(p, false), err = fkError(CLIPS.jump, p.dec);
  console.log(`${cubic?'cubic':'lin  '} ${String(mm).padStart(5)} mm  keys ` +
    `${String(p.keysScalar+p.keysQuat).padStart(5)}  bytes ${String(b.length).padStart(6)}  ` +
    `maxErr ${(err.max*1000).toFixed(2)} mm`);
}

// ------------------------------------------------------------ decode cost
const clip = CLIPS.walk, N = clip.frames;
const eM = EPS_MM/1000, eD = mmToDeg(EPS_MM);
function build(p, quant) {
  const sc = p.sc.map(t => ({ const: t.const,
    f: Uint16Array.from(t.const ? [0] : t.keys),
    v: quant ? Uint16Array.from(t.const ? [0] : t.q.enc)
             : Float32Array.from(t.const ? [t.v[0]] : t.keys.map(k => t.v[k])),
    lo: quant ? t.q.lo : 0, rg: quant ? t.q.range : 0,
    c0: t.const ? (quant ? t.q.dec[0] : t.v[0]) : 0 }));
  const rt = p.rt.map(t => { const keys = t.const ? [0] : t.keys;
    const q = new Float32Array(keys.length*4);
    keys.forEach((k,i) => q.set(quant ? dequantQuat(quantQuat(t.q[k])) : t.q[k], i*4));
    return { const: t.const, f: Uint16Array.from(keys), q }; });
  return { sc, rt };
}
function sampleTracks(S, t, cubic, quant, out) {
  for (let i = 0; i < S.sc.length; i++) {
    const tr = S.sc[i];
    if (tr.const) { out[i] = quant ? tr.c0 : tr.v[0]; continue; }
    const f = tr.f; let lo = 0, hi = f.length - 1;
    while (hi - lo > 1) { const m = (lo+hi) >> 1; if (f[m] <= t) lo = m; else hi = m; }
    const g = quant ? (j => tr.lo + tr.v[j]/65535*tr.rg) : (j => tr.v[j]);
    const t0 = f[lo], t1 = f[hi], u = (t-t0)/(t1-t0), p0 = g(lo), p1 = g(hi);
    if (!cubic) { out[i] = p0 + (p1-p0)*u; continue; }
    const h = t1-t0;
    const m0 = (lo===0 ? (g(1)-g(0))/(f[1]-f[0]) : (g(lo+1)-g(lo-1))/(f[lo+1]-f[lo-1]))*h;
    const m1 = (hi===f.length-1 ? (g(hi)-g(hi-1))/(f[hi]-f[hi-1]) : (g(hi+1)-g(hi-1))/(f[hi+1]-f[hi-1]))*h;
    const u2 = u*u, u3 = u2*u;
    out[i] = (2*u3-3*u2+1)*p0 + (u3-2*u2+u)*m0 + (-2*u3+3*u2)*p1 + (u3-u2)*m1;
  }
  let o = S.sc.length;
  for (let i = 0; i < S.rt.length; i++) {
    const tr = S.rt[i], q = tr.q;
    if (tr.const) { out[o]=q[0]; out[o+1]=q[1]; out[o+2]=q[2]; out[o+3]=q[3]; o+=4; continue; }
    const f = tr.f; let lo = 0, hi = f.length-1;
    while (hi-lo > 1) { const m = (lo+hi)>>1; if (f[m] <= t) lo = m; else hi = m; }
    const u = (t-f[lo])/(f[hi]-f[lo]);
    const d = q[lo*4]*q[hi*4]+q[lo*4+1]*q[hi*4+1]+q[lo*4+2]*q[hi*4+2]+q[lo*4+3]*q[hi*4+3];
    const s = d < 0 ? -1 : 1;
    const x=q[lo*4]+(s*q[hi*4]-q[lo*4])*u,      y=q[lo*4+1]+(s*q[hi*4+1]-q[lo*4+1])*u;
    const z=q[lo*4+2]+(s*q[hi*4+2]-q[lo*4+2])*u, w=q[lo*4+3]+(s*q[hi*4+3]-q[lo*4+3])*u;
    const l = 1/Math.hypot(x,y,z,w);
    out[o]=x*l; out[o+1]=y*l; out[o+2]=z*l; out[o+3]=w*l; o+=4;
  }
}
const rawBuf = encodeRaw(clip), raw = new Float32Array(N*300);
for (let i = 0; i < N*300; i++) raw[i] = rawBuf.readFloatLE(16+i*4);
const sampleRaw = (t, out) => { const f0 = Math.floor(t), f1 = Math.min(N-1,f0+1), u = t-f0;
  const a = f0*300, b = f1*300;
  for (let i = 0; i < 300; i++) out[i] = raw[a+i] + (raw[b+i]-raw[a+i])*u; };
const times = new Float64Array(4096);
for (let i = 0; i < times.length; i++) times[i] = (i*0.7) % (N-1.001);
const out = new Float64Array(300);
function bench(fn, label) {
  for (let w = 0; w < 30; w++) for (let i = 0; i < times.length; i++) fn(times[i], out);
  const r = [];
  for (let k = 0; k < 9; k++) { const t0 = process.hrtime.bigint();
    for (let i = 0; i < times.length; i++) fn(times[i], out);
    r.push(Number(process.hrtime.bigint()-t0)/times.length); }
  r.sort((a,b)=>a-b);
  console.log(`${label.padEnd(46)} ${r[4].toFixed(0).padStart(5)} ns per skeleton sample`);
}
const Slin = build(plan(clip, eM, eD, false, false, true), false);
const Scub = build(plan(clip, eM, eD, true,  false, true), false);
const Sq   = build(plan(clip, eM, eD, true,  true,  true), true);
console.log('\n### decode cost, one full 30-bone pose (300 values)');
bench(sampleRaw, 'raw float32, lerp between two frames');
bench((t,o)=>sampleTracks(Slin,t,false,false,o), 'linear keyframes, binary search + lerp');
bench((t,o)=>sampleTracks(Scub,t,true, false,o), 'cubic keyframes, binary search + Hermite');
bench((t,o)=>sampleTracks(Sq,  t,true, true, o), 'quantised cubic keys, dequantise + Hermite');

// -------------------------------------------------- gzip, split by record
console.log('\n### gzip on the packed stream, by record type (walk, cubic keys)');
for (const [lab, q] of [['float32 keys', false], ['quantised keys', true]]) {
  const p = plan(clip, eM, eD, true, q, true), C = [], An = [];
  for (const t of p.sc) (t.const?C:An).push(encodeTracks({sc:[t],rt:[]}, q).subarray(16));
  for (const t of p.rt) (t.const?C:An).push(encodeTracks({sc:[],rt:[t]}, q).subarray(16));
  const c = Buffer.concat(C), a = Buffer.concat(An), b = Buffer.concat([c,a]);
  console.log(`${lab}: constant ${c.length} B -> ${gz(c)} B (${(c.length/gz(c)).toFixed(2)}x), ` +
    `animated ${a.length} B -> ${gz(a)} B (${(a.length/gz(a)).toFixed(2)}x), ` +
    `whole ${b.length} B -> ${gz(b)} B (${(b.length/gz(b)).toFixed(2)}x)`);
}
console.log('\n### 1.8 m character: millimetres per screen pixel');
console.log([48,64,128,256,512,1080].map(px => `${px}px=${(1800/px).toFixed(2)}mm`).join('  '));

// ------------------------------- is a cached cursor faster than a search?
const scal = plan(clip, eM, eD, true, false, true).sc.map(t => ({ const: t.const,
  f: Uint16Array.from(t.const ? [0] : t.keys),
  v: Float32Array.from(t.const ? [t.v[0]] : t.keys.map(k => t.v[k])) }));
const cur = new Uint16Array(scal.length), o2 = new Float64Array(scal.length);
function seqBin(t) { for (let i = 0; i < scal.length; i++) { const tr = scal[i];
  if (tr.const) { o2[i] = tr.v[0]; continue; }
  const f = tr.f; let lo = 0, hi = f.length-1;
  while (hi-lo > 1) { const m = (lo+hi)>>1; if (f[m] <= t) lo = m; else hi = m; }
  o2[i] = tr.v[lo] + (tr.v[hi]-tr.v[lo]) * ((t-f[lo])/(f[hi]-f[lo])); } }
function seqCur(t) { for (let i = 0; i < scal.length; i++) { const tr = scal[i];
  if (tr.const) { o2[i] = tr.v[0]; continue; }
  const f = tr.f; let c = cur[i]; if (f[c] > t) c = 0;
  while (c+2 < f.length && f[c+1] <= t) c++;
  cur[i] = c; o2[i] = tr.v[c] + (tr.v[c+1]-tr.v[c]) * ((t-f[c])/(f[c+1]-f[c])); } }
console.log('\n### sequential playback, 180 scalar channels only');
bench(t => seqBin(t), 'binary search per track');
bench(t => seqCur(t), 'cached cursor, advance forward');

// ------------------------------------------ where do the jump's keys land?
const pj = plan(CLIPS.jump, eM, eD, false, false, true);
const hist = new Float64Array(CLIPS.jump.frames);
for (const t of [...pj.sc, ...pj.rt]) if (!t.const) for (const k of t.keys) hist[k]++;
let tot = 0; for (const h of hist) tot += h;
console.log('\n### jump: where the keyframes land');
for (const [lab,a,b] of [['0.00-0.30 idle',0,18],['0.30-0.48 crouch',18,29],
  ['0.48-0.54 launch',29,32],['0.54-1.10 airborne',32,66],['1.10-1.18 impact',66,71],
  ['1.18-1.45 recover',71,87],['1.45-2.00 settle',87,120]]) {
  let s = 0; for (let f = a; f < b; f++) s += hist[f];
  console.log(`${lab.padEnd(19)} ${String(b-a).padStart(3)} frames  ${String(s).padStart(4)} keys ` +
    `(${(s/tot*100).toFixed(1).padStart(5)}%, ${(s/(b-a)).toFixed(1).padStart(5)} keys/frame)`); }
console.log(`total animated keys ${tot} over ${CLIPS.jump.frames} frames`);

On the M3 laptop above that prints:

walk   180 frames @60fps  177/180 scalar channels constant, 10/30 rotation tracks constant
jump   120 frames @60fps  175/180 scalar channels constant, 10/30 rotation tracks constant
mocap  180 frames @60fps  177/180 scalar channels constant, 10/30 rotation tracks constant

### walk — 3.00 s, tolerance 1 mm / 0.164 deg
technique                                   bytes     B/s    gzip   gzB/s  maxErr_mm  maxDeg
1 raw float32, every channel every frame   216016   72005   31818   10606         0       0
2 + constant-channel removal (lossless)     69764   23255   18712    6237      0.00   0.000
3 keyframe reduction only, linear           25684    8561    8247    2749      4.69   0.508
4 constant removal + linear keyframes       24068    8023    8132    2711      4.69   0.508
5 constant removal + cubic keyframes        17486    5829    6736    2245      3.78   0.427
6 5 + 16-bit pos/scale + smallest-3 quat     6688    2229    3650    1217      7.24   0.857
7 constant removal + quantisation only      25034    8345   13314    4438      7.26   0.864
   23 animated tracks; keys kept per animated track per second: linear 19.0/60 (31.7%), cubic 13.5/60 (22.4%)

### jump — 2.00 s, tolerance 1 mm / 0.164 deg
technique                                   bytes     B/s    gzip   gzB/s  maxErr_mm  maxDeg
1 raw float32, every channel every frame   144016   72008   11716    5858         0       0
2 + constant-channel removal (lossless)     48516   24258   11168    5584      0.00   0.000
3 keyframe reduction only, linear           17794    8897    5978    2989      3.80   0.304
4 constant removal + linear keyframes       16194    8097    5860    2930      3.80   0.304
5 constant removal + cubic keyframes        15336    7668    4955    2478      3.30   0.325
6 5 + 16-bit pos/scale + smallest-3 quat     6106    3053    2505    1253      8.40   1.016
7 constant removal + quantisation only      18086    9043    5786    2893      8.13   0.941
   25 animated tracks; keys kept per animated track per second: linear 18.1/60 (30.1%), cubic 17.0/60 (28.3%)

### mocap — 3.00 s, tolerance 1 mm / 0.164 deg
technique                                   bytes     B/s    gzip   gzB/s  maxErr_mm  maxDeg
1 raw float32, every channel every frame   216016   72005   45137   15046         0       0
2 + constant-channel removal (lossless)     69764   23255   43491   14497      0.00   0.000
3 keyframe reduction only, linear           26890    8963   16408    5469      5.03   0.426
4 constant removal + linear keyframes       25274    8425   16279    5426      5.03   0.426
5 constant removal + cubic keyframes        24134    8045   15443    5148      4.58   0.486
6 5 + 16-bit pos/scale + smallest-3 quat     8894    2965    5217    1739      7.08   0.807
7 constant removal + quantisation only      25034    8345   14427    4809      5.88   0.764
   23 animated tracks; keys kept per animated track per second: linear 20.0/60 (33.3%), cubic 18.8/60 (31.3%)

### walk — tolerance sweep (constant removal always on)
interp  eps_mm  eps_deg    keys   bytes    B/s    gzip  maxErr_mm  maxDeg
lin       0.1    0.016    3365   55544  18515   15077       0.32   0.044
lin      0.25    0.041    2706   44498  14833   12835       0.89   0.118
lin       0.5    0.082    2068   33878  11293   10446       1.85   0.285
lin         1    0.164    1501   24068   8023    8132       4.69   0.508
lin         2    0.327    1126   17762   5921    6447       9.14   0.941
lin         5    0.819     791   11984   3995    4600      17.74   2.385
lin        10    1.637     587    8636   2879    3457      37.46   4.434
lin        20    3.274     460    6350   2117    2589      69.45   9.093
lin        50    8.185     329    4208   1403    1621     136.83  16.318
cubic     0.1    0.016    2675   44336  14779   12979       0.34   0.046
cubic    0.25    0.041    1864   30482  10161   10073       0.85   0.101
cubic     0.5    0.082    1410   22682   7561    8298       1.74   0.241
cubic       1    0.164    1116   17486   5829    6736       3.78   0.427
cubic       2    0.327     860   13214   4405    5241       8.83   1.102
cubic       5    0.819     663   10004   3335    3952      13.96   1.950
cubic      10    1.637     547    7916   2639    3252      37.35   3.943
cubic      20    3.274     411    5468   1823    2270      70.80   9.061
cubic      50    8.185     322    4082   1361    1637     129.58  12.126

### jump — does a cubic curve help at a tight tolerance?
lin     0.1 mm  keys  1574  bytes  23418  maxErr 0.11 mm
cubic   0.1 mm  keys  1677  bytes  25416  maxErr 0.21 mm
lin       1 mm  keys  1088  bytes  16194  maxErr 3.80 mm
cubic     1 mm  keys  1035  bytes  15336  maxErr 3.30 mm

### decode cost, one full 30-bone pose (300 values)
raw float32, lerp between two frames             243 ns per skeleton sample
linear keyframes, binary search + lerp          1900 ns per skeleton sample
cubic keyframes, binary search + Hermite        2029 ns per skeleton sample
quantised cubic keys, dequantise + Hermite      2019 ns per skeleton sample

### gzip on the packed stream, by record type (walk, cubic keys)
float32 keys: constant 1616 B -> 544 B (2.97x), animated 15854 B -> 6138 B (2.58x), whole 17470 B -> 6733 B (2.59x)
quantised keys: constant 1142 B -> 356 B (3.21x), animated 5530 B -> 3276 B (1.69x), whole 6672 B -> 3637 B (1.83x)

### 1.8 m character: millimetres per screen pixel
48px=37.50mm  64px=28.13mm  128px=14.06mm  256px=7.03mm  512px=3.52mm  1080px=1.67mm

### sequential playback, 180 scalar channels only
binary search per track                          374 ns per skeleton sample
cached cursor, advance forward                   359 ns per skeleton sample

### jump: where the keyframes land
0.00-0.30 idle       18 frames    25 keys (  2.8%,   1.4 keys/frame)
0.30-0.48 crouch     11 frames   160 keys ( 17.7%,  14.5 keys/frame)
0.48-0.54 launch      3 frames    25 keys (  2.8%,   8.3 keys/frame)
0.54-1.10 airborne   34 frames   395 keys ( 43.7%,  11.6 keys/frame)
1.10-1.18 impact      5 frames    49 keys (  5.4%,   9.8 keys/frame)
1.18-1.45 recover    16 frames   205 keys ( 22.7%,  12.8 keys/frame)
1.45-2.00 settle     33 frames    44 keys (  4.9%,   1.3 keys/frame)
total animated keys 903 over 120 frames

The sizes and error figures are exact on every run, because the clips come from a seeded generator; only the four decode timings and the two playback timings move.

If you rig characters with bones and want the animation drawn out as sprites rather than stored as curves, that is what Game Asset Generator & Builder does: describe the motion, let it draw every frame, then clean, slice and export.