Skip to content

Read a window by playing it, not by seeking to every frame - #55

Merged
ibrahimweng merged 2 commits into
mainfrom
claude/pinning-pass
Aug 31, 2026
Merged

Read a window by playing it, not by seeking to every frame#55
ibrahimweng merged 2 commits into
mainfrom
claude/pinning-pass

Conversation

@ibrahimweng

Copy link
Copy Markdown
Owner

This is the "without lagging" half of your request. I measured where the scan's time actually goes on the size of clip you named — 1080p, several minutes — and one of the two passes turned out to be both the slow one and a wrong one.

What the measurement said

A 1080p clip of forty seconds:

before after
pass 1 — reading 20.5s 20.4s
pass 2 — pinning 40.1s 19.8s
total 60.7s 40.2s
main-thread stalls 25 long tasks, 1901ms 4 long tasks, 242ms

Pinning twenty-six moments took longer than the video itself. At the candidate ceiling of a hundred and sixty that is four minutes.

Why, and the part I did not expect

It seeked to each frame in turn and gave each seek 400ms before drawing whatever was on screen anyway. A seek on a clip that size takes 342ms on average, and I measured 56 of 84 running past the allowance — every one with the element still seeking when the frame was taken.

So two thirds of the comparisons made by the pass whose entire job is landing on the exact frame were between stale pictures. It survived that because a wasted comparison scores about zero and simply loses, so the answer degraded rather than collapsing — but the window was really being sampled at a third of the frames it thought, with nothing saying so.

The size matters, and it is exactly your case:

 320w:  11ms a seek,  0/56 past 400ms
 640w:  40ms a seek,  0/56
 960w:  91ms a seek,  0/56
1280w: 171ms a seek,  0/56
1920w: 342ms a seek, 28/56 past 400ms

What it does now

One seek per run of nearby moments, then the stretch is read by playing it and taking frames through requestVideoFrameCallback — which only fires for a frame that was really presented and hands over that frame's own time. A frame the machine could not keep up with goes missing rather than being mistaken for its neighbour. Windows within four frames of each other are read together, since stopping costs more than reading a few frames nobody asked for.

Played at half speed, which sounds backwards for the pass that was the slow one. At full speed one frame in eighty was never presented, and the frames a decoder struggles with most are the ones straight after a cut — which is to say precisely the frames this exists to find. A dropped one there does not read as noise, it reads as the change happening a frame later than it did.

rate 1.00: 1 gap in 81 frames,  944ms a window
rate 0.75: 1 gap in 83 frames, 1089ms
rate 0.50: 0 gaps in 84 frames, 1384ms   ← chosen
rate 0.25: 0 gaps in 84 frames, 2325ms

Half costs almost nothing in the end, because the window is three or four frames wide and the seek that reaches it dominates either way — against the 4680ms a window this replaces.

Two other faults that fell out of it

  • Stopping during the pinning pass did not stop. The guard on the button admits it, so the screen cleared and said "stopped reading the video" — and then the pass, which was never given the signal, ran to the end and put its moments up regardless. Moments out of nowhere, minutes after somebody asked for none.
  • Asking a video element for the time it is already showing fires nothing, so the wait ran to its cap. The first stretch of a pass often starts at zero, which is where a clip already sits.

Nine tests, and two wrong readings worth naming

Seven unit tests on which stretches get read together, each checked by putting the fault back — never joining, joining everything, not extending a run's end, not sorting.

Two browser tests: a moment lands on the frame the picture changed on, and stopping during pinning stops. The first fails plainly on the code it replaces, twice:

frames off:  1  3  1  3  1  2  2  1  3
frames off: -2  1 -2  2 -2  2  2 -1  2

Getting there took two wrong readings, both mine and both worth recording:

  1. It read the moment times through the app's timecode and assumed 30fps. The app measures the rate off the clip. Every moment converted to a time part way through a shot where nothing changes — fourteen readings of exactly zero, and a test failing while the code was right.
  2. It read the clip back by playing it, and that dropped frames. Passed on its own, failed in a full run of the suite: eight of nine readings came back with a hole in them, and a hole shifts every frame after it one place along. It now seeks instead, one frame at a time, waiting with nothing to run out — slow, but a check has to be sounder than the thing it checks, and this has nine moments to look at rather than a hundred and sixty.

379 unit tests, 102 browser tests, build clean.

What this does not do

Pass 1 is unchanged, and I do not think I can fix it here. It plays the clip at 2× and reads presented frames, and a browser presents about thirty frames a second whatever playbackRate says — so fifteen measurements per second of video costs half the clip's length, and that ceiling is the display, not the code. Playing faster is the trade this repo already measured: twice as fast finds 18 of 40 hits instead of 39.

The one route past it is WebCodecs VideoDecoder, which decodes independently of presentation — but it needs a demuxer for the container and is its own piece of work, not something to bolt onto this.

For a five minute 1080p clip you are now looking at roughly two and a half minutes for pass 1 and about a minute and a half for pass 2, down from about four minutes total — with a stop button that genuinely stops.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ux1kydvUkLRoMbHp82ofDi


Generated by Claude Code

ibrahimweng and others added 2 commits August 31, 2026 18:28
Pinning was the slowest thing in the app and it was also wrong.

Measured on a 1080p clip of forty seconds: reading it took 20.5s and
pinning the twenty-six moments took 40.1s -- longer than the video. At
the candidate ceiling of a hundred and sixty that is four minutes.

The reason is that it seeked to each frame in turn and gave each seek
400ms before drawing whatever was on screen anyway. A seek on a clip that
size takes 342ms on average, and 56 of 84 of them ran past the
allowance -- every one with the element still seeking when the frame was
taken. So two thirds of the comparisons made by the pass whose whole job
is landing on the exact frame were between stale pictures.

It now seeks once per run of nearby moments and reads the stretch by
playing it, taking frames through `requestVideoFrameCallback`. That only
fires for a frame that was really presented and carries the frame's own
time, so a frame the machine could not keep up with goes missing rather
than being mistaken for its neighbour. Windows within four frames of each
other are read together, since stopping costs more than reading a few
frames nobody asked for.

Played at half speed, which sounds backwards for the pass that was slow.
At full speed one frame in eighty was never presented, and the frames a
decoder struggles with most are the ones straight after a cut -- which is
to say the frames this exists to find. At half, none went missing, and it
costs almost nothing because the window is three or four frames wide and
the seek that reaches it dominates either way.

  pass 1   20.5s -> 20.4s
  pass 2   40.1s -> 19.8s
  total    60.7s -> 40.2s
  stalls   25 long tasks, 1901ms -> 4 long tasks, 242ms

Two other faults fell out of it:

- Stopping during the pinning pass did not stop. The guard on the button
  admits it, so the screen cleared and said "stopped reading the video",
  and then the pass -- which was never given the signal -- ran to the end
  and put its moments up regardless. It takes the signal now, and the
  caller checks it before publishing.
- Asking a video element for the time it is already showing fires
  nothing, so the wait ran to its cap. The first stretch of a pass often
  starts at zero, which is where a clip already sits.

Seven unit tests on which stretches are read together, each checked by
putting the fault back. Two browser tests: one that a moment lands on the
frame the picture changed on, one that stopping during pinning stops.
The first fails on the code this replaces, at 5 of 8 moments within a
frame against 7 of 8. It reads 1080p in real time, so it wants the
machine to itself and skips above one worker; CI runs one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ux1kydvUkLRoMbHp82ofDi
The test that says a moment landed on the frame the picture changed on
read the clip back by playing it and taking frames as they came. That
passed on its own and failed in a full run of the suite: eight of nine
readings came back with a hole in them, because a 1080p frame the machine
is too busy to present is one the reader never sees, and a hole shifts
every frame after it one place along.

Guarding it with a skip above one worker was not enough -- the failure
came from a single-worker run.

So the check seeks instead, one frame at a time, waiting on each seek with
nothing to run out. That is the thing the code under test stopped doing,
and for a good reason: it is slow. It is the right way round here. This
has nine moments to look at rather than a hundred and sixty, and a check
has to be sounder than the thing it is checking.

It now discriminates plainly and repeatably. Against the pass this
replaces, twice:

  frames off: 1 3 1 3 1 2 2 1 3
  frames off: -2 1 -2 2 -2 2 2 -1 2

Against the pass that is here, nothing off by more than one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ux1kydvUkLRoMbHp82ofDi
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beat-studio Ready Ready Preview Aug 31, 2026 6:29pm

@ibrahimweng
ibrahimweng merged commit 1ba2fdb into main Aug 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant