Skip to content

fix(core): blur quiets discrete callbacks instead of undoing what they wrote - #7271

Merged
vanceingalls merged 3 commits into
mainfrom
fix/blur-mute-discrete-callbacks
Nov 6, 2026
Merged

vanceingalls merged 3 commits into
mainfrom
fix/blur-mute-discrete-callbacks

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

What

#7191 undid what a timeline callback wrote by journalling the DOM, which left the JS state of the callback consumed: a once-guarded or memoized callback never showed its state again (if (done) return; done = true; label.textContent = "B" painted A, A against blur off's A, B). It shipped in 0.9.16, which Desktop pins, and Studio's Subtle and Strong write data-motion-blur-events="on", so films can hit it today.

This replaces the journal. During the scan, the landing and eventful pose reads, blur puts a no-op in place of GSAP's discrete callbacks (onStart, onComplete, onReverseComplete, onRepeat, which includes tl.call) and puts the originals back afterwards. onUpdate keeps firing, which is what finds callback-driven movers. The loud rewind through 0 stays: a silent seek to 0 leaves onUpdate state from the scan.

Measured in a spike first (report and raw results are in the plan folder): across 25 real-GSAP patterns, in both tiers, with and without an eventful mover, blur on matches blur off in label and fire count in all but one row (a non-GSAP adapter stand-in), against 248 of 300 comparisons differing on main. Mute plus restore over 2002 animations measured 0.2 ms median.

Details that matter: the swap is a no-op, not a clear, because hasZeroDurationCallbackTween reads typeof vars.onComplete during the seek; it walks tween.timeline as well as getChildren because staggers and keyframes keep per-target tweens there; and it skips an animation it already muted, because scene timelines nest inside the root and one animation is reached twice (the first spike version left a scene's tl.call muted for good).

Tests

  • gsap.mute.test.ts: 5 tests with real GSAP (quiets the four callbacks and leaves onUpdate; stagger and keyframes; reached through two timelines; nested mutes; params and scope).
  • index.test.ts: callbacks are restored when onUpdate throws during the scan; the three tests pinned to the journal are repaired and assert the same behaviour.
  • captureMotionBlur.callbacks.browser.test.ts (new): 23 patterns x 3 paint sequences ([0.3, 0.7], a fresh worker at [0.7], and [0.3, 0.7, 0.3]) x both tiers, each asserting label and fire count equal blur off.
  • New fixture once-guard-label in the differential and in the producer's 7-worker render check.
  • With only the source reverted: 5 of 5 core mute tests fail, 21 of 25 engine callbacks tests fail (every GSAP row except three that main already handles), and the producer once-guard-label render fails.
  • On the final head: core src/runtime 2814 pass, engine browser 151, producer blurDifferential + motionBlurVideo 28.

Known limits (accepted, please look at these)

  • A mover that exists only because a muted callback set it up is found only if that callback fired before the scan, so a worker that starts before the call leaves that mover sharp while a later worker blurs it. Positions stay right.
  • tl.call(() => gsap.to(card, ...)): the tween runs on GSAP's global timeline, the card is never a mover and stays sharp, as with blur off.
  • A non-GSAP adapter's once-only completion still shows early (adapters/animejs.ts ignores suppressEvents; real anime.js is not installed here, so this rests on a stand-in). Pinned by a KNOWN LIMIT test.
  • Animations created while the mute is on are not muted.
  • If the traversal throws partway, the callbacks muted so far stay no-ops; restore overwrites a callback reassigned while muted. Both are tracked for the final review of this plan.

Supersedes the accepted limit in #7191's description.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

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

Unstable (1)

  • seqfastkeys-none-px-r0-root-z100: tracking 0.06, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

@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 · 3 important · 0 nits · head 476d48e1

The once-guard/memo regression is fixed. The mute/restore bookkeeping holds up for nesting, for a scene timeline reached twice, and for onUpdate throwing. The one failure path left is the walk throwing partway; a ↓ suggestion fixes that and the reassigned-while-muted limit. The Flake gate (optional) is deterministically red on the new browser test ↓.

Scope and checks
  • Read end-to-end: adapters/gsap.ts, discover.ts, filterTier.ts, index.ts, the init.ts host, both new test files.
  • Checked against GSAP 3.15 source: _callback reads vars[type] at call time. The no-op's truthiness matters for vars.onRepeat && (gsap-core L1605/L2525) as well as hasZeroDurationCallbackTween. tween.timeline is the stagger/keyframe timeline and Timelines have no .timeline of their own, so the walk can't cycle on real GSAP. Seeks flush lazy renders synchronously, so nothing fires after unmute.
  • Probed with real GSAP: outer mute then inner mute on overlapping timelines, inner restored first, is correct. A walk that throws partway leaves callbacks muted permanently (inline). A callback reassigned while muted gets overwritten (fixed by the same suggestion).
  • muteCallbacks covers exactly what host.seek drives (scene timelines and the root). Non-GSAP adapters are the documented limit.
  • Local: core gsap.mute + captureMotionBlur/ 218/218. CI: all required checks green. Player Safari perf failed on a Safari webdriver error in heavy-alpha-webm run 2 (optional, unrelated).

— Claude

Comment on lines +495 to +508
const held: Array<[Record<string, unknown>, string, unknown]> = [];
for (const timeline of timelines) {
eachAnimationIn(gsapTimelineOf(timeline) as GsapCallbackHolder, ({ vars }) => {
if (!vars) return;
for (const name of DISCRETE_CALLBACKS) {
if (typeof vars[name] !== "function" || vars[name] === noOpInPlaceOfCallback) continue;
held.push([vars, name, vars[name]]);
vars[name] = noOpInPlaceOfCallback;
}
});
}
return () => {
for (const [vars, name, callback] of held) vars[name] = callback;
};

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 — If the walk throws partway, the callbacks it has already muted stay no-ops for the rest of the session, and the next mute/restore can't bring them back. On the next pass those no-ops look like an outer mute's, so the skip on L500 passes over them and they never go into held. I reproduced it: muteDiscreteCallbacks([a, objWhoseGetChildrenThrows]) throws, after which a's tl.call fires 0 times, including after a later full mute → restore → seek(0)/seek(1) cycle. whileCallbacksMuted calls muteCallbacks() before its try, and the paint swallows the error (runtime.captureMotionBlur.paint), so the film just loses its calls without any visible failure. The trigger is any window.__timelines entry that isn't a GSAP timeline and whose getChildren throws (or nests deeply enough to overflow the recursion).

The fix is small. The same guard on restore also covers your second limit, a callback reassigned while muted. I ran it with both cases as tests, plus gsap.mute.test.ts and captureMotionBlur/: 220/220 pass.

Suggested change
const held: Array<[Record<string, unknown>, string, unknown]> = [];
for (const timeline of timelines) {
eachAnimationIn(gsapTimelineOf(timeline) as GsapCallbackHolder, ({ vars }) => {
if (!vars) return;
for (const name of DISCRETE_CALLBACKS) {
if (typeof vars[name] !== "function" || vars[name] === noOpInPlaceOfCallback) continue;
held.push([vars, name, vars[name]]);
vars[name] = noOpInPlaceOfCallback;
}
});
}
return () => {
for (const [vars, name, callback] of held) vars[name] = callback;
};
const held: Array<[Record<string, unknown>, string, unknown]> = [];
const restore = (): void => {
for (const [vars, name, callback] of held)
if (vars[name] === noOpInPlaceOfCallback) vars[name] = callback;
};
try {
for (const timeline of timelines) {
eachAnimationIn(gsapTimelineOf(timeline) as GsapCallbackHolder, ({ vars }) => {
if (!vars) return;
for (const name of DISCRETE_CALLBACKS) {
if (typeof vars[name] !== "function" || vars[name] === noOpInPlaceOfCallback) continue;
held.push([vars, name, vars[name]]);
vars[name] = noOpInPlaceOfCallback;
}
});
}
} catch (err) {
restore();
throw err;
}
return restore;


const WIDTH = 480;
const HEIGHT = 180;
const ROW_TIMEOUT_MS = 180_000;

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 — The Flake gate is red because of this file: 0/10 runs pass, and every run is killed at flake-gate.mjs's 120 s RUN_TIMEOUT_MS. Under --maxWorkers=2 with CPU contention, 25 tests × 3 sequences × 5 page opens (about 375 fixture loads) doesn't fit in 120 s. It passes in the required Test (cli 2/2) in 89 s. That makes the failure deterministic, not a flake, and it will hit every later PR that touches this file. Splitting it into two or three files by row group (for example a–i, j–r, s–y plus the KNOWN LIMIT) should bring each run under the cap.


// A stand-in shaped like adapters/animejs.ts: seek(ms) runs whatever the page wrote, whether or not
// the caller asked to suppress events. Anime.js itself is not installed in this repo.
const ONCE_ONLY_ADAPTER: Row = {

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 (answering your question about tl.call movers): tl.call(() => gsap.to(card, …)) staying sharp is fine. That tween is on the global timeline, which the render seek never drives. The mute is also strictly better than the journal here: the journal fired the call during the scan and spawned a global tween that undoing the DOM couldn't remove (row g pins that).

The other limit is worse. Take a mover that only gets a box because of a muted call, e.g. tl.call(() => card.style.display = "") or a class toggle that reveals an element a root tween moves all along. 0.9.16 found it, because the call fired during the scan before the DOM was undone. Now it's found only if the worker starts after the call, so a 7-worker render blurs it in later chunks and not in earlier ones, and the blur pops on at a chunk boundary. I'd still ship it: wrong content is worse than missing blur. But please pin it as a KNOWN LIMIT row like row x (say tl.call(() => card.style.display = "") at 0.5, worker started at 0.3 vs 0.7), so the change from 0.9.16 is on record and a fix flips the pin. A possible follow-up: rediscover when a tween target that had no box at scan time gains one at paint.

vanceingalls and others added 3 commits November 5, 2026 18:37
…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>
… 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>
…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>

@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 · 0 important · 0 nits · 476d48e1 → a50f4545

Rebase is patch-identical (range-diff = on both commits); a50f454 touches only the callbacks test harness: paintFrame is captureFrame minus the unused PNG, assertions and all three sequences are unchanged, and the row partition is self-checked. Dropping the no-mover arms is the stated trade-off. CI is green on this head, Flake gate included.

@vanceingalls
vanceingalls added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit cd7e93b Nov 6, 2026
98 checks passed
@vanceingalls
vanceingalls deleted the fix/blur-mute-discrete-callbacks branch November 6, 2026 04:32
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…view

Adopts heygen-com#7294 (line_unread findings carry the measured frames), heygen-com#7271
(blur mutes discrete callbacks instead of undoing them), heygen-com#7293 (the
filter tier's svg keeps the last child), heygen-com#7292 (a mover holding a fixed
child stays sharp), heygen-com#7288 (auto motion blur draws with the filter tier)
and heygen-com#7298 (Shift no longer re-renders the timeline). The fork's seek
recovery in captureMotionBlur stays. Defers heygen-com#7134 (no packages/share)
and heygen-com#7295 (needs the heygen-com#7080 crop pipeline). Watermark advances to
66cbabc.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…-com#7288)

537b88d adopted heygen-com#7288, which makes auto motion blur draw with the
filter tier, but left out the engine side. Pixel tests that expect the
capture tier then failed on Windows CI. Take upstream e2bb7da's
change to captureMotionBlur.pixels.browser.test.ts, which asks those
tests for the capture tier explicitly. The other engine files that
heygen-com#7271/heygen-com#7288/heygen-com#7292/heygen-com#7293 touch (fixtures, thresholds, sweep and
differential tests) do not exist in this fork.

Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…eygen-com#7271)

537b88d ported heygen-com#7271, whose muteCallbacks list spreads
sceneTimelines(), but that helper arrives upstream with heygen-com#6785, which
this fork has not taken. The default tsconfig excludes the runtime, so
only CI's `tsc -p tsconfig.runtime.json` caught the missing name
(TS2552 at init.ts:3982). Add upstream's helper as is: registered
timelines other than the root composition's.

Co-authored-by: Vance Ingalls <vance@heygen.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