Skip to content

refactor(watch)!: let Sync own the instant latency - #3068

Merged
kixelated merged 4 commits into
devfrom
claude/sync-owns-instant
Aug 26, 2026
Merged

refactor(watch)!: let Sync own the instant latency#3068
kixelated merged 4 commits into
devfrom
claude/sync-owns-instant

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Supersedes #3061 and closes #3052, both of which addressed the same wart the harder way.

Why

"instant" was implemented in three places at once. The element mapped it to a zero buffer before handing it to Sync, separately cleared a paced flag on the video decoder, and the decoder skipped its wait. Sync can just do it.

The reason it was split up was the belief that Sync couldn't honor the mode. That is only true of the audio half. The timing half is entirely Sync's.

The two things that are not the same

A zero buffer is not no pacing, which is what made this look harder than it is:

  • A zero floor still holds max(video, audio) in #runBuffer, the selected rendition's own delay. That's a frame interval at 60fps.
  • wait() still sleeps at a zero floor, because the sleep comes from the reference (sleep = currentRef - ref + floor), which holds an early frame until its timestamp comes up. That is exactly why latency-min="0" was never instant.

So "instant" now does both: #runBuffer returns zero outright, and wait() returns without sleeping. The check sits inside wait()'s loop as well as at the top, so frames already parked wake through the existing #update resolve and leave.

What that deletes

  • DecoderInput.paced — the decoder no longer knows the mode exists.
  • DecoderTrack.#latency and its effect — maxBuffer is already zero in the mode via #runRange, so the mirror had nothing left to do. The three call sites read sync.out.maxBuffer directly again.
  • #park — inlined back to Promise.race([wait, effect.cancel]). With no paced input there is nothing extra to race, and the disposable-registration bookkeeping goes with it.
  • #videoPaced and #syncLatency in the element.
  • The Paced type. It existed only to keep "instant" away from a component that can now honor it.

Net: 110 lines removed, 48 added.

What stays

Audio remains the element's business: a ring with no target depth underruns, and Sync has no way to reach the audio decoder. The disable and ring flush are unchanged.

The rewind fix from #3048 stays and is now unconditional, since there is no paced branch to nest it in: an unanchored reference at output time means a reset() landed after the frame's received(), so the frame predates the current timeline and is dropped rather than painted.

Reviewer notes

  • Targets dev: removing DecoderInput.paced and the Paced export are semver breaks.
  • just fix, just check, and just test all pass.
  • Still no automated coverage of the instant path itself; it needs a real WebCodecs VideoDecoder, which bun's test environment lacks.

(Written by claude-opus-5)

`"instant"` was implemented in three places at once: the element mapped it to a zero
buffer for `Sync`, cleared a `paced` flag on the video decoder, and the decoder
skipped its wait. `Sync` can do the whole thing itself.

Zero buffer and no pacing are separate. A zero floor still holds the rendition's own
delay, a frame interval at 60fps, and `wait()` still sleeps because the sleep comes
from the reference rather than the buffer. So `"instant"` now zeroes the buffer
outright and returns from `wait()` without sleeping, checked inside the loop too, so
parked frames wake through the existing `#update` resolve.

Deletes `DecoderInput.paced`, `DecoderTrack.#latency` (`maxBuffer` is already zero in
the mode), `#park` (nothing left to race), and the element's `#videoPaced` and
`#syncLatency`. The `Paced` type goes with them: one `Latency` again, since the split
only existed to keep a value away from a component that can now honor it.

Audio stays the element's business. A ring with no depth underruns, and `Sync` has no
way to reach the audio decoder.

Supersedes #3052.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 817e038891

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread js/watch/src/sync.ts
kixelated and others added 2 commits August 26, 2026 09:36
Zeroing the buffer changes `Sync.now()`, which the caption renderer uses as its
playhead, so captions now track the picture rather than trailing it. Two neighbouring
claims went stale with it: the mode does not merely stop pacing, and the wait is not
the only thing skipped.

Also spell out why neither half is redundant, since that is the whole point: a zero
floor still holds the rendition's own delay and still sleeps on the anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`latency="instant"` makes `Sync.out.buffer` zero, and the audio decoder sized its
ring straight off that. `AudioRingBuffer` rejects a non-positive latency, so the
worklet's construction threw and left it with no backend. Every later resize is
gated on the backend existing, so audio stayed silent even after switching back to
a paced latency. The worklet is built while audio is disabled (deliberately, to make
unmuting instant), so an element that merely starts in the mode hits this.

`setLatency` had the same problem from the other direction: an existing ring
resized to zero throws `empty buffer`.

An AudioWorkletProcessor renders in fixed 128-sample quanta, so a ring shallower
than one can never be read from. Floor both conversions there. Audio owning its own
floor is the point: its depth should not derive verbatim from a buffer whose meaning
is "video holds nothing".

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa4626f2cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread js/watch/src/audio/decoder.ts Outdated
const RENDER_QUANTUM = 128;

/** The ring depth for a target latency, floored at one render quantum. */
function ringSamples(rate: number, latency: Time.Milli): number {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add regression coverage for the zero-depth ring fix

When latency="instant" initializes the postMessage audio path, this floor is what prevents AudioRingBuffer from throwing on zero latency and leaving the worklet without a backend. The existing ring-buffer tests only confirm that zero latency throws; none exercises ringSamples or verifies that decoder initialization normalizes zero to one render quantum, so this bug fix lacks the required regression test and can be removed without a failing check. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L137-L137

Useful? React with 👍 / 👎.

Move `ringSamples` next to `reanchorFloor`, where it is reachable from a test without
exporting anything purely for one. Removing the floor fails the two new cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated enabled auto-merge (squash) August 26, 2026 16:58
@kixelated
kixelated merged commit a18162a into dev Aug 26, 2026
2 checks passed
@kixelated
kixelated deleted the claude/sync-owns-instant branch August 26, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant