Repository navigation
fix(core): blur scan and eventful reads put back the DOM a timeline call changed - #7191
Conversation
There was a problem hiding this comment.
Verdict: COMMENT (needs human) · 0 blocker · 1 important · 2 nits · head ba495207
The mechanism is sound for callbacks whose state lives only in the DOM. I ran real-GSAP probes against this head and main: DOM-only toggles and text sets are fixed, and so is a JS-flag toggle on the frame itself. One regression needs a decision: callbacks with a once-guard or a memo go from showing early to never showing (↓ inline). CI was still running when I posted.
Probe matrix, the three limits, scope
Blur-on paint compared with a plain loud seek, [0.3, 0.7] unless noted. Call at 0.5:
| pattern | main | this PR | blur off |
|---|---|---|---|
classList.toggle / textContent= (DOM only) |
B early | ✅ | |
JS flag toggle, paint [0.5, 0.7] |
wrong | ✅ | |
| JS flag toggle, call at 0 or at end | ✅ | ✅ | |
if (done) return; done = true; text = "B" |
B, B | A, A | A, B |
memo render (if (word !== shown) write) |
B, B | A, A | A, B |
i++; text = words[i] |
C, D | A, D | A, B |
call starts gsap.to(label, …) |
1 early | 1 early | |
| call writes into a shadow root | s1 early | s1 early |
- State outside the DOM: confirmed. The new case is a JS cache of the DOM that the undo changes without the cache knowing (inline).
- Journal memory in the quiet pass: it is one-off and about 241 × tweened elements records, freed at
land. A follow-up is fine. - Document-wide scope: I think
documentis the right scope. Blur-off parity covers writes outside the root too.root.ownerDocumentonly matters across documents, and this module already usesdocument(hideRule,getAnimations). - The per-frame test fails when only the
frames()undo is reverted. Verified. - Body nit: the worker-starting-mid-film test already exists on
main(index.test.ts:1079). It is not new.
Audited: discover.ts, index.ts, filterTier.ts, the new tests, the engine and producer pin removals. I ran the core captureMotionBlur suite locally (202 pass). I did not run the engine or producer browser lanes.
— Genesis (Claude)
| stop: journal.stop, | ||
| land: (t) => { | ||
| seek(t); | ||
| journal.undo(); |
There was a problem hiding this comment.
important — once-guarded and memoized callbacks now never show their state (on main they showed it early). The scan still fires every call past T0. The undo then puts back the DOM but leaves the JS that guards or caches the write. So when the real crossing comes, the callback does nothing. Two cases, run with real GSAP and events="on", painting 0.3 and then 0.7:
let done = false;
tl.call(() => { if (done) return; done = true; label.textContent = "B"; }, [], 0.5);
// main: B, B this PR: A, A blur off: A, B
const state = { word: "A" }; let shown = "A";
tl.call(() => (state.word = "B"), [], 0.5);
tl.eventCallback("onUpdate", () => { if (state.word !== shown) { label.textContent = state.word; shown = state.word; } });
// main: B, B this PR: A, A blur off: A, BOnce-guards are the usual way authors defend against GSAP firing calls again on a backward seek, so this case will be hit. I don't see a DOM-only undo that can fix it. Both behaviours are wrong, and this is a trade-off call. What I'd suggest:
- Name it in Known limits and in
packages/cli/src/docs/motion-blur.md: with events on, callbacks must take their state fromtl.time(), not from guards or memos. - Log a one-time
console.info(or a report code) whenundoreverts achildList,characterDataorclassrecord. That means a callback's DOM write was taken back, and authors would get a signal where today it fails without one.
There was a problem hiding this comment.
Dispositioned: Vance accepted this as a known limit. It is in the body's Known limits, with the follow-up named (stop the loud scan from firing tl.call while keeping onUpdate/render(t)).
| } | ||
| }; | ||
| const observer = new MutationObserver(fold); | ||
| observer.observe(doc, { |
There was a problem hiding this comment.
nit / pre-existing, for Known limits: two more cases still show defect G's "early" symptom after this PR (probed on both heads).
- A MutationObserver on
documentdoes not see inside shadow roots. A call that writesshadowRoot.textContentis not undone.registry/blocks/frost-sequence-camera-orbitrenders into a shadow root, though only fromrender(t), which is safe. - A call that starts something asynchronous (
gsap.to(label, {opacity: 1}), a timer,media.play()) makes its writes afterundo, so the effect still lands early.
There was a problem hiding this comment.
Dispositioned: both cases (async writes, shadow-root writes) are now in Known limits.
| for (const [ns, name, old] of values.values()) { | ||
| if (!isElement(node)) (node as CharacterData).data = old ?? ""; | ||
| else if (old === null) node.removeAttributeNS(ns, name); | ||
| else node.setAttributeNS(ns, name, old); |
There was a problem hiding this comment.
nit: undo rewrites every journaled attribute, even one already back at its old value. With eventful reads, that includes the style of every tweened element on every frame. Each rewrite parses the inline style again and sends a record to every other observer. Skipping equal values is cheap:
else if (node.getAttributeNS(ns, name) !== old) node.setAttributeNS(ns, name, old);(The same check applies to .data and to removeAttributeNS when the attribute is already absent.)
There was a problem hiding this comment.
✅ Fixed in 0830ed8c: putBack skips text and attributes already at their old value, including an absent attribute whose old value is null.
There was a problem hiding this comment.
Verdict: APPROVE · 1 resolved, 2 accepted as known limits, 0 new · ba495207 → 0830ed8c
Merge-order heads-up: this PR conflicts with #7190 in the three pin files. Whichever lands second needs a rebase that empties both KNOWN_REAL_DIFFERENCES maps, and then a fresh approval.
— Genesis (Claude)
0830ed8 to
2bb0920
Compare
There was a problem hiding this comment.
Verdict: COMMENT (needs rebase) · 0 resolved, 0 open, 0 new · 0830ed8c → 2bb09200
The range-diff against the approved head shows only the ...shutterTimes(...) swap in filterTier.ts and index.ts. But this head is on 7862e271, and #7190 and #7149 landed after it, so it now conflicts with main in the three pin files (thresholds.ts, the engine differential, the producer differential). As noted on the last review, resolving that means emptying both KNOWN_REAL_DIFFERENCES maps and dropping the dead MEASURED_* constants and glyphFromMarkup/boxFromMarkup. I'll re-approve once the rebased head is up and CI on it is green.
— Genesis (Claude)
…all changed With data-motion-blur-events on, the mover scan runs the timeline to the end with callbacks on and rewinds with callbacks on. GSAP fires a tl.call on a backward crossing too, so the rewind left the earliest call's DOM effect in place: the callbacks fixture showed label "B" from frame 0, before its 0.5 s call. Calls cannot be un-fired, so the scan (and eventful per-frame reads) now journal DOM mutations with a MutationObserver, land on the frame with callbacks on as before (keeping JS-side call parity), then undo the journal back to the pre-scan DOM, which is what a plain seek to the frame produced. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…side cases Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eir old value Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2bb0920 to
709abde
Compare
There was a problem hiding this comment.
Verdict: APPROVE · 0 resolved, 0 open, 0 new · 2bb09200 → 709abde1
Rebased onto 6728b82e. Range-diff against the approved 0830ed8c: commit 1 differs only by the ...shutterTimes(...) swap, and commit 3 is identical. Commit 2 now empties both KNOWN_REAL_DIFFERENCES maps and removes KnownBug, expectKnownBug, MEASURED_LABEL_EARLY_* and glyphFromMarkup/markupOf/cssPx. EXTRA_OUTSIDE_TIMES and the 7-worker partition guard stay. Every Actions run on this head is green, including Flake gate and Windows render verification.
— Genesis (Claude)
…eview Adopts or partially adopts 8 groups: capture-failure diagnostics and frame-capture retry in engine, ffprobe fixes, renderOrchestrator updates, studio-server render/files/source-mutation routes, CLI screenshot retry and init/snapshot/timeline fixes, and the motion-blur land-then-undo path (fork seek recovery kept). Left out with ledger reasons: heygen-com#7211 render diagnostic CLI, heygen-com#7089 reverse start; heygen-com#7220 deferred. Core packed budget grows by the measured 1065 bytes since upstream heygen-com#7191 changed no budget. Watermark advances to 659ef56. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> Co-authored-by: WaterrrForever <miao.yang@heygen.com> Co-authored-by: Vance Ingalls <vance@heygen.com> Co-authored-by: James Russo <james.russo@heygen.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y wrote #7191 undid the DOM a callback wrote during the blur scan with a mutation journal. That regressed once-guarded and memoized timeline callbacks: the callback ran, its guard flipped, and the journal put the DOM back, so the real seek never wrote it again. Mute GSAP's onStart, onComplete, onReverseComplete and onRepeat for the scan and for eventful pose reads, then restore them. onUpdate keeps firing, so movers driven from it are still found. muteDiscreteCallbacks replaces a callback with a no-op rather than clearing it (hasZeroDurationCallbackTween reads typeof vars.onComplete), walks tween.timeline for staggers and keyframes, and skips an animation already muted because scene timelines nest in the root. The journal (journalDom, landThenUndoDom, putBack) is deleted. The loud rewind through 0 stays. Measured in the spike (mute-spike-report.md, "Probe matrix and fresh worker"): every GSAP pattern matches blur off in label and fire count; 8 of 300 comparisons differ, all the non-GSAP row, against 248 of 300 on main. This supersedes the limit #7191 accepted. Mute and restore over 2002 animations is 0.2 ms. Limits: - A mover that exists only because a muted callback set it up is found only if that callback fired before the scan. A worker starting before the call leaves that mover sharp while a later worker blurs it; positions stay right. - tl.call(() => gsap.to(...)) movers are never found and stay sharp. - A non-GSAP adapter that ignores suppressEvents (adapters/animejs.ts) and writes DOM on a once-only completion shows it early. Pinned by a KNOWN LIMIT test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…y wrote (heygen-com#7271) * fix(core): blur quiets discrete callbacks instead of undoing what they wrote heygen-com#7191 undid the DOM a callback wrote during the blur scan with a mutation journal. That regressed once-guarded and memoized timeline callbacks: the callback ran, its guard flipped, and the journal put the DOM back, so the real seek never wrote it again. Mute GSAP's onStart, onComplete, onReverseComplete and onRepeat for the scan and for eventful pose reads, then restore them. onUpdate keeps firing, so movers driven from it are still found. muteDiscreteCallbacks replaces a callback with a no-op rather than clearing it (hasZeroDurationCallbackTween reads typeof vars.onComplete), walks tween.timeline for staggers and keyframes, and skips an animation already muted because scene timelines nest in the root. The journal (journalDom, landThenUndoDom, putBack) is deleted. The loud rewind through 0 stays. Measured in the spike (mute-spike-report.md, "Probe matrix and fresh worker"): every GSAP pattern matches blur off in label and fire count; 8 of 300 comparisons differ, all the non-GSAP row, against 248 of 300 on main. This supersedes the limit heygen-com#7191 accepted. Mute and restore over 2002 animations is 0.2 ms. Limits: - A mover that exists only because a muted callback set it up is found only if that callback fired before the scan. A worker starting before the call leaves that mover sharp while a later worker blurs it; positions stay right. - tl.call(() => gsap.to(...)) movers are never found and stay sharp. - A non-GSAP adapter that ignores suppressEvents (adapters/animejs.ts) and writes DOM on a once-only completion shows it early. Pinned by a KNOWN LIMIT test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(producer): drop the once-guard-label block-start guard that pins a block that cannot fail The guard required a block start in [1, 15], the worker at frame 9, and the comment said it paints on both sides of the call at 16. Its frames after the call (16, 17) sit inside the trail mask at the label, so that block cannot show the bug. The outside-the-trail case on the 7-worker partition is what catches the mutant; the fixture stays in FIXTURE_NAMES. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(engine): split the callbacks matrix in three and share one blur-off run per row The matrix file took 247 s on an idle Mac and timed out at the flake gate's 120 s in all 10 reruns. Each row now compares the arms against one blur-off page painted in order and one fresh page, not a new blur-off page per arm and sequence; the in-order sequence is the first paints of the page that goes back to the first time; rows run their pages together; the paint skips the full-frame PNG that only the pixel tests read. Only the two arms with an onUpdate mover stay (both tiers): they run the scan's mute and the per-frame mute, which the arms without a mover do not. The rows and harness move to __fixtures__/motionBlur/callbackRows.ts and the matrix is split over three files so each run fits the gate. Rows a to y, the three sequences, both tiers, row y and the KNOWN LIMIT test are all kept; no assertion changed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
…view to heygen-com#7258 Adopts heygen-com#7306 (motion-blur composition counts in render_complete telemetry, adapted to discoverMovers; readEvents stays false without heygen-com#7191) and heygen-com#7318 (a set that interrupts a tween holds at any worker count) together with its merged prerequisite heygen-com#6851. PR axis advances to heygen-com#7258; heygen-com#7253 and heygen-com#7257 are open and pinned as monitored (97->99). Co-authored-by: Vance Ingalls <vance@heygen.com> Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
With
data-motion-blur-events="on"(Desktop's default writes it), a composition whose timeline uses forward-onlytl.call(...)callbacks that change the DOM (a class, a label) rendered the wrong state: the label showed its later value before the call that sets it, from frame 0 on still frames too. This is defect G in Rames's sweep, and thecallbackscase the #7108 invariants pinned.Cause (traced in real Chrome): GSAP fires
tl.callagain when a seek with events on goes backwards, sorewindThroughEveryCall(2.0 s back to 0 with events on) ends on the earliest call's effect. Per-frame eventful reads had the same flaw for a call just past the frame. It is GSAP's behaviour, not the runtime's seek wrappers orholdFrame.Fix: a MutationObserver journal records the DOM during the scan and during eventful reads. The runtime still lands on the frame with events on, which keeps toggle parity in JS, and then puts back the DOM as it was before the scan. Options rejected: re-firing calls and replaying from 0 (reasons in the commit and the report: replays toggles twice or breaks stateful callbacks).
Tests
callbacks@0.2 / 0.3 / 0.4(and the extra frames 6/9/12) and the producercallbackscase are now ordinary outside cases. Both fail with the source reverted.src/runtime: 2762 pass. Engine browser (differential, fixtures, pixels, liveness): 135 pass. Producer blurDifferential 18, motionBlurVideo 7.Known limits (please look at these)
events="on", paint 0.3 then 0.7):if (done) return; done = true; label.textContent = "B"and a memoizedonUpdatewrite go fromB, Bon main (early) toA, Ahere (never shows), while blur off givesA, B. The scan fires the call, which sets the guard; the undo restores the DOM but not the JS guard. Accepted by Vance on 2026-11-04 to merge with this limit; a follow-up would stop the loud scan from firingtl.callcallbacks at all while still firingonUpdate/render(t), which are what find callback-driven movers.gsap.to, a timer,media.play()) writes after the undo, and a write into a shadow root is not seen by the observer: both still show the early state.scrollTop, form values, GSAP's cache when a callback itself callsgsap.set.WORKERS_BY_FIXTURE/REQUIRED_STARTSguard forcallbacksnow guards the undo, not the rewind alone, because the undo hides a rewind regression in the pixels. The rewind is still covered by the core test "leaves a worker starting mid-film the state of the calls behind it".document, so a callback that writes outside the root is undone too (root.ownerDocumentwould be the one-token change).filterTier.tscarries the same change and is covered only indirectly.🤖 Generated with Claude Code