Repository navigation
fix(core): blur quiets discrete callbacks instead of undoing what they wrote - #7271
Conversation
Edit accuracy: accurate 267 (base branch 267), smooth 131 of thoseThe gate passes. Quarantined, measured but not gated (8)
Unstable (1)
|
There was a problem hiding this comment.
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, theinit.tshost, both new test files. - Checked against GSAP 3.15 source:
_callbackreadsvars[type]at call time. The no-op's truthiness matters forvars.onRepeat &&(gsap-core L1605/L2525) as well ashasZeroDurationCallbackTween.tween.timelineis the stagger/keyframe timeline and Timelines have no.timelineof 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).
muteCallbackscovers exactly whathost.seekdrives (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 perffailed on a Safari webdriver error in heavy-alpha-webm run 2 (optional, unrelated).
— Claude
| 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; | ||
| }; |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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.
…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>
476d48e to
a50f454
Compare
There was a problem hiding this comment.
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.
…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>
…-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>
…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>
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 writedata-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 includestl.call) and puts the originals back afterwards.onUpdatekeeps firing, which is what finds callback-driven movers. The loud rewind through 0 stays: a silent seek to 0 leavesonUpdatestate 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
hasZeroDurationCallbackTweenreadstypeof vars.onCompleteduring the seek; it walkstween.timelineas well asgetChildrenbecause 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'stl.callmuted for good).Tests
gsap.mute.test.ts: 5 tests with real GSAP (quiets the four callbacks and leavesonUpdate; stagger and keyframes; reached through two timelines; nested mutes; params and scope).index.test.ts: callbacks are restored whenonUpdatethrows 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.once-guard-labelin the differential and in the producer's 7-worker render check.mainalready handles), and the produceronce-guard-labelrender fails.src/runtime2814 pass, engine browser 151, producerblurDifferential+motionBlurVideo28.Known limits (accepted, please look at these)
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.adapters/animejs.tsignoressuppressEvents; real anime.js is not installed here, so this rests on a stand-in). Pinned by aKNOWN LIMITtest.Supersedes the accepted limit in #7191's description.
🤖 Generated with Claude Code