DEV Community

Cover image for The page and the encoder are different clocks
Keny Alba
Keny Alba

Posted on

The page and the encoder are different clocks

Three times, one file

This is the third post in a series about auto-scroll capture. After choosing live versus a frozen map, the live path still has a quieter bug: my scroll loop, the compositor, and the video encoder do not share a clock.

I spent too long trying to force them into one number. The recordings got worse.

Auto-scroll in the page is a requestAnimationFrame loop. Each tick I compute a delta from real elapsed time, move the document, and hope the next paint includes that position.

The compositor produces frames on its own cadence. A 144 Hz display does not paint 144 unique page states into a capture. Tab capture samples what is on screen when it can.

The encoder then writes a compressed stream. For live WebM that stream is typically variable frame rate: one encoded frame per captured paint, not a metronome. Many players treat VFR as stutter. The file is still a more accurate diary of capture than a resampled clip that invented timestamps.

If I pretend these are one clock, I optimize the wrong layer.

Why I stopped smoothing the scroll delta

The speed presets are defined in “pixels per tick” at a notional 60 Hz. Real ticks are not 60 Hz. Under load - high resolution plus a live encoder - a frame callback can arrive late.

If I scale the scroll step by a large catch-up factor, the page teleports in a single paint. The encoder, if it captures that paint, stores a jump. The screen may have felt only a hitch. The file looks like a cut.

If I clamp catch-up too hard, the recording lags the intent of the speed preset. I chose a modest cap: better a slightly slower capture than a hundred-pixel jump in one frame.

I also tried an exponential moving average on dt. That made displacement even per painted frame and uneven in real time. Capture samples time, not frame index. A long frame that moves twice as far and stays on screen twice as long is the correct live motion. A smoothed dt made the page fall behind and then sprint. I removed the smoother.

Sub-pixel remainder matters here. Rounding every tiny step to an integer pixel creates a staircase that the encoder dutifully records. I carry the fraction and apply whole pixels when I have them. One scrollBy per paint. Never a catch-up burst of several steps in the same callback - that burst is one filmed jump.

Why I do not constrain capture frame rate

It is tempting to tell getUserMedia a maximum frame rate so the file matches a UI setting.

I tried it. On a high-refresh display, a forced period does not land on compositor beats. Chrome waits for the deadline, then takes the next paint. You skip two paints, then three, then two. Scroll advances in uneven steps for the entire take. That is the “page teleporting while I watch a smooth screen” failure, except now it is structural, not a load spike.

Without a frame-rate cap, capture follows content. Gaps between encoded frames match the compositor more closely. The UI can still offer a cadence for offline render and for transcode. Live capture does not get to fake that cadence at the sensor.

Software VP8 in the live recorder is CPU-bound. When pixel throughput is too high, the encoder drops input frames irregularly. The compositor is fine. The file is not. The fix that worked was reducing definition, not demanding a higher frame rate from a pipe that cannot carry it. A slightly softer image is easier to forgive than a slideshow labeled as 60 fps.

Bitrate has to follow real width × height × actual capture rate. Indexing bitrate on a quality label while encoding native retina resolution starves bits per pixel. The picture goes soft and the encoder is still drowning.

Encoding is a fourth clock, on the same machine

Service workers cannot run MediaRecorder. Capture and transcode live in an offscreen document. FFmpeg is bundled as WebAssembly. No remote encode.

Default live WebM is the recorder blob, VFR, download immediately. That is the file that matches the take.

A constant-frame-rate re-encode exists as a playback convenience. It is slower, generational, and can exhaust WASM’s 32-bit heap on large clips. I would rather ship the raw take than crash the export. If a container conversion fails, I keep the original recording under an honest extension (WebM or Matroska, not a lying .mp4 name).

GIF is a special case of the same memory physics. Building a palette from every source frame at full rate OOMs. I reduce temporal resolution before palette generation. A 4K request is not a 4K GIF if the WASM heap cannot hold it.

MP4 is fastest when the live recorder already produced H.264 and I can remux without a second encode. Full software x264 in WASM is a different, much slower job. Timeouts that assume “encode always finishes in N seconds” kill healthy long transcodes. An inactivity watchdog (no log, no progress) is the useful one. A ticker that pings “still working” forever hides a deadlock.

Cinema, from the previous post, is the escape hatch: an offline clock, frame / fps, hardware or WebCodecs encode, then a remux. It does not fix live VFR. It refuses the live time base.

What I would tell past me

Do not make scroll catch up to a late rAF with a huge step. The encoder will publish the lie.

Do not force capture frame rate to flatter a settings menu. You will alias the compositor.

Do not treat “1080p” as a promise about the live file. Treat it as a pixel budget and a bitrate, then tell the truth about VFR.

Local encode is the product constraint, not an implementation detail. The machine that showed the page is the machine that compresses it. When it cannot, a smaller honest file beats a crashed tab.

The live export in ScrollFlow is the compositor’s diary. The Cinema path is a score I conduct. Mixing the metaphors in one pipeline is how the stutter got “mysterious.”

Feedback welcome

If you have seen a smooth screen turn into a stuttering file, I want that case.

I turned this experiment into ScrollFlow, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.

Top comments (0)