Every deliberate deviation from the build specification, plus the decisions the spec left open, plus the bugs whose fixes changed a design. Each entry states what was decided and why, so a later maintainer can overturn it on the merits rather than guessing.
Decision. Vanilla ES modules, no bundler, no framework, no build step — matching Gear3D, LEAPS, QR Studio and Cross-Section Studio.
Why. The spec asks for Astro 5, React 19 and Tailwind v4. This app lives inside a Jekyll site that GitHub Pages builds itself; there is no Node step in the deploy, and introducing one would mean restructuring the deployment of the whole site for one app. Every other E-Labs app is a standalone SPA of plain modules, and consistency across them is worth more here than any framework would be. The spec’s real requirement — marketing pages ship no JavaScript, the analyzer is one contained island — is satisfied trivially: the analyzer is a separate page and nothing else on the site loads any of it.
What was kept. The architectural point the framework choice was serving:
src/engine/ imports nothing from the DOM, no browser API and no UI code. It
takes typed arrays in and returns results, which is what makes it testable
against synthetic signals, which is the only way to know the math is right.
Decision. The spec’s routes (/analyze, /history, /compare,
/exercises, /runners, /professionals, /retailers, /about, /blog)
collapse into one app page plus a science.html, with history, comparison and
the exercise library as dock panes and metric sheets.
Why. The marketing routes describe a company. This is one tool in a personal
academic site that already has its own home page, blog and about page; adding
/retailers would be describing a business that does not exist. The functional
routes are real and are all present — they are panes rather than pages because
the app is a single workbench in the same shape as LEAPS, and because a runner
comparing two analyses wants them beside the player, not on another page.
What was dropped outright, and why it is honest to drop it. Team dashboards and cloud sync: the spec itself marks these as not viable on a static host. They are not faked. Export and import of a signed bundle is offered instead, and the history pane says plainly that history lives in one browser on one device.
Decision. About 300 lines of Canvas 2D in src/ui/charts.js.
Why. Three reasons, all binding. The app must work with the network off after one visit, and a plotting library from a CDN does not. Every chart must be readable at 360 px and must have a table alternative, which is easier to guarantee when the rendering is ours. And the figure this app actually needs — a mean joint-angle curve with a ±1 SD band across strides, normalized to the gait cycle, with the stance fraction shaded — is thirty lines of canvas and an awkward fit for a general-purpose library. LEAPS uses Plotly from a CDN; that was considered for consistency and rejected on the offline requirement.
Decision. src/engine/decode/mp4.js parses ISO-BMFF directly rather than
depending on mp4box.js.
Why. Every timing measurement in this app is a difference between two
presentation timestamps, so the timestamps are not a detail to delegate. It is
also one fewer cross-origin script, which matters when the headline claim is
that nothing leaves the device. The subset needed is small; the parts that are
NOT small are detected and refused rather than guessed at — fragmented MP4,
non-trivial edit lists and anything that is not ISO-BMFF fall through to the
requestVideoFrameCallback path, which is marked timingConfidence: 'reduced'
in the analysis metadata.
ctts is parsed. With B-frames the decode order is not the display order, and
using decode time as presentation time silently reorders the video.
Decision. trunkLean = -signedAngle(vertical, hipMid → shoulderMid).
Why. The spec’s metric table gives signedAngle(vertical, hipMid →
shoulderMid), and its own regression test (§13.2) requires a forward lean to
produce a POSITIVE value. In a frame where +x is the direction of travel those
two requirements are opposite: a forward lean rotates the trunk vector
clockwise, so the expression is negative. The report follows the regression
test, because “positive means leaning forward” is what a reader assumes. The
negation is applied once, in metrics/angles.js, and the test asserts it in
both directions of travel.
Decision. A sixth foot-strike detector, weight 1.3, keyed on the lower of heel and toe reaching the ground.
Why. This is the most consequential change made to the specified design, and it was made because the specified design demonstrably failed. The spec lists five detectors of which two (M1, M2) are heel-based — and those two are not independent of each other, they are the same landmark seen through position and through its derivative. Together they outweigh everything else. On a forefoot striker the heel does not reach the ground until roughly 70 ms after the foot does, so a vote dominated by heel evidence places every forefoot contact 70 ms late and shortens every measured contact time by the same amount.
Measured on the synthetic forefoot runner before M0 existed: 72 ms mean absolute error, against 6 ms for the rearfoot runner. After: 4 ms and 0.1 ms. That is a bias applied to one population and not the other, falling exactly on the distinction the product exists to measure — which is the failure mode the spec’s own §5.8 warns about, arrived at through the detector set it specifies.
Contact is the moment any part of the foot arrives. Tracking the lower of heel and toe makes that literal, and which of the two it turns out to be is the strike pattern itself.
Decision. The position-based detectors (M0, M1, M3, M4) take the ONSET of
the flat region, not the extremum localMinima reports.
Why. A foot on the ground does not trace a sharp minimum in heel height. It descends, arrives, and then dwells for most of stance. A naive “minimum of heel_y” fires at the far end of that plateau — tens of milliseconds after contact, which at 240 fps is many frame periods and is a systematic bias, not noise, so averaging more strides does not remove it. Measured before the fix: every strike 71 ms late on the synthetic runner.
The same argument in reverse gives plateauEnd for toe-off.
Decision. Foot strikes are detected first; each toe-off is then voted for
only within [strike + 100 ms, strike + 400 ms].
Why. The knee reaches full extension twice per stride — once approaching toe-off and again in terminal swing — and on a real trajectory the two peaks are the same height. No amount of prominence ranking separates them, and the detector was picking the terminal-swing peak roughly half the time. Restricting the search to the plausible stance duration removes the wrong peak by construction. This is not circular: toe-off is defined relative to a strike, and the window is the sanity constraint the spec already states.
Decision. The toe is in front of the heel; the median of toe.x - heel.x
gives the direction. Hip velocity is computed as a cross-check only.
Why. Hip velocity is zero on a treadmill by construction. Foot velocity fails too, and less obviously: on a treadmill the planted foot travels backward at belt speed for two thirds of the cycle while the swing lasts about a tenth of a second, so once the trajectories are low-pass filtered the sustained backward stance velocity is LARGER in magnitude than the brief forward swing peak, and “whichever way the foot moves fastest” points backward. That produced a mirrored analysis frame in which every angle was reflected and still looked plausible. Foot orientation is an anatomical invariant, available on every frame, and independent of the surface.
Decision. Each event carries three numbers — rangeMs (max − min),
spreadMs (weighted SD about the voted time) and sigmaMs (the standard error
of the weighted mean, via Kish’s effective sample size). The low-confidence gate
and the error budget use sigmaMs.
Why. The spec says to record the inter-method spread and to mark a stride low-confidence when it exceeds 25 ms, using the range. The range grows with the number of methods that voted: on noiseless synthetic gait at 240 fps, three methods agreeing to within a frame or two already produce a ~29 ms range, and every stride in a perfect clip was being discarded.
Switching to a weighted SD fixed that and left a subtler version of the same perverse incentive. A midfoot strike is seen by five detectors instead of three, because heel and toe arrive together; they disagree by slightly more, the SD crossed the limit, and 16 of 17 strides were thrown away — better evidence producing a worse answer. The standard error is the quantity the gate actually wants: how well the consensus is determined, which correctly falls as more independent estimates agree. All three numbers are retained because they answer different questions and the diagnostics show the spread.
Decision. A cluster is seeded on the candidate with the heaviest neighborhood, not on the heaviest candidate.
Why. Seeding on “the heaviest, earliest” meant that when noise scattered several candidates of one method across the window, the earliest of them anchored the cluster and dragged the event early. The bias therefore grew with noise, which is backward for something meant to be robust: measured contact time fell from 216 ms to 191 ms as landmark noise rose from zero to 0.8% of leg length, against a truth of 215 ms. Asking which candidate has the most agreement around it does not depend on ordering at all.
Decision. The weighted median locates the cluster; the weighted mean of the members within a window around it gives the reported time.
Why. The spec asks for a weighted circular median. The median’s breakdown point is what makes a detector firing in the wrong place harmless, and that is worth keeping. But once the outliers are gone the median throws away most of the information — with three methods it reports one of them and ignores the other two. Taking the mean of the survivors reduced foot-strike error from about 15 ms to about 8 ms on the synthetic runner at 240 fps. (“Circular” is dropped: these are linear times, not phases.)
Decision. M4 looks for the plateau in |foot.vx − stanceVx|, where
stanceVx is estimated from the data.
Why. The spec describes M4 as “local minimum of foot horizontal velocity
magnitude”. On a treadmill the planted foot travels backward at belt speed for
the whole of stance, so a detector looking for a stationary foot finds nothing
at all. What is invariant across surfaces is that stance velocity is CONSTANT,
whatever its value. stanceVx is the median of the slower half of the foot’s
own velocity distribution, which stance dominates — zero overground, minus the
belt speed on a treadmill, without being told which.
Decision. N2 is the upward zero crossing of toe vertical velocity inside the stance window, or the end of the toe’s ground plateau.
Why. The spec words N2 as “local maximum of toe_y after the stance minimum”.
Read literally that is peak SWING height, roughly 150 ms after the foot has left
the ground, and it would drag every toe-off late and corrupt the vote.
TODO(spec) marks this in the source.
Decision. The geometric detector ships alone. The complete stage-2 inference
and fusion path is implemented and exercised by the test suite with a synthetic
“perfect model”, but no .onnx file is included, and results record
stage2: 'not-shipped'.
Why. Training Model A honestly needs running video with force-plate or instrumented-treadmill ground truth, split by subject rather than by clip. None was available. The spec’s own instruction covers this case: “If they are not better than the geometric baseline, ship the geometric baseline and say so.” Shipping a model trained on nothing, or quoting FootNet’s published accuracy as though it were ours, were the dishonest alternatives.
What was rejected outright, per the spec and independently on the merits: a black-box model emitting a “running form score”. No labeled ground truth for good form exists, the output would be unauditable, and explainability is the entire product.
Decision. No overall score out of 100 exists anywhere in the result object, and a test asserts its absence.
Why. The spec argues for this and it is right. Compressing independent, non-commensurable dimensions into one figure invites comparison between people that the measurement cannot support, and it cannot be explained when somebody asks why it moved. Open question 2 in the spec offers the human an override; if one is wanted, add it beside the dimension breakdown rather than instead of it.
Decision. references.js carries an explicit indicative-unsourced entry.
Bands taken from the spec’s Appendix B cite it, are forced to
strength: 'consensus-only', and are weighted lowest in scoring. A test asserts
that no unsourced band claims a higher strength.
Why. Appendix B supplies a table of literature-typical ranges and then says, in the same breath, that they must not ship without citations. Attaching a plausible-looking paper to a number that did not come from it would have been worse than shipping nothing. This is uncomfortable to display and it is the honest thing to display. Replacing these with sourced, speed- and sex-conditional bands is the single highest-value improvement available.
Decision. MediaPipe Tasks Vision and the .task weights are fetched from
jsDelivr and Google’s model storage on first analysis, then cached by the
service worker forever.
Why. The spec asks for self-hosting. The two model files are roughly 9 MB
and 29 MB; this is a personal academic site’s repository and GitHub Pages does
not serve Git LFS, so they would be committed as ordinary blobs. The claim that
matters — no video, no frame and no landmark ever leaves the device — is
unaffected and remains literally true: these are downloads TO the device.
Self-hosting is still the better end state and the modelBase option exists to
switch to it in one line.
Consequence accepted. The very first analysis needs a network connection. Every subsequent one does not, including with the network off.
Decision. No COOP/COEP service-worker shim. The single-threaded WASM build with the GPU delegate is used.
Why. coi-serviceworker works by registering a worker that reloads the page
once on first visit. On a personal site where this app is one page among many,
an unexplained reload is a poor trade for a ~2x inference speedup on a six-second
clip that is already backgrounded with a progress bar and a live skeleton. The
GPU delegate does not need SharedArrayBuffer. crossOriginIsolated is recorded
in the analysis metadata either way, so a user report can be interpreted.
Decision. install fetches every shell file with cache: 'reload'.
Why. Found the hard way. cache.add() and a plain fetch both consult the
HTTP cache first, so a worker installing right after a deploy bakes in the
PREVIOUS build’s file and then serves it as current — and because the worker is
now the thing answering, the stale copy survives every subsequent reload. It
cost an hour of debugging a stylesheet that was correct on disk, correct over
the wire, and wrong in the browser. Registration is additionally skipped on
localhost, because a worker that serves the shell from cache is exactly right in
production and exactly wrong while editing the files it cached.
.sl-app [hidden] { display: none !important }Decision. One global rule inside the app’s scope.
Why. The hidden attribute is only a display: none in the UA stylesheet,
so any later display rule silently defeats it — and most of the elements this app
toggles are flex or grid containers. Without the rule every wizard stage, the
metric sheet and the toast all render on top of each other. Stating it once beats
remembering it at a dozen call sites.
Decision. Text, cues, dosage and contraindications; no video or photography.
Why. Appendix C specifies media files. Demonstration media has to be shot or
licensed, and taking somebody else’s is not an option. The cues are what actually
determine whether a movement is done well, and the app says plainly that media is
not included rather than shipping broken <video> tags.
Decision. A half-sine dip through contact and an exact free-fall parabola through flight, matched for velocity continuity, then rescaled to the prescribed amplitude.
Why. A cosine at step frequency puts its lowest point at foot strike and its most negative velocity a quarter period earlier. Both are wrong, and wrong in a way that would have quietly rewarded the pelvis-based contact detector (M5) for agreeing with an artifact. What actually happens: the body is in free fall through flight, so the pelvis arrives at contact with its most negative vertical velocity, and the ground reaction force then reverses it, so the lowest point is at mid-stance. A test oracle that gets its own physics wrong validates nothing.
Likewise STANCE_ALIGN_FRACTION = 0.30: the hip passes over the planted foot
about a third of the way through stance, not half way. At half way the foot lands
0.41 m ahead at 3 m/s, a quarter of standing height, which no runner does — and
every overstride and shank-angle assertion would have been calibrated against a
runner nobody resembles.
Decision. tools/make-banner.mjs renders the E-Labs card image from real
frames of the synthetic runner and real detected events, through a small
software rasterizer and Node’s zlib.
Why. A picture whose claim is “this is what the app computes” should break
when the app breaks. It also avoids adding an image dependency for one asset.
The card’s hover animation in assets/js/e-labs-canvas.js is a self-contained
closed-form model rather than a port of the engine, because that file is shared
by six other cards and stays dependency-free.
Decision. Ears, eyes, a hand centroid per side and a lateral forefoot per side were added to the canonical vocabulary, plus four derived points (pelvis, neck, head center, mid-trunk).
Why. Everything except the lateral forefoot was already in the pose model’s output and was being discarded. BlazePose returns 33 landmarks; the engine was using 17. The ears in particular are not decoration: Winter places the head-and-neck center of mass at the ear canal, so they are the structural landmark for the head segment, and they survive a profile view — which is the view most of this app runs in — far better than the eyes or the nose. The hand centroid is the mean of the three finger landmarks, which distinguishes a hand crossing the midline from a forearm that has merely rotated.
The lateral forefoot is the exception: BlazePose does not have one. It is in the vocabulary anyway, because it turns each foot from a LINE into a PLANE, which is what a foot progression angle needs. With the default backend the metric that needs it reports itself unavailable and names the missing landmark. That is the honest way for a backend difference to surface, and it is why the adapter exists.
Decision. Winter’s segment inertial parameters, mass-weighted over fourteen segments, giving a whole-body center of mass per frame. Its vertical oscillation is reported alongside — not instead of — the pelvis measurement.
Why. This was prompted by reading Van Hooren et al. (2024), which pooled the observational literature on running biomechanics and running economy. Vertical oscillation of the center of mass is the single strongest association it found (r = 0.35, moderate). Most video tools, including this one until now, report the vertical movement of a hip landmark and call it vertical oscillation. It is not the same quantity: the swinging limbs move opposite to the trunk and partly cancel its rise and fall. On the synthetic runner the center of mass moves 7.4 cm against the pelvis’s 8.5 cm, and a test now asserts that ordering — if it ever inverts, the model is wrong.
Since the best-evidenced variable in the app is this one, it was worth measuring the thing rather than a proxy for it. Both are reported and the pelvis one says which it is.
A lost hand does not delete 0.6% of body mass and drag the center of mass toward the feet: segment masses are renormalized over what is available, and a missing distal landmark falls back to the next joint up.
Decision. Morin et al. (2005), which estimates both from contact time, flight time, body mass, running speed and leg length.
Why. The second and third strongest associations in the same meta-analysis (r = −0.31 and −0.28, both moderate), and every input was already being measured. It converts timings the app was already producing into the variables the evidence actually cares about.
Two constraints that come with it. The model’s peak-force term is an output of a sine-wave approximation to the force trace, not a measurement, and it is never reported as one — the specification is explicit that force cannot come from video, and this is exactly the place where a careless implementation would imply otherwise. And stiffness is withheld entirely unless body mass is entered: substituting a population average for somebody’s own mass would be inventing the answer, so the measurement says what it needs instead of appearing.
Decision. Duty factor’s band no longer describes it as well evidenced;
ground contact time carries the same correction; cadence’s direction now cites a
real source; center-of-mass oscillation and both stiffness terms are the only
bands in the app rated moderate.
Why. This app shipped a band comment reading “Duty factor is one of the
better-evidenced technique correlates of running economy”, rated moderate,
citing Moore 2016. The 2024 meta-analysis pools the literature and finds duty
factor’s association with running economy TRIVIAL and non-significant
(r = −0.06), and ground contact time likewise (r = −0.02). So does it find for
foot-strike pattern, knee flexion, trunk lean, shank angle at contact, stride
length and braking — a large share of what this app displays.
That was an overclaim of exactly the kind the specification forbids, made in good faith from a narrower source, and it was live. It is corrected, a test now asserts the correction, and the reasoning is on the science page rather than buried here.
The number that should be hardest to ignore, and which is now on the science page in a callout: taken in isolation, these technique variables explain 4–12% of the differences in running economy between people. Nearly everything that makes one runner more economical than another is not visible in a video of their technique. A tool of this kind that leaves the opposite impression has misled its user, and a dashboard is very good at leaving that impression.
Decision. The keypoint map, the adapter, the coverage reporting and the one metric that needs it are all in place. No ONNX model is bundled and no second backend runs.
Why, having looked into it. RTMPose-s on Halpe-26 is about 23 MB and RTMPose-m about 56 MB, and both are top-down models: they need a person detector in front of them, so a second model and a two-stage pipeline as well. The public ONNX exports live in third-party accounts on model-hosting sites rather than on a CDN with any stability guarantee. Against that, what it actually buys over BlazePose is the lateral forefoot — one landmark, enabling one metric.
The alternative, done instead, was to stop discarding the landmarks the model already loaded was producing and to build the segment model on top of them. That took the tracked set from 17 to 25 and added the three best-evidenced variables in the app, for no download, no compatibility risk and no second inference pass. Given a fixed amount of effort that was the better trade, and it is the one the evidence pointed at.
The door is deliberately left open: PoseBackend, the Halpe-26 map and
backendCoverage() all exist and are tested, so adding the backend is an
implementation of one interface rather than a redesign.
Decision. Fixed, and pinned by a hand-built MP4 fixture in the test suite.
Why it matters more than an off-by-four usually does. The display matrix in
tkhd begins at byte 40 for a version-0 track header and 52 for version 1. The
parser used 36 and 48. It therefore read the tail of a reserved field instead of
the matrix, got a = 256, b = 0, and concluded every video on earth was
unrotated.
The consequence was silent and total. A phone recording in portrait stores landscape pixels plus a quarter turn in that matrix. WebCodecs decodes the CODED frame and knows nothing about the matrix, so the pose model was handed a runner lying on their side. BlazePose is trained on upright people: given a sideways one it does not fail loudly, it produces a confident and entirely wrong skeleton, and every angle, every event and every measurement downstream was computed from it. Nothing threw. The report looked normal.
Found because a user uploaded a 9:16 clip and said it was being treated as landscape. The fixture now asserts all four quarter turns, both track-header versions, and the display-dimension swap.
Decision. Frames are rotated to display orientation before inference, in the same pass as the downscale.
Why not rotate the landmarks afterward, which moves no pixels: because the model has to see an upright person to produce landmarks worth rotating. The whole problem is upstream of the coordinates.
Why it also fixes a disagreement between the two decode paths. A <video>
element applies the display matrix itself, so the fallback decoder was already
producing upright frames while WebCodecs was not. The same clip analyzed in two
browsers would have given two different answers. The fallback now reports zero
rotation and the WebCodecs path reports the real one, so both arrive upright.
Mirroring is handled too. A negative matrix determinant means the frame is flipped, which front-camera recordings carry. A flipped frame swaps the runner’s left and right, so every per-side measurement and every asymmetry index would be confidently reported for the wrong leg — the same class of silent, plausible error, and it is detected, corrected and announced.
Decision. pose/plausible.js measures each rigid segment against its own
median across the clip and marks the distal landmark missing on any frame where
it departs by more than 40%.
Why visibility was not enough. A pose estimator asked for a landmark it cannot see does not decline; it guesses, and often reports a comfortable confidence while doing so. On the real test clip — a runner on a treadmill filmed from behind and to one side — the far leg was hallucinated below the treadmill deck for much of the cycle and passed the visibility gate. Six left strides and two right ones were being reported from it.
Bones do not change length. That is checkable without any reference to how sure the model claims to be, and it uses the runner’s own proportions rather than a population’s. After the gate: three left and one right, which is fewer strides and a truthful number of them.
Decision. If the runner travels less than two leg lengths across the whole clip, step length, stride length and speed are not reported for an overground capture, and the warning says to select Treadmill and enter the belt speed.
Why. On the real test clip, marked “road” but actually a treadmill, the app reported a speed of 0.10 m/s — a pace of 166 minutes per kilometer. The displacement between foot strikes was near zero because the runner was not going anywhere relative to the camera.
A treadmill and a hand-held camera that follows the runner are indistinguishable in the data, and both make displacement-based measurement meaningless rather than merely noisy. The damage does not stop at the one number: speed feeds the vertical ratio, the spring-mass stiffness model, and the choice of speed-conditional reference band, so a single undetected capture condition quietly corrupts a whole column of the report.
Decision. When the view classifies as oblique, the 24 measurements marked
planeSensitive in the catalog are capped at low confidence — which puts them
below the threshold for scoring and for firing any rule. They are still
displayed, with the reason attached.
Why capping rather than widening an interval. An oblique camera does not make a planar angle noisy, it makes it wrong: the angle is measured in a plane the movement did not happen in. Averaging more strides cannot help, so a wider confidence interval would misrepresent the problem as imprecision. The measurements that do not depend on the camera azimuth — cadence, the timing metrics, the vertical oscillations — keep their normal confidence.
This is the difference between a tool that produces a report from any clip and one that says which clips it can read. The real test clip is 30 fps, oblique, and on a treadmill labeled as road; after these fixes it yields cadence and center-of-mass oscillation, three warnings each naming what to change, and nothing else. That is the correct output for that recording.
Decision. The annotated player was rebuilt around one idea: an analytical overlay draws the thing it measured. Nine layers, three density presets, and a heads-up readout of the values AT THE CURRENT FRAME rather than the stride aggregates the dashboard shows.
Why per-frame values. This is the difference between a video with a skeleton on it and an analysis tool. Stepping frame by frame through a contact and watching knee flexion rise is how a gait cycle is actually read, and for that the numbers have to be on the frame. The arc and the number come from the same per-frame series, keyed by name, so the drawing and the reading cannot describe different joints or disagree about a sign convention.
Why dimension lines. “Overstride 11% of standing height” is an abstraction. The same thing drawn between the plumb line through the hips and the ankle, on the frame where it is taken, is a fact about the picture. Same for the center-of-mass excursion, which is otherwise the least visible of the well-evidenced variables. The construction lines — ground, plumb, hip horizon, scale bar — exist so the reader can see what each measurement is taken FROM.
Why the phase lanes. Stance and swing for both feet across the whole clip makes the event detection inspectable: if the two feet do not alternate, the detection is wrong, and that is visible without reading a number.
Three things the drawing refuses to do.
An angle with no readable value is not annotated at all. An arc without a number is an annotation that cannot say what it found, so if the conditioned series gated that joint out, nothing is drawn.
A dimension line is not drawn for a measurement below medium confidence. Drawing one would let the overlay contradict the report it belongs to — the engine already declines to score or advise on those, and a confident-looking annotation on the frame would undo that. On an oblique capture the frame says so, next to the timecode.
A landmark the pose model was unsure of is drawn hollow and dimmed, and the bones resting on it are drawn faint. A skeleton standing on a guess should look like one.
One sizing note worth keeping. The design unit comes from the geometric mean of the frame, not its short side. A 9:16 phone clip has a short side barely a third of its long one, so sizing from the minimum shrinks every label and lane to illegibility on exactly the videos people actually record.
Decision. The Demo button became a dropdown with two entries. The synthetic
runner stays; a real phone clip of somebody on a treadmill was added beside it,
shipped in demo/ and analyzed through the ordinary path — fetch, pre-flight,
decode, pose over every frame, the same refusals as anyone’s own video.
Why two. They answer different questions and neither can answer the other’s. The synthetic runner is the only clip in existence for which this app knows the true cadence, contact time, trunk lean and strike angle, because they were inputs to the model that generated it — so it is the only one that can show the engine’s error. A filmed runner has no ground truth, only a second measurement. But the synthetic clip is 240 fps, square on, and perfectly tracked, which is not what anybody has on their phone, so on its own it demonstrates the app under conditions the app will rarely meet.
Why this clip, which measures badly. It is 30 fps, portrait, and shot from behind and to one side of a treadmill. The app answers it with cadence, the oscillation metrics and one finding, and withholds the twenty-four plane-sensitive angles, the three timing metrics and both stiffnesses, naming a reason for each. That is the demonstration. An app whose demo only ever shows the capture it was designed for teaches a visitor nothing about the capture they have, and the refusals are the part of this engine most worth seeing.
Why it runs the real pipeline rather than replaying a stored result. Faster, and it would demonstrate nothing. The rotation is really detected, the tracker really runs, the limb-length gate really throws away two thirds of the detected strides. A recorded answer would be a screenshot with extra steps.
Twelve seconds, not the six the app proposes. Eight candidate windows were measured. Six seconds gives three or four usable strides and three measurements at medium confidence and fires no rule; twelve gives nine, eight, and one finding with advice attached, for about four more seconds of compute. The six-second default for a clip you bring is a latency choice, and this is the evidence for what it costs. The window is committed rather than proposed so the demo answers the same thing twice.
What is quoted rather than measured. Standing height 1.68 m, mass 75 kg and
belt speed 3.33 m/s were supplied by the person filmed. Height sets the
pixels-to-meters scale; belt speed cannot be measured at all here, because a
runner on a treadmill does not move through the frame. All three live in
stated in src/ui/demos.js and the report says so on its face, so what the
app was told stays separable from what the app worked out.
Two guards in the test suite. The declared container metadata is read back out of the shipped bytes with the app’s own demuxer, so swapping the file for a different recording fails the build rather than turning the demo’s note into a confident lie. And the stated belt speed is cross-checked against the measured cadence: 3.33 m/s at the 182 steps/min this clip returns is a step of 65% of standing height, which is ordinary. The 5 m/s that was briefly on the table would have implied 98%, which is not a thing a body does, so the arbitration is recorded as an assertion rather than as a memory.
The camera was checked, not assumed. The clip reports about 16 cm of vertical oscillation, high enough to suspect the handheld camera rather than the runner. Matching the left and right 22% of the frame — wall and machine, static in the world — across 58 sample pairs returns zero displacement, on a sum-of-absolute-differences surface that is sharply peaked at zero (7.1 at no shift, 11.9 at one pixel, 20.8 at four) and that recovers an artificially shifted control exactly. The camera does not move. The number is the runner’s.
Decision. scoreValue now divides by the distance from the optimal center
to the acceptable edge on the side the value falls, not by half the total
acceptable width.
Why. Twenty-one of the twenty-four bands here are asymmetric about their own
optimal center — being a little under is rarely as bad as being a lot over. One
half-width for both sides puts the zero closer in on the wider side, inside the
acceptable range. Head oscillation, optimal 4–9 cm and acceptable 3–12, scored a
flat zero from 11 cm upward while bandStatus went on calling 11.7 cm
acceptable, so the app showed a half-filled glyph reading “Near the typical
range” next to a score of 0 out of 100 for the same measurement, and meant both.
Every one of the twenty-one had a dead zone like it.
This was a bug against the function’s own documented contract, which already
said “a value at the edge of the acceptable band scores 0” — true only for a
symmetric band, and none of the interesting ones are. scoreValue > 0 is now
exactly bandStatus !== 'outside' by construction, and symmetric bands are
unaffected. The suite sweeps all 24 bands at 39 points each and asserts the
equivalence; reverting the formula fails those checks, which is the negative
control.
Found by the filmed demo, which put “Posture and alignment 0 / 100” on screen underneath a measurement labeled near-typical. It now reads 5 / 100.
Decision. The arithmetic that picks the analysis window moved out of the DOM
controller into src/ui/propose.js; app.js keeps only the measuring, which
needs a canvas, and the applying, which needs the sliders.
Why. The heuristic’s important behavior is that it declines — a clip of somebody running the whole way through has no quiet part to skip, every window scores the same, and moving the selection then looks like a decision and is a coin toss. That guard cannot be exercised against the shipped demo, because the shipped demo is exactly the uniform case: it correctly proposes nothing, which looks identical to a proposal that never worked. Splitting the decision out makes both paths testable, and the positive path — a 30 s clip with the running in the middle ten — is now a check rather than a hope.
The threshold is a multiple of the median rather than the mean, so a single bright thumbnail (a passing shadow, an autoexposure step) cannot both create the winning window and raise the bar that window has to clear.
A missing IndexedDB key returned the request object, not the default. tx()
resolved with out.result !== undefined ? out.result : out, so a get that
found nothing resolved with the IDBRequest itself, which is truthy.
getSetting('units', 'metric') then read .value off a request, got
undefined, and returned that instead of the caller’s fallback — so on a first
visit the units selector was set to undefined, selectedIndex went to −1, and
the control rendered blank. activeProfile() had it worse: it was handed a
request object as a primary key and threw into a catch that swallowed it. A
miss now resolves as undefined.
Every select clipped its own descenders. .sl-select set height: 2.1em
while inheriting the app’s 1.75 line-height; under border-box that leaves 15.7px
of content box for a 23.8px line. Invisible on “Metric”, obvious the moment
“Demo — synthetic runner” went in one. Now min-height plus a stated
line-height: 1.25.
The Analyze run button was unreachable on a phone. .sl-topbar wraps, but
.sl-topbar-group is a flex item and did not, so the group holding the device
chip, the demo control and the two buttons stayed one row wide however narrow
the viewport got. .sl-workspace uses overflow: clip below 1080 (so its
sticky bar works), which meant the overflow was not scrollable — it simply was
not there. At 380px the primary action sat at x=506 and could not be tapped.
The group wraps now, and below 720px the demo control takes a row of its own.
Worth noting that the third one was latent before this change and would have bitten at a slightly narrower width; adding 232px of dropdown to that row is what made it certain, and therefore visible.
Decision. decodeWithVideoElement pauses the video around the awaited
onFrame, and registers the next requestVideoFrameCallback before resuming.
Why. It did not, and the consequence was severe and silent. onFrame is
pose inference — a hundred milliseconds and up per frame — and a playing video
does not wait for it. Re-registering the callback only after the await meant
every frame presented while the model was busy was gone: the decoder sampled at
the speed of inference rather than at the frame rate of the clip.
Measured on a real 30 fps recording: a free consumer got 24 fps, a 40 ms consumer 11.5 fps with 51 of 117 frames dropped, and real inference about 5. The pipeline then read 5 fps off the timestamps of the frames that survived, refused the clip, and told the user to re-record at 60 fps or higher — about a recording that was already 30, and whose frames the app had thrown away itself. After the fix the same clip yields 119 frames at 29.8 fps and analyzes.
This is not an exotic path. The demuxer here is ISO-BMFF only, so every WebM
file falls back to it on every browser, including files the app’s own camera
recorder produces whenever MediaRecorder cannot give it MP4 — Firefox always,
and Chrome depending on the build. Record a clip in the app, and the app could
refuse it.
The cost is honest: decode now takes as long as inference does, which is what
the WebCodecs path costs too. There is no way to get every frame out of a
<video> except to stop time, and a sampling decoder that quietly discards
four fifths of a clip is worse than a slow one.
And the refusal now names the right culprit. runPipeline is told how many
frames the decoder lost and what the source rate was, and when the two disagree
it says the browser could not keep up rather than blaming the camera. Advice
that cannot help is worse than no advice.
Decision. Frame-interval spread is the 10th-to-90th-percentile range over the median, not max minus min. A few isolated long gaps are reported separately, as skips.
Why. A range is the least robust statistic available, and it was the one gating this warning. One skipped frame makes one interval twice the others and reports “Frame intervals vary by 100%” — which is what a genuinely variable-frame-rate phone recording looks like too, and the two call for different things. On the two-runner test clip it read 121% for a clip that was steady 30 fps apart from two skips in 119 frames.
Every other gate in this engine rests on a robust statistic — D10 uses the standard error rather than the spread, D12 a weighted median before a mean. This was the exception, and it fired on healthy clips.
The filmed demo has one runner in it and is an MP4, so it exercises neither the
person-picker nor the fallback decoder. Both were reached with a clip built for
the purpose: the demo composited beside a time-shifted copy of itself, recorded
to WebM through MediaRecorder. Everything in D40 and D41 came out of that, and
so did these.
The picker was unreachable by keyboard. The choice is a gate — the analysis
stops until it is answered — and the only way to answer was clicking a canvas.
There is now a real <button> per candidate beside the prompt, carrying the
same number its box is labeled with, focused on open, and highlighting its box
on focus so the two readings of one choice stay tied together.
The picker was drawn into a letterbox. The live canvas is pinned to 360px
for the progress display, which left each candidate about 80px wide to hit on a
desktop viewport and far less on a phone. It grows to min(64vh, 620px) while
choosing and is put back afterward.
The share link was built and never shown. shareLink awaited
navigator.clipboard.writeText and only then wrote any text. Without transient
user activation Chrome does not reject that promise, it leaves it pending
forever — so the panel opened, stayed empty, and the link the user had asked
for was never displayed. A catch cannot catch a promise that never settles.
The link now goes on screen first, in a selectable field, and the clipboard
attempt is bounded and merely upgrades the status line. This is the same lesson
Cross-Section Studio already learned: never let the clipboard be the only path.
A note on method. Two of the four were invisible to the test suite and always would have been — they live in the DOM half, which is deliberately not unit-tested (D-series preamble). They were found by driving the real interface with a fixture built to reach the branches the shipped demo cannot. That is the argument for keeping a fixture generator around rather than only golden files.
Decision. renderSharedSummary now shows units, confidence intervals, the
side each reading belongs to, confidence spelled out, and the capture context
that travels in the link. Low-confidence rows are marked and counted.
Why. It showed a bare number, a “Left”/”Right” pair that non-sided metrics
half-filled, and a single letter — l, m — in a column headed Confidence,
because makeShareCode stores confidence[0] to save bytes and the renderer
printed the stored value. So “Step time 305” reached the recipient with no unit
(milliseconds? seconds?), no interval, and a one-character quality flag.
The link already carried summary.c — frame rate, view, surface, speed — and
the renderer ignored it. That is the worst of the set: a cadence measured on a
30 fps oblique treadmill clip is a different claim from one measured square-on
at 240 fps, and the difference is this app’s entire argument. A shared summary
that drops it is the one view of these numbers with none of the apparatus that
qualifies them.
Also fixed while there: a sided metric whose LEFT slot was empty was skipped entirely, taking its measured right side with it. Row count on the demo went from 24 to 28.
And the link is now read when it arrives, not only at startup. The hash was
parsed once in init. Pasting a share link into a tab already showing this page
changes only the fragment, so the browser fires hashchange and does not
reload — and that link silently did nothing.
Decision. exportOverlayVideo plays the source clip and draws on
requestVideoFrameCallback, instead of seeking to each frame in turn.
Why. MediaRecorder timestamps by wall clock, so whatever the render loop
does slowly is what the exported video plays slowly. Seeking once per frame and
awaiting each seeked made a 2.05 s window come out at 2.52 s — 23% slow.
Somebody counting steps in the exported video would get a cadence 23% below the
one in the report it came from, which for this app is the exact failure it
exists to avoid.
Playing the clip makes media time and wall clock advance together by
construction, and indexAtTime picks the matching series frame for each
presented one. Measured after: a 4.11 s window exports as 4.00 s, −2.7%, and
rendering costs 1.3x real time rather than 2x. The seek loop is kept for the
synthetic demo, which has no source video, and there it now paces against the
clip’s own timeline so a slow paint steals from the next frame’s wait rather
than stretching the whole export. Recording starts once there is something to
record, rather than capturing the blank canvas as lead-in.
What was checked and was fine: series.t holds absolute media time, not
time relative to the trim window, so the per-frame seek was pointing at the
right part of the clip. That was verified rather than assumed — had it been
relative, the overlay would have been drawn over unrelated video.
Decision. @media print redefines the palette tokens to the light set, and
a beforeprint handler re-renders the charts in the light theme.
Why. This app is dark-first and browsers do not print background colors.
--sl-ink is #e8eef9, so a reader in dark mode — the default — pressed
“Printable report” and got near-white text on white paper. The feature produced
a blank sheet for most of its users. Charts are canvases and print the bitmap
they hold rather than taking any CSS, hence the re-render; renderResult was
already re-entrant because changing units calls it. The player canvas is left
alone deliberately, being a video frame.
The re-render is synchronous, and that is the load-bearing part. Chart
canvases are normally drawn on the next animation frame, which is a batching
choice rather than a requirement — setup() in charts.js reads
getBoundingClientRect(), which flushes layout on its own, so a canvas can be
drawn the moment it is in the document; the frame buys doing that once for
forty cards instead of forty times. Printing cannot afford the deferral.
beforeprint runs synchronously and the browser lays the page out for paper as
soon as the handler returns, so a repaint parked on an animation frame is a
race — one that happened to be won in Chrome and Firefox with no promise of
being won anywhere else. scheduleChart draws inline while printing, and the
outcome no longer depends on scheduling: dispatching beforeprint and reading
a chart pixel with no await in between now returns the light value, where
before it needed most of a second.
Also hidden in print: the History and Compare panes, which
.sl-pane { display: block !important } had been printing along with
everything else. Compare with nothing selected printed the sentence “Pick two
analyses in the History tab”, which on paper is an instruction nobody can
follow.
Decision. History rows gained an Open action that restores a stored analysis into the player, with the kept video when there is one.
Why. store.getVideo was called from nowhere in the app. The checkbox
“Keep the video with this analysis” wrote the clip into IndexedDB — tens of
megabytes, five retained — and nothing ever read it back: it was write-only
storage, spending the user’s quota in an app that warns them about quota, and
buying nothing at all. Stored analyses could only be compared or deleted.
What comes back says what it is not: the record keeps measurements, scores, findings, warnings, events and keypoints, but not the engine’s internal per-frame series, so the gait-cycle curves and the richer overlay layers are unavailable and a warning says so rather than quietly drawing less.
scores.perMetric is recomputed on open rather than stored twice —
stripMetrics keeps only the dimension scores, and the metric grid reads
perMetric for every card’s status. Scoring is a pure function of the
measurements and the speed band, so what comes back is what was stored.
timeupdateDecision. The player’s overlay is driven by requestVideoFrameCallback,
with an animation-frame loop as fallback and timeupdate demoted to a backstop
for a paused element being scrubbed.
Why. Reported as: the overlay tracks perfectly at 0.15x and 0.25x and falls
apart at 0.5x and 1x. It was hanging off timeupdate, which browsers throttle
to roughly four events a second — measured here at a 266 ms median
interval. That is a fixed budget in WALL time, so what it buys in MEDIA time
scales with the playback rate: at 0.15x, 266 ms is about one frame of a 30 fps
clip and the overlay looks exact; at 1x it is eight frames, and the skeleton
sits on a pose the runner left a quarter of a second ago, jumping eight frames
at a time to catch up. At 180 steps per minute that lag is more than a whole
step, which is why it looked like the detection had failed rather than like a
sync problem. The detection was never wrong; the overlay was drawing the wrong
frame.
Measured, overlay-to-picture lag in frames, median (max):
| rate | before | after |
|---|---|---|
| 0.15x | 1 (2) | 0 (1) |
| 1x | 4 (8) | 0 (1) |
requestVideoFrameCallback fires once per presented frame and hands over that
frame’s exact mediaTime, so the pose drawn is the pose of the picture
underneath it at any rate, by construction. The callback chain is canceled
when the player is re-armed, or two chains would draw over each other.
Decision. The processing stage draws a detection HUD — acquisition brackets around the landmarks found, joints sized by the confidence the model reported, a sweep line, and chips reading the frame index, the number of landmarks locked and whether more than one person is in shot — over a stepper that marks each stage done or running, a percentage, a throughput in frames per second and an estimated time left.
Why. Forty seconds is a long time to look at a stick figure on an empty rectangle with no idea how long is left. Everything drawn is real: the box is the landmark extent, the joint sizes are reported visibilities, the rate and the estimate come from frames actually completed. None of it is decoration imitating telemetry, which in this app would be the wrong kind of joke.
And it found a duplicate mapping. The preview had its own hand-written copy
of the backend-to-canonical landmark table, and it was already out of step with
the engine’s: it knew 17 of the 25 canonical landmarks and produced nothing for
the ears, eyes, hands and outer feet, which adaptFrame builds as centroids of
several raw points. So the live preview drew a poorer skeleton than the report
of the same frame, and the HUD’s “landmarks locked” count ran against a
denominator eight of which could never be filled. The preview now calls
adaptFrame, and the second table is gone. Two copies of a mapping is two
things that can disagree, and these already had.
Found. MediaRecorder writes a FRAGMENTED container — samples live in
moof/trun boxes rather than in the moov sample table — and it cannot write
anything else, because it is describing a stream whose final length it does not
yet know. The demuxer here reads sample tables; it recognizes the fragmented
layout and reports reason: 'fragmented', so probe correctly falls back.
Verified by recording through MediaRecorder and parsing the result: the file
carries moof, traf, trun and mvex, and parseMp4 returns
ok: false, reason: 'fragmented' rather than a track with an empty sample list.
So every clip recorded inside this app takes the <video> playback decoder,
always. Nothing recorded here will ever reach the WebCodecs fast path. That is
correct behavior and it is surfaced to the user as reduced timing precision —
and it is why the playback decoder silently dropping four fifths of its frames
(D40) mattered as much as it did: it was not an edge case, it was the path this
feature uses every time. Preferring video/mp4 over WebM in the recorder’s
format list therefore buys no speed; it is kept only because an mp4 is the more
portable thing for somebody to keep.
Hardened while there: the produced file takes the recorder’s own negotiated
mimeType rather than the first chunk’s, which can be empty and does not exist
at all if nothing was captured; a recording that produced no chunks now says so
instead of handing an empty file to the pre-flight; a clip under two seconds is
called out at the point of recording rather than only in the checks; and the
record button refuses politely when no stream is open.
What was verified without a device. Stubbing getUserMedia with a canvas
stream drives the whole path for real: the camera stage opens, the hint reads
the actual stream settings back, Record becomes Stop, stopping produces
recording.mp4 of the negotiated type, and it lands in the pre-flight and trim
stage with the fragmented-container fallback correctly flagged.
What could not be. A synthetic camera cannot produce a realistic frame rate
in an automated browser: the page is not being painted, so
requestAnimationFrame and requestVideoFrameCallback are both throttled to
about 1 Hz, and the recording comes out at 1 fps — which the app then refuses,
correctly and for the right reason. That refusal is the app being right about a
genuinely 1 fps file, not a defect; a plain <video> reading the same file
sees the same four frames a second apart. The remaining gap is therefore narrow
and specific: a real camera recording at a real frame rate, end to end. The
decode half of that join is separately verified — the same fragmented/WebM
fallback path recovers 29.8 fps out of a 30 fps clip (D40) — so what is
untested is the hardware, not the code it feeds.
These were answered with the conservative default and are easy to change.
/science states this plainly
and publishes only what was actually measured, against synthetic ground truth,
labeled as such./science. Retained
rather than dropped so a reader looking for pronation finds the explanation
rather than nothing. If it misleads more than it informs, drop it.test/run.mjs pins a fixed synthetic clip twice: against the engine’s committed
output (tight tolerance) and against physical truth (loose). Any change that
moves a golden number must be recorded here with its reason — a golden file
alone will happily preserve a bug forever, because it only ever asks whether
today matches yesterday.
Current baseline, seed 20260831: cadence 171.949, GCT 217.830 / 228.976 ms, duty 0.3120, step length 1.1163 m, vertical oscillation 8.596 cm, trunk lean 6.000°, foot-strike angle 10.851°, overstride 11.197% of height.