Repository navigation
fix(core): a motion blur worker starting mid-film keeps the right state - #6967
Conversation
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>
There was a problem hiding this comment.
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 samescanMoversand then callsseek(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 coversdata-motion-blur-tier="filter", Chrome without html-in-canvas, and everysettleTier()handoff (translation-only onauto, 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 intodescribe("filter fallback")with no html-in-canvas installed, and it fails at this head withexpected 'second' to be 'first'. Addingseek(0);beforefilterTier.ts:133makes it pass, and the wholecaptureMotionBlur/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
Edit accuracy: accurate 271 (base branch 271), smooth 128 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
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>
There was a problem hiding this comment.
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
…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>
…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>
…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>
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. Inspacex-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 firestl.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
scanMoversnow 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 throughscanMovers, including the rescan after a tier handoff. A silent seek to 0 is not enough: it leavesonUpdatestate from the scan, and the counter in the same film then read$1.0Tinstead 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-launchforced 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 inHF-heygen-stripe), because the scan starts from the worker's own state. No visible difference inspacex-launch, andHF-heygen-stripestays within its Off noise floor.🤖 Generated with Claude Code