Skip to content

fix(core): blur scan and eventful reads put back the DOM a timeline call changed - #7191

Merged
vanceingalls merged 3 commits into
mainfrom
fix/blur-events-callback-state
Nov 5, 2026
Merged

vanceingalls merged 3 commits into
mainfrom
fix/blur-events-callback-state

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

What

With data-motion-blur-events="on" (Desktop's default writes it), a composition whose timeline uses forward-only tl.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 the callbacks case the #7108 invariants pinned.

Cause (traced in real Chrome): GSAP fires tl.call again when a seek with events on goes backwards, so rewindThroughEveryCall (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 or holdFrame.

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

  • New core tests with real GSAP: a toggle call and a text-set call through scan and a paint before and after the call; a worker-starting-mid-film case (this one already exists on main and is not new); a per-frame case that fails on its own when only the per-frame undo is reverted. All fail with the source change reverted.
  • test(engine): pixel invariants and fixtures that guard default-on capture blur #7108 pins flipped: engine callbacks@0.2 / 0.3 / 0.4 (and the extra frames 6/9/12) and the producer callbacks case are now ordinary outside cases. Both fail with the source reverted.
  • Core src/runtime: 2762 pass. Engine browser (differential, fixtures, pixels, liveness): 135 pass. Producer blurDifferential 18, motionBlurVideo 7.

Known limits (please look at these)

  • Once-guarded and memoized callbacks regress (Genesis's probes, real GSAP, events="on", paint 0.3 then 0.7): if (done) return; done = true; label.textContent = "B" and a memoized onUpdate write go from B, B on main (early) to A, A here (never shows), while blur off gives A, 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 firing tl.call callbacks at all while still firing onUpdate/render(t), which are what find callback-driven movers.
  • A callback that starts something asynchronous (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.
  • State outside the DOM is not restored: JS variables beyond toggle parity, canvas pixels, scrollTop, form values, GSAP's cache when a callback itself calls gsap.set.
  • The producer WORKERS_BY_FIXTURE / REQUIRED_STARTS guard for callbacks now 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".
  • The journal is flushed on each step of the eventful pass only; records from the quiet pass accumulate until the final landing, so memory there scales with steps x tweened elements. A flush-only hook for that pass is a possible follow-up.
  • The journal watches document, so a callback that writes outside the root is undone too (root.ownerDocument would be the one-token change).
  • filterTier.ts carries the same change and is covered only indirectly.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 108 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 (8)

@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: 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 document is the right scope. Blur-off parity covers writes outside the root too. root.ownerDocument only matters across documents, and this module already uses document (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();

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.

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, B

Once-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:

  1. Name it in Known limits and in packages/cli/src/docs/motion-blur.md: with events on, callbacks must take their state from tl.time(), not from guards or memos.
  2. Log a one-time console.info (or a report code) when undo reverts a childList, characterData or class record. That means a callback's DOM write was taken back, and authors would get a signal where today it fails without one.

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.

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, {

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.

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 document does not see inside shadow roots. A call that writes shadowRoot.textContent is not undone. registry/blocks/frost-sequence-camera-orbit renders into a shadow root, though only from render(t), which is safe.
  • A call that starts something asynchronous (gsap.to(label, {opacity: 1}), a timer, media.play()) makes its writes after undo, so the effect still lands early.

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.

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);

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.

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.)

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.

✅ Fixed in 0830ed8c: putBack skips text and attributes already at their old value, including an absent attribute whose old value is null.

@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, 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)

@vanceingalls
vanceingalls force-pushed the fix/blur-events-callback-state branch from 0830ed8 to 2bb0920 Compare November 5, 2026 05:02

@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: 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)

vanceingalls and others added 3 commits November 4, 2026 22:01
…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>
@vanceingalls
vanceingalls force-pushed the fix/blur-events-callback-state branch from 2bb0920 to 709abde Compare November 5, 2026 06:06

@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 · 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)

@vanceingalls
vanceingalls added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 898d0fb Nov 5, 2026
98 checks passed
@vanceingalls
vanceingalls deleted the fix/blur-events-callback-state branch November 5, 2026 06:54
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…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>
vanceingalls added a commit that referenced this pull request Nov 6, 2026
…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>
wangzheuser pushed a commit to wangzheuser/hyperframes that referenced this pull request Nov 6, 2026
…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>
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…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>
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