<!--
Smooth Web Video
Part of the Bles Software agent skills repository:
https://www.bles-software.com/claude-skills

Install for Claude Code:
  mkdir -p ~/.claude/skills/smooth-web-video
  mv smooth-web-video.md ~/.claude/skills/smooth-web-video/SKILL.md

Install for Codex:
  mkdir -p ~/.codex/skills/smooth-web-video
  mv smooth-web-video.md ~/.codex/skills/smooth-web-video/SKILL.md

For Gemini CLI and other agents, drop the body of this file into the
project instruction file the agent already reads.

Note: in production this skill also ships 1 helper file(s) alongside
SKILL.md. The procedure below is complete and self-contained; the helpers are
convenience scripts you can rebuild from the steps described here.
-->

---
name: smooth-web-video
description: Produce smooth, fast-flowing video from anything that renders in a browser (three.js scenes, product turntables, game demos, hero loops, landing-page films) and prove it is smooth before it ships. Use whenever a video is captured from a web page or canvas, whenever a video is reported as laggy, choppy, stuttering, jerky or slow, whenever a page or section playing a video stutters, and before publishing any video to a site, X, or a client.
---

# Smooth web video

A video captured from a browser is either driven by frame index or it stutters.
There is no third option. This skill is the driven-by-frame-index way, plus the
gate that catches the other way before a human does.


**Sound is part of the gate (owner, 21 Oct 2026).** Smooth and silent is still
broken unless the brief says silent. Run `python3 ~/teleclaudious/scripts/audio_qa.py
<file.mp4>` beside `encode.cjs --check`: no audio track, mean under -45 dB, peak under
-20 dB or silence over 60% of the time fails. The Etsy listing videos of task 5100
lost their Kie sound to a `-an` remux and nobody listened; keep audio in every master
and mark a deliberately muted copy with `--silent-ok`.

## The failure this exists to stop

`3d.bles-software.com` shipped a 27-second pack reel. It probed clean: h264,
1280x720, 30 fps, faststart, 810 frames. It was also unwatchable. Measured, only
**119 of those 810 frames ever changed**: 4.8 fps of real motion, with 600 ms
holes where the renderer stalled. The other 691 frames were duplicates the muxer
padded in.

That is what "laggy" means from the outside, and no ordinary check finds it.
Duration, resolution, bitrate, codec and file size are all perfect on a video
made entirely of frozen frames.

**Cause:** the capture sampled the wall clock. Headless WebGL on swiftshader
renders a heavy GLB at ~5 fps, so a 30 fps recorder wrote the same frame six
times in a row. The renderer's speed leaked into the output.

## The rule

**Never record. Pose, render, grab, advance.**

Drive the page by frame index, not by time. Frame `i` sets the scene to exactly
where frame `i` belongs, waits for it to actually composite, and only then
screenshots. A slow renderer makes the *capture* take longer. It can never make
the *video* jerk, because every frame written is a distinct, correctly-spaced
pose.

Corollaries, all learned the hard way:

- **Two rAFs, not one.** One `requestAnimationFrame` after a state change
  flushes it; the second proves a full frame composited with it. Grab after one
  and you capture the pose before the one you set.
- **Freeze simulation time.** Anything reading `Date.now()`, `clock.getDelta()`
  or `performance.now()` reintroduces the wall clock. Turn it off
  (`window.__animate = false`) or step it by a fixed `dt` you pass in.
- **Supersample.** Capture at `deviceScaleFactor: 2`, downscale with lanczos at
  encode time. Costs render time, buys crispness that no CRF setting can.
- **Nothing holds still.** A frozen frame is indistinguishable from a stall, to
  the gate and to the viewer. Give even a title card a slow push-in.
- **A held beat needs real grain, not a grain layer.** A reading pause where only
  a slow shader moves reads to `mpdecimate` as duplicates (measured 3 Sep 2026:
  183 ms holes at exactly the two flat scroll beats). Shifting an SVG-turbulence
  overlay per frame does nothing, mean luma delta stayed at 0.41 against 1.41
  while scrolling, because `overlay` on near-black is almost a no-op. What works
  is a real film grain: a small canvas (640x360) redrawn every frame from a
  frame-seeded PRNG, black-or-white pixels with alpha in `[0, A)`, stretched over
  the viewport. Amplitude is the whole trade: `A = 15` gave 60.0 unique fps at
  62 MB, `A = 9` gave 53.7 fps and a 67 ms hitch at 22.6 MB for the same 20s.
- **Constant angular velocity across cuts.** In a multi-item reel, let each item
  start where the previous stopped. Twelve shots read as one continuous turn
  instead of twelve restarts. This is what makes it "flow" rather than merely
  "not stutter".

## Use it

    node <skill>/scripts/capture.cjs <spec.cjs> <frames-dir>
    node <skill>/scripts/encode.cjs <frames-dir> <out.mp4> [--fps 60] [--width 1280] [--crf 20]
    node <skill>/scripts/encode.cjs --check <any.mp4> [--holds N]

`spec.cjs` exports `{ fps, width, height, scale, shots }`; each shot is
`{ url | html, ready, frames, drive }` where `drive(page, i, frames, globalFrame)`
poses frame `i`. Shots concatenate into one continuous numbering, so a reel of
many pages is one capture and one encode.

Worked example, twelve GLBs turning as one shot list:
`apps/img2threejs-studio/sculpt/build-reel.cjs`.

## The gate — run it on every video, always

    node <skill>/scripts/encode.cjs --check video.mp4

It reports three numbers and exits non-zero on any failure:

| Check | Default | Why |
|---|---|---|
| `unique motion` | ≥ 24 fps | Unique frames per second via `mpdecimate`. The only measure that sees a video padded with duplicates. |
| `worst hitch` | ≤ 100 ms | Largest gap between two changing frames. A renderer that stalled leaves a visible hole. |
| `faststart` | required | `moov` before `mdat`, or the browser downloads the whole file before it plays. |

`--holds N` ignores the N largest gaps, for a deliberately held card. Use it
sparingly: every hold you whitelist is a stall you stopped measuring.

`encode.cjs` runs the gate on its own output automatically, so a bad reel never
reaches the publish step. Nothing built by this skill ships ungated.

## Autoplaying hero: it must also OPEN right

    node <skill>/scripts/encode.cjs --check hero.mp4 --opens-on-content --poster hero.jpg

A hero that scores a perfect 30 unique fps still gets reported as "laggy, it
never loads" when the first thing it paints is a card. The visitor does not read
a black frame with a line of text as your intro, they read it as a placeholder
and a broken video. Two more checks, opt in per file:

| Check | Default | Why |
|---|---|---|
| `opener` | < 85% flat | Share of frame 0 sitting on one colour. Measured: a text card 91%, a dark 3D scene 70%, a lit product shot 30%. Over the line means the video opens on a card, not on content. |
| `poster match` | ≤ 3 mean diff | Poster vs frame 0, grayscale. A poster cut from frame 0 scores ~0.2; a poster grabbed from a different moment scores 4.6+ and the picture visibly jumps the instant playback starts. |

So, for a hero:

- **Cut the intro and outro cards out of the file.** The page already has a
  headline next to the video; a card inside a 300px tile is duplicate copy that
  is usually clipped anyway. Card boundaries are normally keyframes, so
  `ffmpeg -ss <in> -to <out> -i in.mp4 -c copy -movflags +faststart out.mp4`
  trims it losslessly, with no second-generation quality cost and no size
  increase. Check with `ffprobe -select_streams v:0 -skip_frame nokey
  -show_entries frame=pts_time` first; re-encoding the same trim cost +110% file
  size for nothing.
- **Cut the file's own poster from the trimmed file**, never from an earlier
  version: `ffmpeg -i hero.mp4 -frames:v 1 -q:v 4 hero.jpg`.
- **Bust the cache when you replace either.** Behind Cloudflare the old file
  lives on for hours; ship `hero.mp4?v=2` and `hero.jpg?v=2` together.
- Preload the poster in `<head>`:
  `<link rel="preload" as="image" href="/hero.jpg?v=2" fetchpriority="high">`.
  23 KB paints in ~180 ms and the video takes over invisibly.

A card that is genuinely the product (a pack cover with the twelve models on it)
is content and passes — the check measures flatness, not the presence of text.

## Encode settings

60 fps, `libx264 -preset slow -crf 20`, `yuv420p`, `-g 120`, `+faststart`, no
audio track on a muted loop, lanczos downscale from the supersampled capture.
1280 wide is the web sweet spot. Measured: a 21.7s 60 fps turntable reel of 12
PBR models lands at 5.2 MB / 1900 kbps.

For an autoplaying loop also set on the `<video>`: `muted playsinline loop
autoplay preload="auto"` and a `poster`. `preload="metadata"` on an autoplay
video buffers into playback and reads as lag even when the file is perfect.

## The file passed the gate and it is still laggy

Then the file is not the problem, the page rendering it is. A perfect 30 fps film
inside an expensive section plays back at 6 fps and reads exactly like a bad
encode. Measure the page, do not read the CSS and guess:

    node <skill>/scripts/page-smoothness.cjs <url> [anchorId] [seconds]

Run it once for the number, then re-run with `PROBE_CSS` disabling one suspect at
a time. That is what turns "probably the blur" into evidence.

**Trust `layoutsPerSec`, not fps.** On a loaded or GPU-less box fps and p95 swing
by 3x between identical runs, so a single before/after pair proves nothing.
`layoutsPerSec` is load-independent: a section animating a layout property reports
one layout per animation frame, a section animating only transform/opacity reports
exactly `0.0`. Interleave the arms (before, after, before, after) and compare the
dropped-frame ratio across rounds; never conclude from one round.

The four things that actually cost, all found on `teleclaudius.bles-software.com`
around one 30-second film, together taking it to **6.6 fps with 950 ms p95 hitches**:

| Cost | Fix | Why it is expensive |
|---|---|---|
| `backdrop-filter` on anything overlapping a playing video | drop it, raise the background alpha instead | every video frame invalidates the backdrop, so the blur re-renders 30x/s per element |
| `filter: blur()` on a glow div | bake the falloff into the gradient stops | a gradient is already smooth; the blur only buys a full-size layer that recomposites |
| `@keyframes` animating `margin`, `top`, `height`, `width` | `transform: translate3d()` / `scaleY()` | animates layout, so the page relayouts every frame, forever |
| `transform-style: preserve-3d` with no Z-positioned children | delete it | pure cost, zero effect |

Also: `preload="metadata"` on an autoplaying video buffers into playback and reads
as lag, and a video that autoplays off-screen steals frames from everything the
visitor is actually looking at. Use `preload="auto"` plus an IntersectionObserver
that pauses it when it leaves the viewport.

Result after those four changes, live and interleaved: layouts per second
**6.7 → 0.0**, p95 frame time **950 ms → 67-100 ms**, dropped video frames
**25% → 14%**, on a box whose load average had meanwhile gone from 18 to 34.

## When this is the wrong tool

Real footage, AI-generated video, and screen recordings of someone using an app
are not driven by frame index. The capture half does not apply. **The gate half
still does** — run `--check` on those too before they go out.
