Skip to content

fix(core): a motion blur worker starting mid-film keeps the right state - #6967

Merged
vanceingalls merged 2 commits into
mainfrom
fix/motion-blur-worker-start-state
Nov 2, 2026
Merged

vanceingalls merged 2 commits into
mainfrom
fix/motion-blur-worker-start-state

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What was wrong

With blur on and data-motion-blur-events="on", a render worker that starts mid-film could show the wrong one-off state until the next callback. In spacex-launch, the first caption showed the second caption's text for up to 74 frames.

The mover scan walks the film with callbacks live and finishes at the end. Each tier's discover() then seeks straight back to the worker's first frame. GSAP fires tl.add(fn) calls in both directions, so that backward seek re-runs only the calls ahead of the worker, and the latest state wins.

Why it went unnoticed

Most local renders interleave frames, so every worker starts near frame 0 and the backward seek crosses every call. Workers get contiguous blocks when capture can't encode at the same time: non-H.264, chunked encode, PNG capture, or as many workers as CPUs. Distributed chunks are contiguous too, but they force blur off today.

The fix

scanMovers now ends by rewinding through 0 with callbacks live, so the seek to the start frame goes forward. Every worker then lands on what the frame-0 worker already had. Both tiers go through scanMovers, including the rescan after a tier handoff. A silent seek to 0 is not enough: it leaves onUpdate state from the scan, and the counter in the same film then read $1.0T instead of $0.0T.

Evidence

spacex-launch, Subtle, PSNR against an interleaved whole render:

  • Contiguous local render, before: frames 891-956 wrong, min 31.2 dB. After: no frame below 40 dB.

  • Distributed chunks (blur force-off removed in a scratch build), before: frames 883-956 wrong. After: mean 66.0 dB, min 49.9 dB on Mac; min 48.8 dB on Linux with Chrome 156.

  • spacex-launch forced to the filter tier, after: the caption is right on chunks and on a contiguous local render. Chunked vs whole still differs at frames 541-581, 680-750 and 1112, and those same frames differ with blur off, so they are not blur's.

New test leaves a worker starting mid-film the state of the calls behind it, in the capture and filter suites, fails without the fix on both: expected 'second' to be 'first'.

Not fixed here

Worker scans can still disagree by one mover (76 vs 77 in spacex-launch, 27 vs 28 in HF-heygen-stripe), because the scan starts from the worker's own state. No visible difference in spacex-launch, and HF-heygen-stripe stays within its Off noise floor.

🤖 Generated with Claude Code

The mover scan walks the whole film with callbacks live, then seeks back to
the worker's start. GSAP fires one-off calls in both directions, so a worker
starting between two calls took the later call's state and held it until the
next call. Going through 0 first gives every worker what frame 0 sees.

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

@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: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · head a5e397a

The capture-tier fix is right: going through 0 replays the frame-0 worker's path. But the filter tier still has the bug.

  • blocker packages/core/src/runtime/captureMotionBlur/filterTier.ts:133: discover() runs the same scanMovers and then calls seek(restoreTo) directly from the end of the film. A worker starting mid-film on the filter tier still keeps the state of the call ahead of it. That covers data-motion-blur-tier="filter", Chrome without html-in-canvas, and every settleTier() handoff (translation-only on auto, root-opacity defects, and so on). The handoff case also undoes this fix: the filter tier rescans at the worker's first frame and seeks backward from the end again. I copied the new test into describe("filter fallback") with no html-in-canvas installed, and it fails at this head with expected 'second' to be 'first'. Adding seek(0); before filterTier.ts:133 makes it pass, and the whole captureMotionBlur/ suite passes (188/188). Please make that change and add the filter-tier test.
Scope

Read the full diff, index.ts discover/blockedByScan/settleTier, filterTier.ts discover, and discover.ts scanMovers (when events are on it ends on a quiet pass at the end, so the loud seek through 0 crosses every call). With events off, the extra seek(0) is quiet and costs nothing. The frame-0 worker's path doesn't change. I ran the new test locally and it passes. CI was still running when I reviewed, so this verdict doesn't depend on CI.

— Genesis

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 128 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)

Unstable (1)

  • seqplainkeys-none-center-r0-nested-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

Both tiers call scanMovers and then seek to the worker's start. Rewinding
inside the scan covers them both, including the rescan after a tier handoff.

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

@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 · a5e397a → 8829228

✅ The filter-tier blocker is fixed in 8829228. scanMovers now rewinds through 0, so both tiers and the handoff rescan get it. Both new tests fail without that line (expected 'second' to be 'first'), and captureMotionBlur/ passes 188/188. CI is green at this head.

— Genesis

@vanceingalls
vanceingalls merged commit 01c0bf4 into main Nov 2, 2026
144 of 145 checks passed
@vanceingalls
vanceingalls deleted the fix/motion-blur-worker-start-state branch November 2, 2026 19:40
vanceingalls added a commit that referenced this pull request Nov 3, 2026
…e the trail

Renders five blur fixtures through the real producer as a PNG sequence with 4
workers (contiguous blocks, three of them starting mid-film, the #6967
condition): blur off twice, blur on once. Outside the trail of everything that
moved, blur on must equal blur off in every frame with two neighbours. The
callbacks and scroll-list known differences are pinned by symptom.

Import route: a relative import of blurDifferential.ts, fixtures.ts and
thresholds.ts from packages/engine/src/services/__fixtures__. The producer
tsconfig excludes test files, so I type-checked the file with a temporary
tsconfig (rootDir widened); no error. No package subpath was needed.

RENDER_OUTSIDE_MAX_MAE: render noise is 0 (blur off, twice, bit-identical), the
rewind mutation measured 111.085 when caught, so the 1.0 floor applies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Nov 4, 2026
…e the trail

Renders five blur fixtures through the real producer as a PNG sequence with 4
workers (contiguous blocks, three of them starting mid-film, the #6967
condition): blur off twice, blur on once. Outside the trail of everything that
moved, blur on must equal blur off in every frame with two neighbours. The
callbacks and scroll-list known differences are pinned by symptom.

Import route: a relative import of blurDifferential.ts, fixtures.ts and
thresholds.ts from packages/engine/src/services/__fixtures__. The producer
tsconfig excludes test files, so I type-checked the file with a temporary
tsconfig (rootDir widened); no error. No package subpath was needed.

RENDER_OUTSIDE_MAX_MAE: render noise is 0 (blur off, twice, bit-identical), the
rewind mutation measured 111.085 when caught, so the 1.0 floor applies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ameyamg pushed a commit to ameyamg/hyperframes that referenced this pull request Nov 4, 2026
…ture blur (heygen-com#7108)

* test(engine): pixel invariants for capture blur against blur off

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): fixtures for every capture blur failure class seen so far

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): nested canvas fixture reproduces heygen-com#6975, sharp-tile fixtures have no moving times

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): capture blur must match blur off outside the trail and the shutter average inside it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): differential tests pin known bugs by symptom, history test checks the reference frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): a playing preview keeps changing with blur on

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(producer): a multi-worker blur render must match blur off outside the trail

Renders five blur fixtures through the real producer as a PNG sequence with 4
workers (contiguous blocks, three of them starting mid-film, the heygen-com#6967
condition): blur off twice, blur on once. Outside the trail of everything that
moved, blur on must equal blur off in every frame with two neighbours. The
callbacks and scroll-list known differences are pinned by symptom.

Import route: a relative import of blurDifferential.ts, fixtures.ts and
thresholds.ts from packages/engine/src/services/__fixtures__. The producer
tsconfig excludes test files, so I type-checked the file with a temporary
tsconfig (rootDir widened); no error. No package subpath was needed.

RENDER_OUTSIDE_MAX_MAE: render noise is 0 (blur off, twice, bit-identical), the
rewind mutation measured 111.085 when caught, so the 1.0 floor applies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(producer): render callbacks with workers that start between its calls

With 4 workers the blocks started at 0, 15, 30, 45, each on a call, so the
rewind mutation showed in a render only 3 times in 5. Seven workers over 60
frames start at 0, 9, 18, 27, 36, 45 and 54, two of them strictly between the
calls at frames 16 and 30; the mutation then fails callbacks in 8 of 8 runs
(worst MAE 97.02), the clean build in none.

A new case checks the partition with distributeFrames. The #list and #label
rects now come from the fixture's markup, and MEASURED_RENDER_REWIND_MUTATION
is 97.02.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): blur pins bound the symptom, liveness detector reports blindness, fixtures time out

Pinned known bugs now require every bad tile inside the pinned region and a worst
MAE within half to double of the measured one. tileTouches, Rect and the band check
move into blurDifferential.ts with a dpr 2 unit test. The blank-frame census counts
canvases it cannot read and asserts none, and names both readings when no blank frame
is seen. No console.info on passing runs. Fixtures cases get a 30 s timeout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(producer): scroll-list render case bounds the race, pins share the engine helpers

The scroll-list loss depends on timing per worker block, so the case now requires
frames 2..27 clean and any later bad tile inside #list, without requiring the loss.
The callbacks pin keeps requiring its symptom, bounds it to the label and to half to
double of its measured size, and its message names the entry to delete. Wording says
multi-worker, not 4-worker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* build(engine): keep the blur test helpers out of the published tarball

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): print the worst outside tile of each arm to explain the CI text diff

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: both arms draw grayscale text so host subpixel settings are not read as blur

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(producer): run the multi-worker blur render alone so it cannot skew other Chrome tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: re-pin the producer lane runner after splitting out the heavy blur render

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): read the blank-frame pin from a wrap observer, not an animation frame poll

The pin sampled in requestAnimationFrame, so it could miss the gap between the
wrap and the first draw. A MutationObserver now reads the output canvas the
moment the root is wrapped, which is deterministic, and every fixture must
report a wrapped root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(engine): drop the blurs field no fixture could set to false

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ci: give the flake gate 60 minutes, it budgets runs by file count not time

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
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