Repository navigation
feat(watch): run a min-delay and a max-delay decoder for warmup renditions - #7555
Conversation
Quest-Claim: fb462e0a241cd5b7
…tions A video rendition with a catalog `warmup` (intra refresh) now decodes twice. The min-delay decoder holds the app's latency, drives the shared clock, and may show refresh stripes after a join or a skip. The max-delay decoder starts one group earlier and tolerates the warmup more lateness, so its picture stays clean; it shows whenever its timestamp is at or past the min-delay one's, and nothing shown ever steps back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chromium accepts an SEI recovery point as a key chunk only with a zero recovery_frame_cnt, so an intra-refresh join throws there. What remains is that, and verifying Firefox and Safari. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A picture the min-delay decoder held from before a publisher restart could seed the no-rewind floor and freeze the new timeline, and a max-delay decoder opened after a restart never matched the min-delay decoder's local restart count. Each picture now carries the group it was read from, and only pictures from the min-delay decoder's current timeline start on are candidates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chromium's WebCodecs accepts an H.264 chunk that opens a codec only when it is an IDR or carries an SEI recovery point with recovery_frame_cnt == 0. An intra-refresh recovery point counts a whole sweep, so every join and skip of a warmup rendition failed. On the chunk that opens a codec, when the rendition has warmup and the chunk has no IDR, insert a zero-count recovery point before the first slice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…utput" This reverts commit 05add87. A 32-chunk window could evict the group of a picture the codec still owes, and WebCodecs gives no proven bound on that pipeline. Whether to cap the map for publishers that never output a picture is left to the maintainer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 3 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 22cc2aef0d00433508d8aafd6f1151ec87379885.
No actionable bugs found in the changed paths. Checked the dual-decoder lifecycle, min-only clock/stat ownership, restart filtering, tie/no-rewind selection, opening-chunk rewrite, and relevant tests and surrounding code, including an independent selector/Sync cross-check.
Overall direction is sound: the pair preserves live latency while retaining refresh history, using the existing consumer and shared clock rather than another scheduler. A single decoder would sacrifice either continuity or latency; keeping the pair internal and retaining one decoder without warmup limits the complexity. The documented local-review dispositions are not new findings here.
Verification limits: static review only; I did not run tests, browser playback, or benchmarks. Firefox/Safari recovery-point behavior and real-hardware player-level cost remain unverified; the PR's Chromium measurements were not independently reproduced.
(Written by OpenAI)
There was a problem hiding this comment.
Important
The dual-decoder plumbing is sound and insertRecoveryPoint checks out against the H.264 spec and Chromium's key-frame gate. Two bookkeeping edges in the new selector can drop pictures the old single-decoder path showed: the #groups retirement loop runs for outputs that the #generation guard later discards, and Rendition.timestamp no longer resets on a timeline break, so #runPending's caughtUp compares against a stale playhead.
Reviewed changes
- Dual decoder.
Renditionwraps a min-delayDecoderTrackthat drivesSync, counts stats, and posts the poster, plus a max-delay one gated on catalogwarmup; the selector shows the max picture when its timestamp is at or past the min one and never steps back. - H.264 join.
insertRecoveryPointinserts a zero-count recovery-point SEI before the first slice of the chunk that opens a codec, handling length-prefixed and Annex B payloads; IDR, no-slice, and malformed input pass through. - Wiring.
Decoder.#activeis now aRendition; the min decoder alone reports toSync; the max decoder addswarmupto its consumerdelaybounds and subscriptionmaxDelay, merged upstream by js/net. - Tests and docs. New
decoder.test.tscases for cold join, short/long stalls, restarts, budgets, hand-back, and the SEI rewrite;doc/lib/js/watch.mdand the quest updated.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…roups only on known outputs A rendition's timestamp kept the old timeline's playhead after a restart, so a pending rendition waited to catch up to it. An output with no group entry, decoded before the last reset, no longer retires entries fed since. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 5620fc283d0e81fc6d3ebdeefbdb34b968fc1ca3, against my previous 22cc2aef0d00433508d8aafd6f1151ec87379885 review; base unchanged.
- Fixed: the stale rendition timestamp. Clearing it on a timeline change restores pending promotion, and the new test targets that case.
- Fixed in scope: unmapped output deleting new group entries. The retirement guard prevents that deletion. The broader late-output caveat remains, as already acknowledged in Follow-ups, not a new finding: if an engine emits an old 1000 ms picture after a restart to 0 ms,
decoder.ts:570–574skips retirement but:581/:604can still update first/playhead; in instant mode subsequent lower-PTS pictures are dropped at:575–578. Discarding retired-lifetime output before timeline-state updates, with a regression test, would close that remaining case.
No new actionable bugs found in this delta. These targeted state fixes fit the existing design without another scheduler or timeout.
Static review plus independent cross-check only; no tests or browsers run. Actual engine delivery of post-reset stale output remains unverified.
(Written by OpenAI)
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
#groupsretirement guarded by a known output. The retirement loop now runs only when the output frame has its own entry in#groups, so a stale picture delivered after areset()(the map is cleared before any new entry is written) can no longer delete the new timeline's entries. A frame with no entry is from the codec life the reset ended.- Selection timestamp clears on a restart.
Rendition.#selectnow setsthis.timestamp.set(undefined)when the min-delay decoder'sstartchanges, mirroringDecoderTrack.#onBreak, so#runPending'scaughtUpno longer compares a pending rendition against the outgoing timeline's stale playhead. - Regression test.
promotes a pending rendition once the outgoing one restarts its timelineexercises exactly that promotion path and fails without the#selectchange.
Both prior review findings are fixed; the residual "pictures a codec outputs after reset()" case remains the pre-existing Follow-up it was already acknowledged as, not a new issue.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Quest: quest/m2/intra-refresh/dual-decoder.md
Summary
@moq/watchnow decodes a video rendition with a catalogwarmup(intra refresh) twice, as Rust'smoq_video::decode::Consumerdoes (moq-dev/moq#7376). It also opens an H.264 intra-refresh join past Chromium's key check.Sync(received, andreseton a timeline restart), counts stats, and shows the first-picture poster. It may show refresh stripes after a join or a skip.delay: { min: delay + warmup, max: maxDelay + warmup }, and its subscription getsmaxDelay + warmup. It starts one group earlier and tolerates one more sweep of lateness. That follows theDelay { min, max }decision from moq-dev/moq#7311: it passes nowarmupprop. js/net merges the two subscriptions upstream. Pictures already past due present at once without moving the clock.Renditionclass wraps the pair insidedecoder.ts. The existing pending/active switch promotes a whole pair. The rule:caughtUp.warmup. A missing or zerowarmupkeeps one decoder, as before. A catalog change opens or closes the max-delay decoder. A changed value updates its budgets without reopening it.configureorreset), when the rendition haswarmupand the chunk is H.264 with no IDR,insertRecoveryPointinserts an SEI recovery point withrecovery_frame_cnt = 0right before the first slice. It handles both length-prefixed payloads (the avcCdescriptiongives the length size) and Annex B. An IDR, a payload with no slice, or one that does not parse is left alone. Both decoders get it, on every open and every reset. The code comment says why: Chromium's WebCodecs only opens a codec on an IDR or a zero-count recovery point (AnalyzeAnnexBinmedia/formats/mp4/avc.cc), and an intra-refresh recovery point counts a whole sweep.doc/lib/js/watch.mdexplains the two decoders and the inserted recovery point.Tests in
js/watch/src/video/decoder.test.tscover the cases the Plan lists:warmup: one subscription and one decoder.Sync, including across a timeline restart.insertRecoveryPoint: length-prefixed (4-byte and 2-byte lengths) and Annex B (3-byte and 4-byte start codes). IDRs, payloads with no slice, and malformed payloads come back unchanged.warmupare untouched. Disabling thewarmupgate, or the opening flag, fails these tests.The quest file now describes only what remains: checking Firefox and Safari.
Measurements (Chromium 156 headless, WebCodecs only)
1080p30 H.264 x264 intra refresh (1 s sweep, 6 Mb/s, 20 s clip). Each access unit is fed live at 30 fps to one or two
VideoDecoders. Three rounds, alternating order. The host was shared with other jobs, so CPU is noisy.The second decoder costs about 26 MiB and up to about twice the decode CPU, with no late frames. This measures the decoders, not the whole player. Headless Chromium's decoder implementation (software or hardware) was not checked.
Engine checks (H.264 recovery point sent as
key)intra-refresh=1:keyint=30, so it has one IDR and recovery points at frames 30, 60, and 90. I joined at frame 60 and fed 60 access units to Chrome 156.0.8078.79 headless, sending each recovery point askey. Without the rewrite,decode()throws "An EncodedVideoChunk was marked as typekeybut wasn't a key frame" (avcC) or "A key frame is required" (Annex B). AfterinsertRecoveryPointran on the opening chunk, it decoded 60/60 pictures in both formats. Chromium checks only the chunk that opens a codec (verify_key_frameinthird_party/blink/renderer/modules/webcodecs/video_decoder.cc), so later recovery points sent askeypass unchanged.Decisions
✅ A (2026-11-01). How should js/watch get an H.264 intra-refresh join past Chromium's key check?
configureorreset), when the rendition haswarmupand the chunk is H.264 with no IDR, insert an SEI recovery point withrecovery_frame_cnt = 0before the first slice. Keep the change local todecoder.ts.warmuprenditions as unsupported in Chromium.Answer A is implemented. The quest stays open for Firefox and Safari, because neither can be checked on this host.
✅ B (2026-11-01). Should the per-picture group map have a hard cap for a publisher that never outputs a picture?
DecoderTrackmaps each submitted chunk's timestamp to its group, and each output retires every entry at or before its timestamp. WebCodecs outputs pictures in presentation order, so the map stays bounded for any stream that shows pictures. A publisher that never outputs a picture (for example, only hidden VP9 frames) grows it by one entry per chunk. The maintainer accepted that growth as an explicit exception to the root AGENTS.md bounded-state rule for this map. WebCodecs sets no bound on how many chunks a codec may hold before output, so any cap is a guess, and round 4 showed that a cap can evict the group of a picture the codec still owes. The comment on#groupsinjs/watch/src/video/decoder.tsrecords the exception. A review finding that raises this again is refuted by this decision.Choices made without asking
frameCount,bytesReceived) come from the min-delay decoder only, since the bytes arrive once.exact_match_flag = 0andbroken_link_flag = 0, and goes after any SEI the stream already carries, right before the first slice.Follow-ups
requestAnimationFramelanding between them would paint the min-delay picture once (striped while it is unrecovered). Rust holds a newer min-delay picture up to one frame interval for the max-delay one. Consider porting that hold if it shows up in practice.bench/browser), with hardware decode and awarmupcatalog, once an importer publishes one (h264-import).key. HEVC has no recovery-point SEI that Chromium honors, so it needs its own approach once h265-import publishes awarmup.reset(). If an engine delivers a picture decoded before a reset (the Pullfrog review asks which do), it still passes the late check and can moveDecoderTrack.timestamppast the new timeline. That predates this PR. Dropping any output with no#groupsentry would fix it, but the synthetic-output tests would need entries first.Review
Head
5620fc283d: local review skipped under the simple-review rule (moq-dev/quest#364). It is a small fix (about 30 lines with its test) indecoder.tsinternals, with no public API, wire format, security code, orunsafe. It answers Pullfrog's two findings on22cc2aef0d:#groupsretirement runs for an output decoded before a reset, and can delete the new timeline's entries. Fixed: only an output with an entry in the map retires entries. Every chunk gets its entry right beforedecode(), after any reset clears the map, so an output without one is from a codec life the reset ended. Moving the loop below the#generationcheck, as suggested, would not catch it, sincegenerationis read when the frame comes out, after the bump.Rendition.timestampkeeps the old timeline's playhead after a restart, so a pending rendition waits to catch up to it. Fixed: the selector clears it when the min-delay decoder's timeline start changes, asDecoderTrack.timestampclears on a break. The new testpromotes a pending rendition once the outgoing one restarts its timelinefails without the fix.Head
22cc2aef0d: local review skipped under the same rule. Since the last reviewed tree, it only added a comment on#groupsthat records decision 2, and a conflict-free merge of origin/main that touches no js/ files.Earlier rounds: Codex
gpt-6.1-sol, medium effort, viaquest review --base origin/main, session01a1990f. Head5a3d950494has the same tree as round 2's head6b9f0d54ba, plus the round-3 cap and its revert.a003ac15b1): [medium]#groupsgrows for chunks that produce no picture (hidden VP9 frames). Fixed in6b9f0d54ba: an output retires every entry at or before its timestamp.6b9f0d54ba): [medium] still unbounded when no picture is ever output. Refuted by maintainer decision 2 (✅ B, 2026-11-01). The round-3 cap (05add87419) was reverted.05add87419): [medium] that cap can evict the group of a picture the codec still owes. Fixed by the revert in5a3d950494. This was the design checkpoint, and the maintainer chose to continue with B. A future local review starts withquest review --new.Earlier rounds on the dual decoder (heads
38d55dc47aand7775e5a3e9): two findings, both fixed, then no findings.🤖 Generated with Claude Code
(Written by Claude Opus 5.5)