Skip to content

fix(core): a video after a cut plays in sync instead of about 70 ms behind - #6494

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/video-cut-start-lead
Oct 27, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/video-cut-start-lead

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What

At a cut between video clips, the incoming video now plays in step with the playhead and its sound, instead of about 70 ms behind it.

Why

A user's detailed report measured that at every cut of a multi-take edit the incoming video's second frame arrived about 66 ms late. Measured on the playbench (#6418), the incoming picture runs 62-75 ms behind the playhead over the first half second of every cut, then takes over a second of rate steering to catch up; the steering's rate change is what makes the second frame late.

The cause is the browser, measured in isolation (a muted 25 fps <video>, play(), Chrome 144, 8 runs each):

first frame after play() frames behind a clock started at play()
file with an audio track (what users import) 83-100 ms 50-67 ms
same file with the audio track removed 33-50 ms 0-17 ms

A muted video still clocks itself off its file's audio track, so its playback starts 50-67 ms after play(). The hidden/visible switch at a cut makes no difference.

Related work

Builds on #6438 (a video no longer runs ahead of the playhead or past its out-point), now merged. Builds on #6515 (now merged), after which core's npm package no longer ships the unminified media sync, so this PR adds 908 B to core's tarball (the two minified runtime bundles) and fits its size budget with no raise.

How

Design call. Options:

  1. A fixed lead (start every upcoming video 50 ms early). One line, but the delay depends on the machine: on a faster one it starts too early and skips the take's first frame (measured: 83 ms early skips it here), on a slower one it falls short.
  2. Pre-roll from before the in-point (the first version of fix(core): a video no longer runs ahead of the playhead or past its out-point #6438). Tried and measured: it shows a frame from before the in-point and the gap stayed at 66.6 ms.
  3. Learn the delay from the videos already started, and start an upcoming video that much before its cut, hidden on its first frame.

Picked 3:

  • At each video play(), the sync notes the video's offset from the playhead and how early it was started. A quarter second into the clip, the offset's change plus that lead is the start delay. The first sample is taken as is, later ones are averaged with the running value, and it is capped at 0.1 s.
  • An upcoming video counts as starting early once it is due within that delay plus half the next tick (the same startsEarly path audio already uses for its one-tick cue), so it starts on the tick nearest the right moment. Until a start has been measured the delay is unknown and nothing changes.
  • A seek in the same tick as the play() is read after the seek, so the stale pre-seek offset never becomes a sample.
  • Looping videos are not sampled: a loop can wrap between play() and the sample, which would put the sample off by a lap.

No extra work per frame or per render: one WeakMap lookup per playing video per tick, and an upcoming video starts decoding a few tens of milliseconds sooner.

The playbench gains a row: how far the incoming picture runs behind the playhead over its first 0.5 s. The existing second-frame metric reads only the first two frames, so it missed the same delay on the 30 fps edit.

Before

Before: the picture after each cut, against the playhead

After

After: the picture after each cut, against the playhead

What I measured

Playbench in Chrome 144 on an 8-CPU Linux box, #6438's head against this branch, median [min-max] over 5 runs per setup (10 runs for the last column):

incoming picture behind the playhead, first 0.5 s (negative = ahead) 3 takes, 30 fps, audio 10 takes, 25 fps, audio 3 takes, no audio device 10 takes, no audio device 10 takes, audio, 10 runs
base 62.1 ms [54.3-72.5] 74.8 ms [63.9-78.2] 71.0 ms [70.1-77.9] 74.9 ms [64.4-76.7] 75.4 ms [65.6-79.0]
this branch -17.1 ms [-22.5-3.4] -8.2 ms [-11.9--2.5] -9.8 ms [-18.0-12.9] -9.0 ms [-10.0--4.7] -4.8 ms [-18.6-6.4]
incoming second-frame gap, median 3 takes, audio 10 takes, audio 3 takes, no device 10 takes, no device 10 takes, audio, 10 runs
base 41.7 ms 66.7 ms 50 ms 66.6 ms 66.6 ms
this branch 33.4 ms 49.9 ms 33.4 ms 50 ms 33.4 ms

At 25 fps on a 60 Hz display, 33 and 50 ms are the normal frame steps.

  • Residual: the picture now lands within about one sync tick of the playhead, slightly ahead on average. Per cut it scatters by about ±25 ms (one run: 23 ms ahead to 30 ms behind), because each start's measured delay moves the next start and starts happen on ticks. A lag under 40 ms is inside the sync tolerance, so steering leaves it.
  • Frames shown past an out-point: unchanged against base (10 runs: runs with one at cut 1, 1/10 on both; later cuts 0 [0-2] to 1 [0-2]). Every such frame I traced is the out-point frame itself, shown 2-7 ms after the playhead passes the cut, with the same timing on base.
  • Playhead hold per cut: no change (median -12 to +2 ms on both sides). The report's 22-37 ms hold per cut does not reproduce on this machine.
  • Unit tests, vitest run src/runtime: 106 files, 2599 tests, 3 runs in a row; playbench analyzer tests 7/7; oxfmt, oxlint, core tsc, playbench tsc, comment ratchet and fallow audit clean.
  • fails without the fix: each of seven mutations fails a test (no early video start; no half tick; first sample halved; sample ignoring the early lead; offset read before the seek; looping videos sampled; no 100 ms cap).

What I did NOT exercise

  • The reporting user's own setup (Chrome 156 on a 2-4 vCPU machine with a real audio device). The delay there may differ; the change learns it rather than assuming this machine's.
  • Playback rates other than 1x and clips with rate ramps: covered by the unit maths, not by the bench.
  • Safari and Firefox.

Test plan

  • Unit tests added: no early start before a start is measured; an upcoming video starts early by the measured delay; it starts on the tick nearest its lead; a video seeked and started in the same tick still gives a true sample; an early start that lands on time keeps its lead; a looping video sampled across a lap wrap teaches nothing; the learned delay is capped at 100 ms.
  • Playbench analyzer test for the new row.
  • Manual testing: playbench before/after above.
  • Documentation updated (not applicable: no user-facing option)
  • Comments follow CONTRIBUTING.md "Comments"

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE · 0 blocker · 1 important · 0 nits

No blocker. One important finding inline: a looping video's start can feed the learned delay a sample taken across its wrap.

Scope

Read the diff against #6438 (media.ts, media.test.ts, playbench analyze.ts/run.ts/test) at 5700a2ee, plus the surrounding sync path: mediaSourceTimeAt clamps relTime to mediaStart before the cut, and the playing visit window (MEDIA_PRESEEK_SECONDS) reaches clips due within the lead. Locally: vitest run src/runtime 106 files / 2597 tests pass, playbench analyzer 7/7, oxlint and oxfmt clean on the changed files. Main's required CI does not run on a PR stacked on checklong/playstart, so it must run once this is retargeted to main. Playbench numbers taken from the body.

Comment thread packages/core/src/runtime/media.ts Resolved
heygengenesis[bot]
heygengenesis Bot previously approved these changes Oct 27, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 0 resolved, 1 open (important: loop sample), 0 new · 5700a2e → 20a81e5. Rebase only: the patch matches the earlier head line for line, and the runtime suite passes 2597/2597 locally on this head.

Base automatically changed from checklong/playstart to main October 27, 2026 02:01
@miguel-heygen
miguel-heygen dismissed heygengenesis[bot]’s stale review October 27, 2026 02:01

The base branch was changed.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 156 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@miguel-heygen
miguel-heygen force-pushed the fix/video-cut-start-lead branch from 20a81e5 to 7fa1f88 Compare October 27, 2026 02:21

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 0 resolved, 1 open (important: loop sample), 0 new · 20a81e5 → 7fa1f88. Rebase onto main only: the patch matches the earlier head line for line, CI is green, and runtime 2597/2597 and playbench 7/7 pass locally.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 1 resolved, 0 open, 0 new · 7fa1f88 → c1fbfe0. CI is green, runtime passes 2598/2598 locally, and the new loop test fails with the guard removed.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 27, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 27, 2026
@miguel-heygen
miguel-heygen force-pushed the fix/video-cut-start-lead branch from c1fbfe0 to fc0dfb5 Compare October 27, 2026 04:41
@miguel-heygen
miguel-heygen changed the base branch from main to fix/core-pack-runtime-source October 27, 2026 04:41
heygengenesis[bot]
heygengenesis Bot previously approved these changes Oct 27, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 0 resolved, 0 open, 0 new · c1fbfe0 → fc0dfb5. Range-diff shows the 3 approved commits rebased onto #6515 unchanged, and the trim commit keeps the same behaviour. The new cap test fails without the 0.1 s clamp. CI is green, and runtime 2599/2599 and playbench 7/7 pass locally.

Base automatically changed from fix/core-pack-runtime-source to main October 27, 2026 05:25
@miguel-heygen
miguel-heygen dismissed heygengenesis[bot]’s stale review October 27, 2026 05:25

The base branch was changed.

@miguel-heygen
miguel-heygen force-pushed the fix/video-cut-start-lead branch from fc0dfb5 to 81a80e0 Compare October 27, 2026 05:37

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE — 0 resolved, 0 open, 0 new · fc0dfb5 → 81a80e0. Rebase onto main only: range-diff shows the same 4 commits, the patch matches line for line, CI (with size budgets) and Windows render are green, and runtime passes 2599/2599 locally.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 594df21 Oct 27, 2026
87 checks passed
@miguel-heygen
miguel-heygen deleted the fix/video-cut-start-lead branch October 27, 2026 06:29
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