Skip to content

✨ feat(video-engine)!: a browser video engine, and annotations that draw on the frame it presents - #93

Draft
cfviotti wants to merge 323 commits into
mainfrom
poc/ve-mini-consume
Draft

✨ feat(video-engine)!: a browser video engine, and annotations that draw on the frame it presents#93
cfviotti wants to merge 323 commits into
mainfrom
poc/ve-mini-consume

Conversation

@cfviotti

@cfviotti cfviotti commented Aug 21, 2026

Copy link
Copy Markdown

Description

The problem

supervision already plays a video. It opens the file through mediabunny, and the renderer asks the
source for a sample at a time the renderer picked.

What it does not have is exact frame identity. The frame index it reports is
round((mediaTime - firstTimestamp) * estimatedFrameRate), and its own type documents that as an
estimate. That arithmetic names the wrong frame on:

  • fractional frame rates, 29.97 and 59.94;
  • non-integer frame timestamps;
  • container timebases that are not milliseconds;
  • variable frame rate.

The failure is quiet. The pixels stay correct and only the annotations move. It survives pausing and it
looks like bad model output.

This pull request adds a browser video engine. The engine, not the renderer, decides which frame is
on screen. It publishes that frame with its own media time and its identity in the container's
timebase, and every annotation layer draws against that one value.

Who this is for

  • Applications that review model output on video: annotation tools, evaluation views, demos.
  • Hosts that already ship their own decoder and want to keep it. The push path is public.
  • Existing image and camera consumers. The engine is not on their path: they keep their own
    renderer sources and the renderer keeps picking the time. What does reach them is the
    maxDevicePixelRatio default in What breaks, which caps every presentation surface at 2.

The pipeline

flowchart LR
  SRC["Media source"] --> ENG["Video engine"]
  ENG -->|"presented frame + media time"| PRES["Video presentation"]
  ENG -->|"presented frame + media time"| DET["Temporal detections"]
  INF["Inference or fixtures"] --> DET
  PRES --> R["Renderer"]
  DET --> R
  R --> CAN["Canvas"]
Loading

Which path a source takes

flowchart TD
  A["Source opened"] --> B{"Which renderer source<br/>did the caller pass?"}
  B -->|"the default one"| PULL["Pull path"]
  B -->|"createWebVideoEngineMediaRendererSource"| C{"Engine chunk loaded?"}
  C -->|"no"| ERR["Throws, and names supervision/web-video-engine"]
  C -->|"yes"| D{"H.264 with an avcC record?"}
  D -->|"yes"| S["One decode session, held across seeks"]
  D -->|"no"| K["mediabunny sinks, re-positioned per request"]
  S --> PUSH["Push path"]
  K --> PUSH
  PULL --> P1["The renderer picks the time.<br/>It reads the sample timestamp."]
  PUSH --> P2["The engine picks the time.<br/>It publishes the presented frame."]
Loading

The library does not read the media and choose. The caller chooses by which source it passes. A
video file passed as a URL opens through mediabunny and takes the pull path.

The pull path is what existing consumers use. Its shape is unchanged: the renderer picks the time
and reads the sample timestamp, as it does on main. Its behaviour is not. A container that opens
with no parsed track now fails as UnsupportedFormat, where main failed it as NoVideoTrack.
errorKind is the field to branch on, so a consumer that reads it sees this. The default changes in
What breaks reach the pull path as well.

What the engine does

packages/video-engine is a new workspace package. It is private, so npm never publishes it. Its
build is staged into supervision, and consumers import it from supervision/web-video-engine. It
does not depend on the renderer.

Capability Detail
Decode One long-lived decode session across seeks for H.264 tracks with an avcC record. Other codecs decode through mediabunny's sinks, which re-position on each request.
Seek Keyframe-aware. A target behind the read head restarts from the keyframe before it and decodes forward.
Scrub Continuous, forward and backward. Gesture direction is read from a bounded ring of the last 8 samples, so one jittery pointer move cannot swing it and a reversal keeps a share of the prefetch window behind the pointer.
Cache Two tiers: full-resolution frames near the playhead, downscaled frames over more of the timeline. Both are written from one decode. Both tiers are sized from the device's reported memory inside fixed byte clamps; the decode resolution sets the per-frame cost and the floor.
Frame identity An index and a tick count in the container's own time grain. Built from each packet's own timestamp.
Ownership Every decoded frame has exactly one owner responsible for closing it. A host that never closes one is told so.
Zero copy Where WebGPU exposes importExternalTexture and the strategy decodes at the source's native size, a frame is sampled without a copy. Naming a display box makes the engine decode at the display size instead, which rules the path out. The demo names one.
Diagnostics A trace recorder exports a capture as JSON. armTrace(windowMs) sizes the ring from the broadcast rate.
Frame extraction The analysis entry point opens a source and pulls frames without a player.

Presented-frame identity

The frame table is built from each packet's own timestamp. That is what makes identity survive a
fractional rate or an unusual timebase.

The position published for a frame comes from the packet that was submitted. It does not come from
the timestamp the platform decoder returns. A decoder that counts from its own origin, or reorders,
therefore cannot slide the annotations off the picture.

A tripwire in the present throws if any layer is handed a media time other than the presented one.
It is armed in every build, production included. It costs about eleven comparisons per presented
frame.

Two rate-derived indexes remain. Both are documented as estimates, not identity:
estimatedFrameIndex in the renderer state, and the NearestFrameIndex detection selection mode.

Temporal detections

Detections are temporal data, independent of decoding. They can be precomputed, appended while
playback runs, or composed from several sources. Overlapping results update the active range without
rebuilding the whole annotation state.

Two producers exist in this repository: precomputed fixtures, and a remote model that pulls frames
from the engine's sample sink.

Prepared annotation rendering

The renderer prepares annotation artifacts ahead of the playhead. It keeps prepared frames on both
sides of it in a bounded cache, so a reversing scrub finds work already done.

On the push path, rendering is event-driven. Pixi's ticker is unused. The scene draws only on a
change: a new presented frame, a detection change, a prepared artifact landing, a hover or selection
change, or a presentation change. A paused scene nobody touches submits no frames. The pull path
still repaints on the ticker.

Masks

A worker builds one byte per pixel holding a detection id. A shader colours those ids from a palette
on the GPU. Where that raster cannot be built, the same worker produces an RGBA composite.
Boxes, labels and vectors draw as before; the hover silhouette is the one thing lost, because
the ids it needs are what the raster carries. The mask layer reports that state rather than
leaving it silent.

The palette holds 80 entries. One entry is the background, so a raster can name 79 detections. It is
keyed on detection index: a mask writes its detection's index plus one. A frame past the ceiling
falls back to the RGBA composite. That path walks each mask's runs rather than the whole plane
once per detection, which on 81 masks over 1920x1080 costs 19 ms for fills and 69 ms with
outlines, where walking the plane cost 151 and 337. The raster path is 1.5 ms. On the 2113-frame horse trail clip, 75
frames used to fall back and no longer do.

A host can declare the box it paints masks into, through
renderPreparation.maskFrame.display. The raster is then built at the size that box can show. Left
unset, masks are built at the detections' own resolution.

The push path runs on WebGPU where images stay on WebGL. Every shader therefore carries a WGSL
variant, and a test requires each shader to carry a program for both backends.

What is new in the public API

37 exported names are added and none is removed. The largest group answers a question a host could
not ask before: why will this file not play?

Added For
WebVideoEngineErrorCode Say why a file will not play. Ten codes, from DecodeUnsupported to RateUnsupported. Reached at supervision/web-video-engine.
createWebVideoEngineMediaRendererSource, openWebVideoEngineMediaSource, WebVideoEngineMediaSource Open a video file through the engine.
PresentedFrameChannel, PresentedFrameSource, PresentedFramePlayhead and their signal types Write your own push source.
PreparedAnnotationWindowSnapshot, PreparedAnnotationWindowFrame, PlaybackGateReach Read how far preparation and the gate have reached.
resolveMediaSessionDefaults, ResolvedMediaSessionDefaults Show a viewer the buffering numbers the session resolved, rather than a copy that drifts.

DecodedMediaSource declares both drive modes, and both are public. sampleSink answers
getSample(timestamp) for a time the renderer picked. engine is a PresentedFrameChannel: the
source announces each frame it puts on screen. A host with its own decoder can implement the push
path rather than only consume this engine's. sampleSink stays required either way, and the engine
supplies a real one over its batch analysis path, which is what serves thumbnails and one-off frame
grabs.

Out of scope

  • Choosing a model. Storing detection data. Application-level editing.
  • Audio. The renderer is video-only and audio playback is deferred.
  • Reverse playback. Rates outside 0.25x to 8x throw, negative rates included.
  • Moving presentation off the main thread. The cost is measured and the decision is deferred to its
    own pull request.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation or example update
  • Refactor or maintenance
  • Performance improvement

Validation

  • Tests added or updated where behavior changed
  • Documentation updated where the public API or workflow changed
  • Screenshots or recording attached for visual changes

How to run it

npm ci
npm run verify      # the full gate: boundary, format, lint, typecheck, tests, builds, smoke tests
npm run dev         # package watchers plus the demo server
npm run docs:serve  # the documentation site

Numbers, measured at this commit

Measured Value How
Test files 214, all passing npx vitest run
Tests 2,444, all passing npx vitest run
Engine test files 38 packages/video-engine/src/*.test.ts
Documentation contract checks 31, all passing npm run docs:check
Browser evaluation metrics gated 30, across 11 families METRICS in tools/demo-eval/baseline.mjs
Frame-table ceiling 1,000,000 frames: 9.3 h at 30fps, 4.6 h at 60, 2.3 h at 120 FRAME_TIMELINE.MAX_FRAMES
Mask palette ceiling 80 entries per frame MAX_ID_MASK_PALETTE_ENTRIES
Playback rate range 0.25x to 8x, forward only PLAYBACK_RATE
Detection selection tolerance 0.5 ms of playhead quantization PLAYHEAD_QUANTIZATION_TOLERANCE_SECONDS

The engine's 38 test files cover decoding, timelines, cache behaviour, scrub trajectories, playback
scheduling, frame ownership, worker communication and presentation. They run in Node against fake
browser APIs and a recorded packet table.

Where the numbers come from

Every performance figure in this description was measured on an Apple M3 Max, 16 cores, 64 GB, in
Chrome, against the 70-second horse trail clip at 30fps through the WebGPU renderer. The clip
carries 2,113 frames and 98,115 detections, which is 46 a frame.

Nothing here is measured on a slower machine, at a higher frame rate, or on a denser clip.

The browser evaluation harness

npm run eval:demo drives the running player and gates 30 metrics against a recorded baseline. It
exits non-zero on a regression. The baseline records the machine, the commit, the clip and whether
the tree was dirty. The repository ships no baseline file and gitignores it: one recorded on a
given processor is only meaningful on that processor, so it stays local.

Ten families: sync, latency, layers, cadence, throttle, blanking, drag, playhead,
backscrub, focus.

The layers family carries a hard budget: zero frames over 34 ms, in every layer combination.

Playback rate, presented-frame identity, cache behaviour and cache memory ceilings are covered by
engine unit tests instead. The harness only measures playback at 1x.

Reviewer checklist

  • npm ci && npm run verify from a clean clone.
  • Load every fixture in the demo picker and confirm each one draws. Use Chrome. Firefox cannot
    open the HEVC fixture.
  • Drag the timeline backwards with masks on.
  • Drag a detection, then resize it.
  • Change a style through setPresentation while keeping the same detection array.
  • Read the breaking-change table below against any custom MediaRenderer or interaction-style
    code.

Notes For Reviewers

Answers to review feedback

1. "Polyline rendering broke on a docs page."

You were right, and it is fixed. It was the fixture, not the polyline renderer.

The polylines page embeds the demo with the basketball_sam3 fixture. The page filters to
className === "basketball" and metadata.trajectoryTrackId === "basketball-track:0".

SAM3 returns a whole-scene answer for that prompt alongside the ball. The fixture's trajectory
step accepted the whole-scene mask as the tracked ball and stamped the track id on it. The page's
filter then kept it faithfully. The precise shape of the defect:

On the broken fixture Measured
basketball-track:0 detections with rect exactly 1920x1080 199 of 225
Polyline points within 40 px of frame centre 4,857 of 5,499 (88.3%)
Where the trace head parks Frame centre from frame 26 (t = 1.04 s) onward

af35486 rebuilt the trace and refuses any candidate covering 50% or more of the frame.
demo/src/fixtures/demo-fixtures.test.ts now gates it: widestFrameCoverage must stay under 0.5.
That assertion evaluates to 1.0 on the old data, so it is the regression gate for exactly this.

At HEAD the trail is a ball trail: 216 polylines, all on the ball track, footprint 0.013% to 0.221%
of the frame.

The polyline renderer itself is untouched by this branch apart from the new shadowStroke default,
which landed after your report.

2. "Make 'buffered by detections' part of the createMediaSession API."

Done. createMediaSession takes playbackGate, a plain boolean. You either want that playback
mode or you do not, which is the shape you asked for.

createMediaSession({ playbackGate: false }); // start at once, draw annotations as they land

It is an umbrella switch over two gates. Either gate can still be set on its own, through
detections.playbackGate and renderer.renderPreparation.playbackGate.

Gate Default when you pass nothing What it holds for
Render preparation On Prepared raster artifacts: masks, polygons
Detections Off, unless the session has appendable detections, or you pass playbackGate: true Detection frames arriving and covering the playhead

Neither default changed. Both were already resolved this way on main. What was missing was a way
to say yes or no to the whole thing in one place, and a gate that reached a source presenting its
own frames at all: on main the wait lived in the renderer's sample pump, which such a source never
enters.

The docs page you saw playing bare now waits. The masks page embeds the demo. The demo opens a
sample on the Mediabunny media path, which the renderer pulls samples from. A pull source is held at
every frame whenever any gate is on, so playbackGateReach reports EveryFrame. The sample passes
no session gate, so render preparation is the gate holding it. The detection gate stays off, because
a sample ships its annotations with it.

One nuance worth knowing before you rely on it. On a source that presents its own frames, the two
gates reach different distances:

flowchart TD
  P["play()"] --> G{"playbackGate"}
  G -->|"off"| RUN["Frames arrive at once"]
  G -->|"on, pull path"| E["Held at every frame<br/>reach is EveryFrame"]
  G -->|"on, push path"| Q{"Which gate?"}
  Q -->|"render preparation"| S["Held at the start only<br/>reach is StartOfPlayback"]
  Q -->|"detections"| E
Loading

The render-preparation gate is awaited once inside play() on a push source, because stopping the
producer mid-run needs an answer about coverage that render preparation cannot give without waiting
for it. The detection gate does stop and restart the producer, so it holds every frame on both
paths. A pause or a scrub during the wait abandons the play, so readiness landing later does not
start a picture the viewer stopped.

What breaks

This takes supervision to 0.2.0-next.0, published on the next tag. latest stays on 0.1.7 until
0.2.0 goes out from main. The pinned public surface goes from 405 exported names on main to 425:
20 added, none removed. The engine's own names are not among them: they reach consumers at the
supervision/web-video-engine subpath. Every break below is a change to the shape of a type, or to what a
default does. Rows are ordered by how easily each slips past a consumer.

Change How you find out What you do
Four fields are gone from BaseInteractionStyleOptions: shape, cornerRadius, stroke, fill. All four were already @deprecated on main. TypeScript stops the build. Plain JavaScript says nothing, and your custom highlight silently becomes the built-in one. Move them into hovered.boxStyle and selected.boxStyle, which reach mask, label, keypoint, polygon and polyline highlights too.
requiredForPlayback is now requiredForCoverage. TypeScript stops the build. Plain JavaScript says nothing, and a false reverts to the default true, so the composed source waits on that entry again. Rename it. Polarity and default are unchanged.
Five protected resolvers are gone from BaseInteractionStyle: resolveBoxInstruction, resolveShape, resolveCornerRadius, resolveStroke, resolveFill. Nothing, unless you compile with noImplicitOverride. A subclass that overrode one keeps compiling and stops being called. Style through hovered.boxStyle and selected.boxStyle.
A container that opens with no parsed track now fails as UnsupportedFormat, where main failed it as NoVideoTrack. Nothing. A branch on NoVideoTrack stops matching that file and falls through to your generic handler. Branch on UnsupportedFormat as well. A container whose tracks read and carry no video still fails as NoVideoTrack.
The detection chunk cache raises its own ceiling to twice the widest buffer window it has served, from a floor of 12 chunks, and never lowers it. Nothing. Backward scrubbing finds more in memory. A long session holds more of it. Pass maxCachedChunks for a fixed cap.
MediaRenderer gains four required members: togglePlayback(), scrub(), getRenderCount(), getPreparedAnnotationWindow(). TypeScript stops the build, in your code. Implement them, or narrow the annotation to Pick<MediaRenderer, ...>. Anyone who only calls createMediaRenderer() is untouched.
maxDevicePixelRatio left unset now caps the presentation surface at 2, where main rasterized at the display's own ratio. Nothing, above 2x: the picture is drawn at 2 and looks slightly softer. Below 2x nothing changes. Pass window.devicePixelRatio explicitly for the old behaviour. The cap is what puts the surface, the mask rasters and the decode on one grid. A mask raster can only be sampled nearest, so a grid it did not share showed as stair-stepped edges.
A trajectory drawn with the default polyline style now sits on a dark contrast stroke. Nothing. An orange ball trail over a wooden court becomes readable. A path already on a contrasting background gains a thin outline. Pass shadowStroke: null to BasePolylineStyle to draw the path bare.
Detections for a file are re-derived every 2.5 s, where main re-derived every 0.5 s. Nothing. A window that does not reach the playhead still reloads at once, so this only changes how often covered ground is derived again. Pass detections.buffer.refreshIntervalSeconds for the old cadence. Streams are unchanged at 0.25 s.
A file session buffers ten seconds of detections ahead of the playhead and five behind, where main buffered ten ahead and half a second behind. Core's own defaults move the same way, from five and half a second. Nothing breaks. Annotations survive a backward scrub where they used to blink out. The lookahead is main's; what changed is how much ground behind the playhead stays buffered. A narrower lookahead was measured and rejected: over 48 runs six seconds ahead lost to ten in 11 of the 12 backward cells and tied in all 12 forward ones, so the window was widened rather than shifted. If you measured memory, the window is 15 seconds against 10.5. Nothing. To pin the old window, pass detections: { buffer: { bufferAheadSeconds: 10, bufferBehindSeconds: 0.5 } }.
VideoSource.id is removed from UrlVideoSource, BlobVideoSource and StreamVideoSource. TypeScript stops the build if you set it. Drop the property from source literals. Nothing in the engine ever read it. These three types are new to supervision, and reach consumers only at supervision/web-video-engine, so no released consumer can be holding it.

playbackGate is not on this list, and that is deliberate. The render-preparation gate already
defaulted to enabled on main, and the detection gate already defaulted on for appendable sessions.
Both are unchanged. What is new is the playbackGate boolean itself: an off switch, and a way to
turn the detection half on for a session that is not appendable. Nothing an existing consumer does
starts behaving differently.

Two more are changes in output rather than removals.

  • Detection frame selection now tolerates 0.5 ms of playhead quantization. main compares the
    playhead against the frame's media time exactly. On a source whose frame timestamps are not whole
    milliseconds, a playhead that rounds down selected the previous detection frame. Sources on
    exact-millisecond timestamps are unchanged.
  • In NearestFrameIndex mode the grid step is measured from the buffered frames' own media
    times.
    frameRate is the fallback when the buffered indexes cannot give a step. With no indexed
    frame at all the mode does not apply, and selection matches by interval instead. A caller whose
    rate matched the clip sees no change. A caller who passed a nominal rate the clip does not run at
    was previously walked off the grid by the accumulating difference.

MediaRendererState gains five optional fields, so an existing renderer still satisfies the type.

Field Reports
drawnMaskFrameTime The frame the visible mask belongs to.
maskHeldStale That frame is not the one the active detections describe.
playbackGateReach How far the gate reaches on this source: Off, EveryFrame or StartOfPlayback.
seeking A seek is still in flight, where playbackState cannot say so.
scrubbing A drag is open on the playhead, so the viewer leads the picture rather than waits for it.

seeking answers for the transport. The transport settles one message before the landed frame
reaches the main thread. A host that needs "is the right picture up" must compare the presented
frame's own media time instead. A scrub sets seeking on every tick, so a host that draws a wait
indicator must read scrubbing first.

Deprecated

Deprecated Still works? Removal
MediaRendererOptions.muted Yes. Nothing ever read it. Delete at your convenience.
MediaSessionRendererOptions.muted Yes. Nothing ever read it. Delete at your convenience.
DetectionFrameSelectionOptions.frameIndexOriginTime Yes. Selection does not read it. Delete at your convenience. Each buffered frame carries the media time its index sits at.

Neither muted option was ever read, so nothing sounded different before or after. Audio playback
is deferred.

The main-thread cost

Every annotation is drawn on the page's own thread. The engine decodes off it. The picture and the
boxes, masks, labels, polygons, keypoints and focus over it are composited by Pixi on the main
thread, in one synchronous block per presented frame.

Measured on the reference machine and clip, playing from t=5s, three runs of a 6.0-second window
holding 180 presented frames:

Per presented frame Annotations off Annotations on
presentVideoFrame, entry to return 1.05 to 1.10 ms 1.08 to 1.14 ms
Main thread busy, all causes 7.23 to 7.27 ms 7.23 to 7.50 ms
Main thread occupancy 21.7 to 21.8% 21.7 to 22.5%

The frame period is 33 ms. Annotations cost 0.03 to 0.07 ms of the block.

What you see when the budget runs out is the picture falling behind. You never see annotations from
the wrong moment: the frame and every layer over it are drawn from one media time, in one block
nothing can interrupt.

A host application shares this thread with its own work. The direction is to make the block smaller
rather than move it to a worker, and the ceiling on what moving it would buy is known: the block is
1.08 to 1.14 ms of the 7.23 to 7.50 ms the thread is busy, so the rest of the thread bounds the win.
docs/internal/video-engine-presentation.md documents the mechanism. The figures above come from
a CDP profiling run over the demo, which is not committed.

Tradeoffs

We chose It costs
Walk every packet on open to build an exact frame table. Load is slower on a long file, and there is a hard ceiling of one million frames. A 70-second 30fps source walks 2113 packets in a measured 5.7 ms.
Composite every annotation on the page's own thread. The block runs 1.08 to 1.14 ms per presented frame on an M3 Max. It gets expensive on a slower machine, at a higher frame rate, or on denser detections.
Cap the presentation surface at 2x device pixel ratio by default. A display above 2x draws slightly softer. It is what puts the surface, the mask rasters and the decode on one pixel grid.
Ship the engine inside supervision, on its own import path. Every consumer downloads the engine. The published tarball grows from 654,101 to 1,732,755 bytes. A dynamic import() keeps it out of the bundle, so an app that only creates a media session emits no engine asset.
Refuse a file we cannot index exactly, rather than guess. Some files that would play in a <video> element are refused here. WebVideoEngineErrorCode names which limit was hit.
Commit the fixture media and its raw model output. demo/fixtures is 306 MB tracked over 107 files. Every clone and every CI run pays it.

Known limitations

  • Firefox cannot open an HEVC clip. Firefox 154 plays an HEVC file in its own <video>
    element, but its VideoDecoder reports both hvc1 and hev1 configurations unsupported. The
    engine refuses the file at load with DecodeUnsupported, before any frame is presented. There is
    no software fallback in the package. This is that decoder, not a rule about non-Chromium browsers:
    Safari 18.6 reports both supported and plays the same file. The demo's default fixture, the 70s
    horse trail, is HEVC Main 10, so Firefox errors on it. The 9-second basketball fixture is H.264
    and plays.
  • Without a usable WebGPU device, every presented frame is copied through a staging canvas.
    This is the generic fallback, not a browser-specific path. Safari 18.6 reaches it because it has
    no WebGPU at all. Firefox reaches it for a different reason. The cost is characterised in
    docs/internal/video-engine-presentation.md: the upload runs once per presented frame and
    dominates the wall clock during playback, where a straight VideoFrame upload costs a small
    fraction of that. It carries no number.
  • Masks are built at the detections' own resolution unless a host declares a display box.
    Passing renderPreparation.maskFrame.display is what makes the raster follow what the screen can
    show. The demo passes one. The presentation numbers above are optimistic for an integration that
    has not opted in.
  • A frame whose mask is still being prepared may keep the previous frame's mask, for exactly one
    frame.
    The bound is one frame of the buffered detection timeline, whatever media distance
    separates them, and holds cannot accumulate. Nothing scales it: a budget in seconds multiplied by
    playback rate once left a mask four tenths of a second stale after an 8x run, and there is no rate
    on this path. Boxes, labels and the other layers draw regardless, because readiness is tracked per
    layer rather than per session. drawnMaskFrameTime and maskHeldStale report a hold while it is
    happening. Measured on a slow backward scrub, this takes frames drawn with no mask from 14% to 4%
    and the flicker from ten a second to under three; at 4x it changes nothing, which is what one frame
    is worth at that speed.
  • The StreamVideoSource variant is declared but no test or demo exercises it. A stream cannot
    be re-opened, so the decoder-recovery path degrades instead of rebuilding on one.
  • Reverse playback is refused rather than clamped. Rates outside 0.25x to 8x throw.

Three defects that ship on main today

All three are fixed here, and none of the fixes is on main. The code each one lives in was there
first: region effects and their fixture landed on main before this branch, the prepared-window
timeline has been there since the shape-primitives work, and the interaction layer has followed a
selected detection across frames since before this branch opened.

The region-effects lens jumped off a player's head, frame after frame. Some lenses floated over
the crowd with nobody under them. A head the model did not see was moved by however far the player's
whole bounding box moved, and that box is set by whichever limb reaches furthest, usually a raised
arm. An invented head now sits between the two real observations on either side of it.

Invented heads Median error Badly placed
Before 7.2 px 15.7%
After 2.8 px 2.8%
Real heads, for scale 2.8 px 3.5%

A detection selected while scrubbing vanished for good the first time its annotations were late.
Scrubbing backward is where they are most often late, so the selection usually died within a frame or
two of the first drag, and picking the detection again was the only way back. An absent frame and a
detection that had genuinely left the video both rebased to nothing, and the caller wrote that empty
result over the selection. The follow step now leaves a selection alone while data is missing and
adjudicates on the next frame that has any.

On a looping clip the prepared render window ranked a frame from the previous lap as the furthest
thing prepared.
Seventy seconds of footage reported 66.86 seconds of readiness for 211 frames
covering seven. That number is not a readout: it is compared against the lookahead a session asks
for before playback is considered ready, so a wrong value can hold or release the gate for the wrong
reason.

Reading the diff

The pull path is unchanged. The push path, the transport, the frame-present walk and the
prepared-annotation window are new files, reached only through a presented-frame channel. Today only
the video engine drives that channel. The pull path keeps its three ticker callbacks and its draw
order. That is the split worth holding in mind while reading the renderer diff.

Almost every deletion is fixture data. 2,191,256 of 2,197,601 deleted lines sit under
demo/fixtures, because the detection payloads are no longer pretty-printed. Outside those
fixtures the diff is 425 files, 88,464 insertions against 6,345 deletions. That is the code to
review.

The fixture data itself differs from main. The SAM3 fixtures are generated against the source
videos at their native frame rate rather than a resampled proxy. The clearest case is the basketball
sample. On main its manifest reads 270 frames at 30fps against basketball_sample.normalized.webm.
Here it reads 225 frames at 25fps against basketball_sample.mp4, the clip's own rate. Loading every
fixture in the demo picker covers this better than reading the diff does.

What the fixtures cost a clone. demo/fixtures is 306 MB tracked over 107 files, in a
repository whose .git is 772 MB.

Fixture Tracked Largest single file
horse_trail 231 MB 1min-horse-video.mov, 128 MB, the media the demo plays
basketball_sam3 28 MB raw-sam3.jsonl, 11 MB
basketball_sample 28 MB basketball_sample.mp4, 22 MB
basketball_regions 19 MB head-detections.json, 9 MB

horse_trail/raw-sam3.jsonl is 44 MB of raw model output kept for provenance beside the 59 MB of
chunked detections derived from it. Nothing loads it at runtime. It is worth deciding deliberately,
since it is what every reviewer and every CI run pays to clone.

Packaging and release

The engine does not publish on its own. packages/video-engine is a private workspace, and its
browser build is staged into supervision under dist/web-video-engine. Consumers reach it by
import path:

import { createWebVideoEngineMediaRendererSource } from "supervision/web-video-engine";

The subpaths are supervision/web-video-engine, supervision/web-video-engine/analysis and
supervision/web-video-engine/worker. createWebVideoEngineMediaRendererSource and
openWebVideoEngineMediaSource are exported from the package root as well, and are the same function
in both places.

There is no second install and no optional peer dependency. Installing supervision installs the
engine, because the staged build is inside the tarball. The tarball grows from 654,101 to 1,732,755
bytes, and every consumer pays that download even if it never imports the engine. The bundle cost
stays conditional. The engine is reached by a dynamic import(), so an app that imports only
createMediaSession emits 1,750,666 bytes and no engine asset, while adding the engine adapter
emits 3,278,684 bytes with the engine in its own 1,503,131-byte chunk. Still images and camera input
never load it. Opening a video file does. If that chunk does not load, the video path throws an
error naming supervision/web-video-engine and saying the engine is a lazily loaded chunk of
supervision, rather than a bundler stack trace naming a hashed asset.

The release workflow publishes one package. It builds the video-engine workspace, stages that build
into dist/web-video-engine, and deletes the engine's file: devDependency from the packed
manifest. It then builds the portable tarball, smoke-tests it in a clean consumer, and publishes
supervision. A released supervision therefore names no engine package and no engine version.
After the upload the workflow polls npm view supervision@<dist_tag> up to twelve times at
five-second intervals, until the dist-tag resolves to the version it just published. The workflow
publishes from main, or from a release/* branch when dist_tag is next.

No release step needs a person. supervision is already on npm, so its trusted publisher is
already attached. The workflow publishes the generated tarball with npm publish and authenticates
through OIDC. It needs no npm login and no NPM_TOKEN. The engine is private and is never
published, so there is no second name to register.

Two things that will not warn you

A custom workerFactory must match the host's version. The mask preparation protocol changed.
The artifact kind is idMask rather than pngIdMask, the payload field is raster rather than
png, and the job carries a maxRasterWidth. None of those types is exported, so nothing warns.
Point the factory at supervision/render-preparation-worker and this cannot happen.

Content Security Policy is unaffected. This package already spawns classic blob workers for mask
preparation and for tracking. The engine's worker needs the same directive and no new one.

Documentation status

docs/public is the published documentation and it is checked against the code. npm run docs:check
runs 31 checks: every path a document names exists, every npm script it runs is declared, every flag
matches the script that reads it, every checksum matches the file beside it, every version matches
the manifest, every symbol it imports is exported, and every copyable integration example
typechecks. All 31 pass.

Eighteen files under docs/public change here:

Page Covers
guides/browser-support.md New. The four limits an integration has to plan around.
api/video-engine.ts New. The engine subpath's own surface, pinned.
guides/media-sessions.md, guides/detections-and-rendering.md, guides/media-preparation.md, recipes/streaming-detections.md, recipes/multiple-detection-sources.md Which distance each gate reaches on which source.
guides/application-integration.md The single install, the engine's import path, and the download that carries the engine either way.
guides/public-api.md, concepts.md, annotation-renderers/polylines.md The push path, the presented frame, and the polyline trail.
api/media-preparation.ts, api/rendering.ts, api/sessions.ts The 20 added exported names, pinned.
guides/presentation-styles.md, recipes/interactive-picking.md, recipes/progressive-upload-normalization.md Engine references renamed, and the picking and upload recipes kept in step.
typedoc-icons.js The generated icon set the API pages render with.

One gap I know about and have not closed:

  • guides/browser-support.md says Firefox refuses every HEVC profile. What was measured is that
    Firefox 154 reports hvc1 and hev1 configurations unsupported. Every HEVC codec string starts
    with one of those two, so the claim follows, but it is inferred rather than tested profile by
    profile.

What this pull request does not have

  • No screenshots and no recording. This is a visual change and it should have one. The demo is
    the artifact worth recording: load a fixture, scrub backwards with masks on, and watch every
    annotation stay on its frame.
  • The evaluation harness leaves no artifact in the repository. It was run on this commit,
    clean tree, engine path pinned, on the 70s horse-trail clip: eleven scenarios pass and
    battery skips itself, because it needs a Storybook this repository does not ship. Detections
    land on their frame with a worst offset of 0 ms, a backward scrub keeps every mask
    (maskInkRatio 1.000) and settles at 91 ms p95, no presented frame is dropped, and there
    are no skipped intervals or engine stalls. Seek p95 is 3.7 ms against a 250 ms limit and step
    p95 is 53.5 ms against 80 ms. None of that is in the diff: .gitignore excludes
    tools/demo-eval/report.json and tools/demo-eval/baseline.json, because those are one
    machine's numbers and only mean anything on that machine. Reproduce with
    npm run eval:demo -- --url 'http://localhost:5173/?mediaPath=engine'.
  • No second machine. Every performance number here is one M3 Max in Chrome. Nothing is measured
    on a slower machine, and the main-thread block is the number most likely to move on one.
  • No comparison against the alternative. The cost of moving presentation to a worker is priced
    on one side only. The other side needs a harness story that lives in the engine repository.

cfviotti and others added 30 commits August 21, 2026 18:10
Writable source, cold store pruning, the composite source's loadFrames options, and the new projected detection frame source with its coordinate projection utility. This branch never opened these files.
Media error kinds, the presentation-timeline media source, the live session writes, and the region, vector, overlay, interaction and mask-halo pixi layers plus the shared annotation shape styles. Files this branch never opened, so they land verbatim from origin/main.

Their callers did diverge. pixi-mask-layer.ts, pixi-media-scene.ts, pixi-focus-layer.ts and the mask pipeline are in the conflicting set and are not touched here, so this tree does not compile yet.
The Android video frame source, its nitrogen output, the dependency patches and the iOS-side updates that came with them. This branch has never touched React Native.
Core package manifest, rollup config and tsconfigs, typedoc config, the geometry fixture generator's new geometry.mjs, the docs contract test and the tarball packaging tools. All upstream-only paths.
The four new annotation renderer pages, the tracking post-processor pages, the vector primitives proposal, the Android plan, and the demo playgrounds and controls this branch never opened.
packages/web/rollup.config.js and packages/web/package.json are the one pair
in the both-touched set where a clean auto-merge would still lose something:
upstream's third rollup config, the embedWorkers rename with its second
sentinel, the "post-processing" source alias root, the "#post-processing/*"
import and the "./detection-post-processing-worker" export all sit in regions
this branch never edited, so taking ours would silently drop the tracking
worker from the published package.

Both sides are kept: upstream's file as the base, with this branch's
supervision-js-video-engine externals and optional peer block re-applied on
top. Version follows upstream to 0.1.7.
…proxy

The fixture arrives from origin/main, where its detections were computed on a
30fps grid and the demo re-normalized the variable-rate source in the browser
at load. This branch replaced that with a committed forced-CFR proxy declared
as media.proxyFile, so the upstream meta's normalizeInBrowser flag would have
been read by nobody: the demo loader globs every fixture directory and casts,
so no type error fires, the fixture plays the raw variable-rate MP4, and every
detection lands on the wrong frame.

Ported the meta to proxyFile and pointed it at
demo/fixtures/basketball_sample/proxy-30fps.webm, which is 1920x1080 VP9 at a
flat 30/1 over 9.000s, matching the manifest's 270 frames. The proxy is the
same normalizeMedia output the v1 pipeline produced, timestamp for timestamp,
so it names the same frames the browser normalization did.
Nothing built or typechecked in this tree: `packages/trackers` was missing
from the root `workspaces` array, so every `npm run -w supervision-js-trackers`
in the build chain failed before tsc was reached.

What changed:

- `packages/trackers` joins `workspaces`, and `clean`, `typecheck` and
  `dev:lib` name it alongside this branch's `supervision-js-video-engine`.
  `build`, `build:js` and `build:types` reach it through core, whose own
  chain builds trackers first.
- `vitest.config.ts` takes both sides' aliases: upstream's `#post-processing/`
  and `#types/ellipse-style` next to this branch's video-engine entries.
- The boundary rule that published packages declare every package they import
  exempts private workspace packages. `supervision-js-trackers` is private, so
  core cannot declare it and rollup inlines it instead; declaring it would ship
  a manifest npm cannot resolve.
…e ingestion

A detection producer whose results are in its own coordinate space now has a
declared way to say so, a live producer can append a frame that stays current
until the next one supersedes it, and a bounded retention window prunes in
place instead of reloading and rewriting everything it keeps.

What changed:

- `DetectionFrameLoadOptions` on `loadFrames`, so a composite source projects
  each child while its `coordinateSpace` is still attached.
- `ColdDetectionFrameStore.pruneFrames` and `DetectionFrameLiveOptions`, with
  `LiveWritableDetectionFrameSource` naming the shape
  `createWritableDetectionFrameSource` actually returns.
- `copySortedDetectionFrames` and `validateDetectionFrames` carry and check
  `coordinateSpace`, `keypoints.boxRelative` and `trackerId`, so a frame
  survives a copy with everything the new sources put on it.

The counting frame source in the buffered-timeline tests now declares the
`readonly DetectionFrame[]` return the `DetectionFrameSource` contract asks
for, so a deferred load can stand in for it.
…rror kinds

A detection producer that reports in its own coordinate space now renders at
the right scale: the renderer projects every detection input once, where the
media dimensions are known, instead of trusting each source to match. A media
failure also carries a machine-readable kind, so a host can tell a decode
failure from a network one without matching on the message.

What changed:

- `media-renderer-core.ts` wraps static frames, a caller-owned source and a
  composite source in `createProjectedDetectionFrameSource`, and its `play()`
  and `seek()` guards treat `Buffering` as a stalled form of playing.
- `media-renderer-state.ts` routes render errors through `toMediaSourceError`,
  so `MediaSourceState` reports `errorKind` next to `errorMessage`.
- `media-renderer-scene.ts` and `pixi-interaction-presentation-layer.ts` carry
  the halo, ellipse, marker and box-corner styles through presentation.
- `package-smoke.test.mjs` expects the tracker factories, the projection
  helpers and the media-error surface in the published runtime exports.

The `Buffering` guards are upstream's answer to a state machine this branch
re-specified around `media-renderer-transport.ts`. They merge cleanly and no
test on either side contradicts them, so they land as taken and the meaning of
`Buffering` after the merge stays open.
…t notes

The demo shell can reach the tracking post-processor playground the docs site
embeds, and the demo presentation exposes the ellipse, marker, box-corner and
mask-halo styles alongside per-class visibility.

What changed:

- `App.tsx` routes the `post-processor` embedded view to
  `DocsTrackingPostProcessorPlayground`.
- `demo-presentation.ts` maps the new renderer settings onto their styles, and
  its tests pin the ellipse footprint and marker placement.
- `docs/internal/library-contract.md`, `npm-release.md`, `tarball-packaging.md`
  and `agent-guidance.md` describe the two-package release surface.
- `.gitignore` covers the Android library module's Gradle and CMake output.
Reading the playback-gate options told you two opposite stories. The interface
headers said the options are accepted and ignored; the field docs under them
said playback pauses until detections arrive, and the live-coverage docs said
finalizing keeps coverage-gated playback from stalling. Nothing here gates the
picture on annotations, so a host wiring `playbackGate.enabled` from those
field docs got silence.

What changed:

- `DetectionPlaybackGateOptions` and `RenderPreparationPlaybackGateOptions`
  mark each field ignored and say what happens instead.
- `DetectionBufferPrepareOptions` says all three of its fields are ignored, and
  points at `setTimelineContext` as where duration and looping come from.
- `finalizeCoverage` and `finalizeDetectionCoverage` name the readers they
  actually serve: `waitForRange` callers and the buffered window's coverage
  arithmetic.
- A docs-contract test reads the three surfaces the flag spans and fails if any
  stops saying that playback never waits, or starts asserting a gate again.

Reversing the decision means restoring upstream's `waitForPlaybackGate` in
`buffered-detection-timeline.ts` and re-measuring `tools/demo-eval/baseline.json`,
which was recorded with the gate gone.
… pipeline

A Region renderer set to crop by a detection's own mask had nothing to crop
with: the coverage plane upstream threads through mask preparation never
reached this branch's raster representation, so those regions fell back to
box crops. The prepared artifact, the worker protocol and both preparers now
carry per-target alpha crops alongside the id raster and the RGBA composite.

An instruction can also now say it exists only to carry that coverage.
`materializeMaskInstructions` drops it, which gates the RGBA composite and the
id raster at the one point they share, and a test pins that both stay empty.

The browser package's export surface unions upstream's trackers, projection,
media errors and the four new annotation styles with this branch's video
engine and prepared-window exports.
…k layer

The mask halo renderer had no way in: it reads detection ids out of a texture,
and this branch's mask layer only ever built one for its own shader. The layer
now resolves halo passes per frame from the live halo style, renders them
beneath the mask so the glow bleeds outward from the silhouette, and builds an
id texture for the RGBA composite path from the id plane the preparer carries
alongside it.

Region renderers cropping to a detection's own mask can now ask the layer for
the active coverage frame and a texture per target.
…e scene

Ellipse, marker, box-corner and mask-halo renderers reached the renderer but
stopped at the scene, so a presentation carrying any of them drew nothing.
The scene now composes the three shape kinds onto whatever shape style the
caller passed, and forwards the halo style to the mask layer.

Region renderers cropping to a detection's own mask get the full pipeline:
the scene keys the prepared artifact by which targets crop that way, emits an
invisible coverage instruction for a target with no mask of its own to draw,
and hands the layer the active coverage frame and the media texture.

Mask preparation now also runs for a halo-only or coverage-only presentation,
which previously had no reason to cook an artifact at all.

The shared Pixi test double gains the alpha mask and blur filter the region
and halo paths construct, keeping this branch's buffer-image source alongside.
…d picking

Hiding a class left it lit: ambient focus targets every detection in the
frame, so a class the caller had hidden was still cut out of the dim overlay.
The focus layer now takes a visibility predicate and drops those targets
before it decides whether it has anything to draw.

Dragging a detection had the same shape of bug from the other side. Its hover
and selection presentation redrew from the stored geometry while the base
layers were already drawing the gesture, so the highlight trailed behind the
shape. The interaction presentation layer already knew how to suppress that;
nothing was telling it when.

The renderer core's detection loads carry the media coordinate space, so its
assertions now expect the third argument.
Covers what the halo and visibility ports actually promise: two detections
asking for different spreads get one blur pass each with only their own id in
the palette, a halo still draws when mask preparation falls back to the RGBA
composite, and a hidden detection draws no interaction presentation at all.
…ource

The tracking playground needs to sit between the fixture's chunked source and
the session so it can re-track loaded frames, and it needs to pause, seek,
refresh and resume around a re-track. The fixture loader now takes a source
wrap, and the renderer hook exposes the playback and refresh controls those
steps need.

Playback control names follow the hook's own vocabulary rather than the props
of the one component that used to consume them, and seeking resolves when the
seek has settled instead of returning before it.

The region-effects sample opened on a blank picture in the sample picker,
which its own showcase hid because that playground pins every layer off. It
now opens with masks drawn, the geometry its detections are built around.
… fixtures

The public API guide and the internal ingestion note each described half of the
merged tree. They now describe all of it: coordinate-space projection, tracking
post-processors, region media crops, the new renderer kinds, typed media
failures, live append and coverage finalization, and bounded retention, next to
this branch's video-engine source and its rule that playback never waits for
annotations.

The keypoint showcase fixture the tracking playground opens on was missing, so
that playground silently fell back to a different sample. It is here now, on the
30fps proxy its detections were computed against.

The geometry fixture tool takes upstream's head-region pipeline with this
branch's guard on top: a pose run measured against a different frame grid is
rejected rather than warned about, because every frame index resolves against
any grid and the skeletons would simply land on the wrong frames.

The React Native patches now apply on install, and the lockfile carries both new
workspaces and the tool that applies them.
Scene fixtures now carry the four renderer styles the options require, and the
halo test names the prepared frame by the representation this branch keeps.
…aces

A caller reading `requiredForPlayback` on a composed detection source sees a
name that promises a playback gate, and this build has none. The web-side
option and the multiple-sources recipe both say so outright; the core type,
which is the one a library consumer reads first, stopped at "waitForRange
skips this entry" and left the gate question open.

The docs contract test checks the file, not the doc comment, so the core
surface satisfied it on prose written for `playbackGate` several hundred
lines away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream renders through WebGL. This branch's push presentation forces WebGPU.
A shader written in GLSL alone therefore draws nothing here, and does it in the
worst possible way: no error, no failing test, a clean typecheck, and a picture
that is simply missing something.

Two arrived that way in the merge. Exact-mask media regions, upstream's
headline feature, drew nothing at all: bare video where the cropped heads
should be. The mask halo drew nothing either, which is harder to notice because
its absence looks like a style choice.

Regions had two further breaks stacked under the shader, both invisible to a
compiler. The region layer was handed the canvas the pull path uploads into,
which nothing writes to under GPU compositing, so crops sampled an empty
surface. Handing over the presented texture then exposed the last one: the demo
plays a proxy smaller than its media, and the region layer addresses in media
coordinates, so every crop was computed against the proxy's pixel size and
sampled the wrong part of the frame. That is the misalignment a human spotted
before any test did.

Attribution was proved by isolation rather than argued: forcing WebGL drew the
crops while WebGPU did not, removing the WGSL alone returned it to bare video,
and disabling the alpha mask alone gave rectangles. The halo is pinned the same
way: against a paused frame, the fixed build changes 19 percent of the canvas
when the halo is enabled, and the build from HEAD changes zero pixels.

A test now walks the renderers directory, finds every shader by reading it, and
fails when one lacks a program for either backend or names a resource its WGSL
does not declare. Discovery rather than a list, so a shader written next month
is covered by nobody remembering anything. It fails on both shapes of this
defect, verified by introducing each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The horse trail's detection chunks were stored one JSON token per line. That
is 2.1 million lines for 71 files, and it made every regeneration look like a
rewrite of the repository: a pull request that touched them read as eight
hundred thousand changed lines, which is not a diff anybody can review.

They are minified now, which is what the basketball fixtures already were. The
loader parses them, so the formatting was never load-bearing.

2,139,080 lines become 102, and the tracked payload drops about thirty percent.
No detection, mask, polygon or keypoint value changes: the files are reparsed
and re-serialised, not rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sample picker offered three basketball clips that a viewer could not tell
apart. Two were the same nine seconds of the same game and differed only in
which model run produced their detections and whether the demo played the
source file or a 30fps transcode of it.

There is one now, and it is the better of the two: five annotation kinds on the
clip's own 25fps frames, where the removed fixture had four on a resampled
proxy. The three documentation playgrounds, the annotation renderers, the
homepage basketball demo and the tracking post processor, all open on it.

Nothing on those pages looks busier or emptier than before. The merged fixture
draws 10.9 detections a frame at its own confidence gate against the removed
one's 11.0, and the per-second profile matches: 4.4 at the opening rising to a
plateau of 11.8 to 13.1.

Two pages needed care to keep looking right:

- The polylines page pins its confidence gate to zero. That page scopes itself
  to the ball's one derived trace, and the fixture's 0.5 gate hides 200 of the
  224 trace segments, so the page drew the ball with no trail at all.
- The tracking page stopped inventing a frame count. It read "0/270" while
  loading, which was the removed fixture's length quoted as a fact. It reads
  "0/0" until the real number arrives.

The clip's media also stopped being tracked twice. `basketball_sample.mp4` was
committed in two fixture directories; the second was a Git LFS pointer, so a
clone was fetching the same 22MB payload a second time for nothing. Both
fixtures share the one copy. `benchmark/masks/run.mjs` located that media
through the manifest's provenance record, which names a path that no longer
exists, so it now reads `fixture.meta.json`, which is what the demo itself uses.

The removed fixture's pose run moves to `basketball_regions`, the only thing
that still reads it, and the fixture builder's defaults follow the fixture that
survives. Its README now carries the geometry coverage and the provenance the
removed one held, including the two model runs that cannot be reproduced from
this repo as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anyone installing `supervision` and reaching for video got told to install
`supervision-js-video-engine`, and that package did not exist. The release
workflow built and published one tarball, the browser package, and the engine
sat at version 0.0.0 with nothing to publish it.

The release now ships both. The engine goes first, so the browser package never
reaches the registry naming a peer that cannot be installed, and it publishes
through the same guards the browser package already uses: the manifest version
is the source of truth, an already-published version is a silent no-op rather
than a failure, and a prerelease and its dist-tag have to agree.

The engine starts at 0.1.0, matching how this project released its first
browser version, and the browser package moves to 0.1.8. It had been sitting at
0.1.7, which is what npm already serves, so the next release would have been
refused as a republish.

The optional peer range narrows from `"*"` to `"^0.1.0"`. The old range would
have accepted a future incompatible major, and the failure would have surfaced
at run time inside `openVideoEngineMediaSource` rather than at install.

One step still needs a person, once. npm will not attach a trusted publisher to
a package name that does not exist yet, so the very first engine release fails
until someone registers the name from their own machine:

    npm trust github supervision-js-video-engine \
      --file publish-npm.yml \
      --repository roboflow/supervision-js \
      --environment npm-publish

The engine also gets the LICENSE and README that npm always ships regardless of
the `files` list, so its package page is not blank. Its entry-point table was
checked against the manifest's own `exports` map rather than written from
memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The halo glow vanished entirely on any scene where some masked detections are
filtered out of the picture. Every player kept their silhouette, and not one of
them glowed.

A single wide mask was enough to do it. The id mask raster holds one identity
per pixel, so the last mask written to a pixel owns it, and the halo reads the
identities back to find the silhouettes it paints. The layer that prepares that
raster admitted every detection carrying a mask, including the ones the halo
declines to paint. A full-frame detection at low confidence therefore claimed
every pixel on screen, buried the identities of the players above it, and left
the halo with a palette full of entries and no pixels to match them.

The preparation and the paint now ask the same question through one predicate,
so they cannot disagree again. A halo that paints nothing, whether because it
has no mask, no instruction, no opacity or no spread, also claims nothing.

That last case was live: a demo halo style reports its configured opacity
unconditionally, so at zero glow opacity every masked detection was still
claiming raster pixels while painting nothing at all.

Preparing that coverage is expensive, so the scene reuses it until the set of
detections the halo admits actually changes. It compares the two styles over
the detections currently buffered instead of assuming any restyle is a new set,
which is what a style whose only member is an arbitrary function allows. Moving
the spread slider through twelve steps cooked mask coverage 204 times before and
cooks it zero times now; driving glow opacity off zero still costs the 17 cooks
that genuinely have to come back.

One gap stays open and is documented where it lives: a prepared artifact can
outlive the buffered window, so a restyle that moves the admission boundary on a
frame the buffer has rolled past keeps a stale artifact. Closing it exactly needs
`MaskHaloStyle` to carry the identity `MaskStyle` already carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scrub group reported four seek timings above a population nobody could see,
and two counters the engine broadcasts every tick were read by nothing at all.
A reader looking at four timings and a zero had no way to tell "zero because
nothing asked" from "zero because the counter is broken".

A `Cursor seeks` row now reads them, split as exact and key.

Reading it against `Seeks` in the group below answers a question that has cost
real time twice: a seek issued while the video plays re-anchors playback instead
of moving the cursor, so it lands in neither count and times nowhere. Paused,
seven seeks read seven exact. Playing, the same seven seeks read zero here and
seven there. Both ledgers are on screen and visibly disjoint.

No engine counter was changed to make the panel look busier. The engine was
already counting these correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three ways the demo eval could report a number that was not true.

**A retried scenario measured a warmer page than its first try.** When a
scenario was disturbed and retried, the retry ran on the page the first attempt
had already warmed, so it looked dramatically better and the harness kept the
better number. Measured on the drag scenario, attempt one to attempt two went
32.6ms to 8.1ms stale and 50 to 91 frames a second, on the same build in the
same minute. A retry now reloads first, and the two attempts land together:
32.3 to 27.1ms and 50.8 to 62.2fps. The reload costs about 0.6 seconds and only
a disturbed attempt pays it. `cadence` keeps its page on purpose, because it
selects its own fixture and a reload would drop the demo back to the default
clip while every number still named the other one.

**The paints scenario could not see a pause that keeps drawing.** It waited six
seconds before it started tracing, so anything that decayed after a pause was
already over. It now starts the trace first and pauses inside the window.
Twenty passes put the settling burst at 167 to 177ms and 11 to 15 paints, with
zero paints once settled, so the new budget sits five times wider than the
widest pass: the gate is for a pause that keeps drawing, not for the transition.

**No recorded number said which tree it came from.** A report could be compared
against a baseline taken on different code with nothing to catch it. Reports now
carry the commit, whether the tree was dirty, and the fixture the scenario ran
on, and the baseline comparison warns before it prints a single delta.

The ten guessed noise floors are untouched. Picking numbers without measuring is
the failure being fixed here, and the paints scenario's neighbours now read
slightly outside two of them, which makes those floors the next thing to measure
rather than the next thing to widen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven documents in this repo were false at the same time and every gate stayed
green. A fixture README told a reader to run a script that has never existed.
The root README named a release that had already shipped. A rebuild command
silently read a different input than the one it named.

The gate checked that links resolve and that the API facades cover every export.
It never read a claim.

It now checks six kinds of claim across all 81 tracked Markdown files:

- a path a document names has to exist
- an `npm run` script it shows has to be declared, workspace forms included
- a flag it passes has to be one that script actually parses
- a checksum it quotes beside a path has to match that file
- a version it states beside a package has to match that manifest
- a module it imports has to export what it imports

Each was proven able to fail by injecting the failure and watching the gate
catch it, including the two real ones above. The link check widened from a
subset to all 95 links in the corpus.

Six live violations turned up, all in planning documents: a module that never
existed, a proposed filename read as an existing path, three references to a
module that was renamed before it shipped, and an API sketch importing three
symbols under names the package does not use.

Counted claims like "nine tsc projects" are not checked. A number in a sentence
has no mechanical link to the set it counts, and a gate that guesses is worse
than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run verify` was failing on this branch before any of today's work, on two
counts that had nothing to do with each other.

The mask benchmarks declared `setTimeout` and `clearTimeout` in a `/* global */`
comment. This branch had already added those to the shared eslint globals, so
every one of them was reported as redeclaring a built-in. The comments keep only
the globals the config does not supply.

And a fixture tool had drifted out of Prettier's shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cfviotti and others added 30 commits August 30, 2026 16:17
Two documents carried the same three figures for what a staging canvas costs
Safari, in nearly the same sentences, typed by hand from one machine on one
clip. Nothing in the repository re-derives them and no benchmark emits them, so
the first person to measure a different browser build would have found two
places to correct and no way to know the second existed.

What a reader deciding about Safari needs survives: the staging route runs once
per presented frame, it dominates the wall clock during playback, a direct
upload in the same browser costs a small fraction of it, and the cost is not
pixel count alone because the staging surface is media-sized whatever the
decode delivers.

The figures that anchor a rule stay where they are, in both files: the frame
cap and the browser versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A suite reimplemented useState, useRef, useEffect and useCallback, then
replaced React with that reimplementation before calling the hook under test.
Nothing checked the copy against the real one, and it exported four hooks: the
day the code reached for useMemo or useSyncExternalStore it would have thrown,
in a suite whose passing was supposed to mean the hook worked.

Three other suites read component source as text and asserted that identifiers
appeared inside brace-matched function bodies. Those pass on inverted logic and
fail on a rename that changes nothing.

The suites that compare exported declarations between two real modules stay.
They catch drift a reader cannot see, which is what a contract test is for.

This loses coverage and nothing replaces it: a play failure reaching the error
message, upload wiring, session lifetime across a view-mode switch, teardown on
unmount, and the repaint after a re-attach. Testing them honestly needs a
browser environment, which would add two permanent dependencies to a published
library. A test that cannot fail for the right reason was not covering them
either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row explaining why the workbench opens on Mediabunny said the library ships
with that reader, two lines above a sentence saying the engine is the other
reader in the same package. Both cannot be true. Mediabunny is also the demuxer
inside the engine, so the claim was wrong a second way.

The other five rows on that panel were checked against the library and are
accurate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stroke wider than twelve texels drew at its full width on the mask layer and
at twelve on the interaction highlight of the same annotation, so selecting a
detection changed the thickness of its outline.

The two layers each built the shader's palette from mask instructions, in code
copied closely enough that two helpers had identical bodies and a shared
docblock sentence. The ceiling was the one number the copies spelled
differently, under two names, and nothing compared them.

Sixteen is the survivor, because it is what the data already carries: the frame
clamps its own stroke widths to sixteen before the interaction layer ever
samples the raster, and the native builder unrolls its loop with the same
constant. Both shaders scan the smaller of the cap and the width the frame
asked for, so a wider cap never widens a scan.

The id check, the texel conversion and the palette write now have one home in
core, and the conversion returns an already-clamped width so neither caller can
apply a ceiling of its own. What each layer does when an id will not fit stays
different on purpose and sits one line below the shared check: a frame that
cannot be represented is refused whole and falls back to a composite that draws
everything, while a single write past the end of a fixed array is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shader built from a GLSL program alone draws nothing under the WebGPU
renderer and raises nothing while doing it. Six renderers declared they inject
a shader factory requiring both programs. The seventh required only GLSL, and
it is the file that hands that factory to the two renderers which build WebGPU
programs.

Nothing caught it. TypeScript accepts the narrower factory where the wider one
is expected, and the test written for exactly this failure inspects files
containing a shader construction call, which that file does not have.

The injected Pixi surface now has one declaration that every renderer uses, so
the requirement cannot be dropped in one place again, and a test fails if any
renderer's factory stops demanding the WebGPU program.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tart

A hook stored a fixture id resolved against the clips the selector shows and
read it back against the whole catalogue. The two lists match today because a
flag is unset on every fixture, so the failure waits for whoever sets it in a
manifest and never opens the hook.

Four measurement scenarios each decided for themselves when the renderer was
ready, in three different ways. A definition of ready that is too loose does
not fail; it reports plausible numbers measured on a page that was not
finished, which is worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dragging the playhead backward left the detection layer with nothing to
draw. At 2x it could not draw on 66% of the frames it was shown.

Measured over 48 runs, arms taken off the session's own edges and
alternated innermost: a 10 second lookahead beats 6 in 11 of the 12
backward cells, and the one inversion ran under the matrix's heaviest
load. Forward is a 12 of 12 tie, so the shorter lookahead bought nothing
in the direction it was meant to help. Backward 1x drops from 0.0741 to
0.0056, and 2x from 0.6632 to 0.3523. About 6.5 MB more is held.

Core kept its own fallback of 6, so anyone building a timeline directly
got the window this measurement rejects. Both layers now say 10 ahead
and 5 behind, and a test pins the core fallback that nothing exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A mask's outline is scaled against the width of the raster it was drawn
on. The interaction layer was reading that width from the mask it
happened to be looking at rather than from the frame, so on any raster
the preparer had resized the outline came out at the wrong thickness,
and a selected mask did not match the one beside it.

The frame-wide width is already computed while the raster is built, so
it is now carried out with it: through the prepared frame, the worker
protocol's completion message, and the worker, to the layer that scales
the stroke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hovering a mask lights its silhouette. Past 80 addressable ids the
raster that carries those ids is not built, so the highlight quietly
stops appearing while boxes, labels and vectors keep drawing. Nothing on
screen tells the viewer which piece went missing.

The trigger is wider than a frame with many masks: the id is the
detection's index among all detections, not among the masked ones, so a
frame with two hundred detections and five masks loses the raster too.

No fallback is offered, because there is no honest one. Ids past the
palette's end are clamped into it, so naming detection #120 would light
detection #78's silhouette: a wrong answer rather than a missing one.
The layer now reports the state instead, and the compositor refuses the
plane on the same condition it would have failed on anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workbench opens on Mediabunny, so a run that did not say otherwise
measured the library's own reader and reported the number as the
engine's. On that path the eval presents no frames, prepares none, two
scenarios return invalid-environment, and latency and back-scrub miss
their limits by an order of magnitude, so no engine baseline could be
recorded at all.

The harness already had --url and every report already records the path
it ran on. The missing half was the workbench reading a query string, so
it now does:

    --url 'http://localhost:5173/?mediaPath=engine'

An unsupported name is refused rather than guessed, so a typo cannot
quietly hand back the default and read as a deliberate choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pinning the media path in the URL moved the session but not the panel: it
kept badging Mediabunny as the path the workbench opens on while the
session was running on the engine. Anyone reading the panel to find out
what they were looking at got the wrong answer, which is the one job
that panel has.

The badge now follows the path the workbench resolved, rather than the
constant it would have used had nothing been pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in the byte store, both on the decode worker, the one thread
that must not stall.

Every banked block sits next to the run it extends, and the store
rebuilt that whole run on each merge. Banking 160 MB at the 512 KB block
size copied 25.6 GB and spent about 4.3 seconds in memcpy; back and
forth scrubbing copied 7.7 GB to hold 145 MB. Measured over 2000
sequential reads, a run now costs 3.02 copies of what it holds where it
used to cost 1002.

The memory ceiling could not be enforced either. Eviction dropped whole
runs and stopped while one was left, but merging turns a linear read
into exactly one run, so a 160 MB read against a 64 MB budget kept all
160 MB. An oversized run is now trimmed to a window at the playhead.

A budget that holds also ends the background walk, which used to wait
at ten polls a second for room that a full store never frees.

The class docstring said a served range copies nothing. That is true of
serving and was never true of insertion, which is why neither defect
showed up in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When a frame carries more detections than the id palette can name, masks
are drawn by compositing them one at a time instead. That path scanned
every pixel of the frame once per detection, so the frame the viewer was
waiting on took hundreds of milliseconds.

It now walks each mask's runs, which is the shape the id path already
used, and tracks the bounding box while it goes so the outline pass
stays inside it. A detection with no outline no longer decodes a full
plane it never reads.

Measured on 81 masks over 1920x1080: 151 ms to 19 ms for fills, 337 ms
to 69 ms with outlines. The output is asserted pixel for pixel against
the old implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cache treated a stored frame as eligible if the target came within a
millisecond of it, so dragging the playhead could show a frame slightly
ahead of where the pointer actually was: 2.4% of positions at 24fps and
12% at 120fps.

The tolerance existed to cancel a rounding on the seek query, and the
two only worked as a pair. Both are gone, so an on-frame seek now
matches the stored timestamp exactly rather than being rounded down to a
whole millisecond and missing it.

Seek resolution was never at risk: a painted frame ahead of the awaited
target is already rejected before it settles a seek. What this fixes is
the preview under the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scene decides whether to repaint by comparing a list of presentation
fields. The list was checked for membership, so it was complete today
and nothing said it had to stay that way: a seventeenth field would
compile, and the picture would simply never update when it changed.

The list is now derived so that any field no renderer kind owns has to
be named explicitly, and a field that is neither fails the build. A case
per field asserts a single change repaints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…seeks

The playback controller feeds its own next decision, so what it does
over a sequence is not what any single step does. The suite drove it at
one rate per run, which left three moves untested: changing speed while
it plays, a stall shorter than its recovery window, and a burst of
seeks.

The harness now takes a rate that varies over a run, anchored at each
change so a constant rate stays exactly as it was. Three runs cover
stepping up to 8x and back, a 400 ms decode blackout, and seeking
repeatedly while the transport keeps running.

This pins a claim that had no test: a machine with room opens a new rate
at the full frame offer, with no ramp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing the engine does not wait for a foreground decode that is already
in flight, so a step or seek can land after teardown. The scheduler
already refuses that frame and closes its sample; what was covered was
only that the sample got closed, not that the frame stayed off screen.

The test parks a step decode, closes mid-decode, then releases it, and
checks all three ways such a frame could still surface: the step answers
with nothing, the cache is not refilled, and a listener that subscribes
afterwards gets no replay.

Awaiting the step before asserting is what makes it bite. The cache
checks run synchronously, so on their own they pass while the late
decode is still settling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The recovery ceilings nest: the first-frame seed is bounded tighter than
a random-access decode, and every seed attempt has to fit inside the
main thread's backstop or the worker is given up on while it is still
doing what it was told to do.

Only the outermost of those held by construction, being derived from the
decode ceiling. The rest were prose. Raising the seed timeout to 20
seconds spends 60 across three attempts against a 45 second backstop,
and nothing said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…millisecond

The exact tier keyed frames on their timestamp rounded to a whole
millisecond, so two frames closer than that shared a slot and the second
overwrote the first. Nothing in the engine stops a source arriving at a
rate where that happens: it was a bound that held in practice, not one
the code guaranteed, in a change whose whole purpose is to stop naming
frames by time arithmetic.

The tier now keys on the frame's own tick count, which the timeline
already derives from every packet in the track and which collapses
same-instant pictures when it is built. Two distinct frames cannot share
a slot at any rate a container can express.

The preview tier still rounds, because its answer is declared
approximate and never claims to be the frame at the target. What each
tier learns as the source frame interval is now bounded by the grid its
own keys round onto, so the same frame arriving twice under float slop
cannot be mistaken for a frame gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…shed

The background walk yields to whatever the viewer is waiting on. It
counted that read as finished the moment the request resolved, but the
demuxer reads the body after that, so the walk went back to competing
for the connection while the frames on screen were still arriving.

A read now holds the link until its body ends, is cancelled, or errors,
and every one of those paths releases it exactly once.

A held link cannot be held forever by a reader that walks away, so a
chunk left unclaimed past a ceiling releases it. That ceiling covers
only the gap between a delivered chunk and the consumer's next pull: it
used to span the wait for the server too, which made a slow link
indistinguishable from an abandoned read, and standing down matters most
on exactly the links that are slow.

Disposing releases whatever is outstanding rather than leaving a timer
per read behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Banking a block allocates, so it can throw. On the two paths that end a
read, it ran before the claim on the link was given back, and the claim
was already past the ceiling that would otherwise have expired it. An
allocation failure there held the link for the rest of the session: the
background walk then wakes ten times a second to find the link busy and
never prefetches again.

The claim is given back first on both paths, and armed before a block is
banked mid-body for the same reason.

The claim is a closure removed from a set by identity, so a read whose
cancel races its own end releases twice and the second is a no-op. Both
interleavings are covered, along with a cancel arriving while a pull is
still in flight, which is the case with no ceiling left to catch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exact tier will not answer with a frame further from the target than
the shortest gap it has seen between two frames. On a variable-rate
source that bound can be learned from a dense stretch and then govern a
sparse one: a frame that owns 990 ms of the clip answers for the first
10 of them, so across that second the cache serves 2% of lookups and
sends 98% back to the decoder.

Nothing widens the bound again, not even evicting every frame that
taught it, so a tier holding two frames a second apart can still be
bounded by a gap of ten milliseconds it no longer holds anything to
justify. A second tier with the same residents and a different history
answers differently, which is what the test pins.

This is a miss and never a wrong frame: the at-or-before filter runs
first, so the bound can only remove candidates. That is worth keeping
true, which is why it is now pinned rather than described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playing a clip whose masks fall behind stopped the picture on "Drawing
ahead of the video" and left it there. The video only moved again if you
paused and pressed play, and then it stopped at the next hard frame.

Nothing was slow. Preparation had stopped entirely: no frame pending, no
frame in flight, the drawn lead a fraction under the second it was
waiting for, and no work left that could ever close the gap. Dragging
the playhead suppresses preparation so a drag does not queue work for
frames nobody will see, and that suppression also covered the frames a
waiting gate was asking for. It now yields to a gate that is holding.

The wait a superseded run walks away from is cancelled rather than left
outstanding. An abandoned wait used to read as a gate still holding,
which disabled that same drag suppression for the life of the window and
also left its promise pending forever. Playback, seeking and teardown
all cancel on both the pull and the push transport.

A required lead longer than the frame cache could ever hold was
unsatisfiable: the requirement was bounded by the window it prepares
into but never by the cache it keeps, so a small cache waited for a lead
that could not arrive. The cache now bounds it too, and a cache size
that is not a whole number of frames is floored rather than reaching
past the end of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every preview build since 4bb7bdf has failed, so the running preview is
19 hours behind the branch and serves a bundle none of the fixes are in.
The build passes locally at this commit, so this asks the service for a
fresh attempt rather than changing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate that waits for masks only ever held a reader the renderer pulls
frames from. On the engine, which paces itself, it held the very first
frame and nothing after, so a machine that could not keep up played on
with no masks at all and said nothing about it. Switching every gate on
did not change that: the per-frame hold was wired to detections alone.

Masks can now stop a running engine too, so a clip whose masks fall
behind slows down and keeps its annotations, which is what the reader
that pulls has always done.

A hold that waits forever would be worse than the fault it fixes, so it
gives up after two seconds and lets the picture go on without masks,
saying so rather than going quiet. It gives up only on preparation that
has finished nothing at all: a slow one re-arms the gate every time it
finishes a frame, however far behind it still is. Preparation that only
gets a frame out while the picture is stopped counts too, which is what
happens when drawing and decoding share a busy processor.

Pausing during a hold no longer leaves the reader frozen. A wait that
fails, and a pause that lands while a play is still waiting, both give
the reader back, so the next drag moves the playhead instead of
restarting a video that was paused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playback could stop with nothing on screen to explain it. The only sign
was a small ring on the play button, which is what the overlay falls
back to when no notice could be built, so the viewer was left guessing.

Three ways that happened, all now named.

Fetching the video was invisible. The notices only ever described masks
and detections, so a stop waiting on the clip's own bytes had nothing to
report: on a real recording, two seconds after a scrub went entirely
unexplained while the file was still arriving.

A notice needed a quarter second of unbroken waiting, and the count
started again every time a wait cleared for a frame. The reader that
pulls holds many short waits rather than one long one, so the count
never matured and no notice could appear however long the stutter ran,
while the engine's single long hold showed one immediately. The same
library, the same gate, opposite behaviour. A wait that clears for a
moment and returns is now one wait.

A hold on the frame about to be shown, while the frame on screen was
ready, produced no notice at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitting the previous two commits dropped the code that reports a stop
waiting on the clip's own bytes, while keeping the tests that cover it.
A stop on a source read fell back to the generic buffering notice, which
is what left the wait unexplained in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f it

Every detection carried by a streaming model was measured by painting its
mask into a frame-sized buffer and scanning every pixel of it for the
edges. At 1920x1080 that is a two-megabyte allocation and two million
reads to produce four numbers the run lengths already carry.

The runs are walked instead. A frame-filling mask goes from 5.69 ms to
0.03 ms; a heavily fragmented one from 6.57 ms to 2.38 ms. The answer is
the same in both, checked against the old path.

This ran on the thread that draws, so a model streaming its results took
a third to two thirds of every tenth of a second away from the work that
keeps the picture moving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p ahead

How much drawn mask sits in front of the playhead was counted as an
unbroken run, stopping at the first frame nobody had finished. A window
that was almost entirely drawn reported nothing ahead when one frame
near the playhead was outstanding, so the video stopped with a full bar
of drawn masks on screen behind it.

A model that streams its results puts a fresh undrawn frame into that
window several times a second, so the count could never climb back to
what the gate asked for. Measured on a clip driven that way: the picture
was stopped for 15.6% of the time in five freezes, the longest most of
a second. It is now stopped between nothing and 6% in freezes of 80ms,
and where enough is drawn ahead it never stops at all.

The run now steps over a frame something is already drawing, since that
one arrives on its own, and still ends at a gap nobody is working on. A
frame the viewer is about to see still stops the picture, as before.

The two edges of the wait were also far apart and in the wrong unit.
Stopping cost a quarter second of clip and starting again asked for a
whole second of it, so every stop had to bank about twenty-three more
drawn frames than the one that triggered it. Both are now wall clock and
the second is the first plus a margin, scaled by how fast the clip is
playing: a stop buys about six frames instead of twenty-three, and
asking for a deeper bank no longer buys a longer stop. The two are held
apart at every speed and bank, so the pair can never meet and flap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The play button kept its play triangle while the video was stopped
waiting for something, with only a thin line orbiting the square as a
hint. It read as a video that could be played rather than one already
trying, and it was the only sign at all whenever no notice named the
wait.

The button now shows a turning ring in place of the triangle while the
picture is waiting, and stands still for anyone who has asked for less
motion. The gate's two edges are separate controls, since they are now
separate numbers, and the ceiling says what it does: it buys no drawn
frames, it only shortens a stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

render-preview Creates a demo render preview

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants