Skip to content

feat(studio): add keyframe timeline state - #2781

Merged
miguel-heygen merged 7 commits into
mainfrom
codex/studio-timeline-b-keyframe-state-v2
Jul 28, 2026
Merged

feat(studio): add keyframe timeline state#2781
miguel-heygen merged 7 commits into
mainfrom
codex/studio-timeline-b-keyframe-state-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Adds the shared Studio state and cache primitives for keyframe-aware timeline authoring.

Why

Timeline lanes need one canonical source for discovered GSAP keyframes, selection identity, and edit state before rendering or interaction layers can safely consume them.

How

Introduces focused keyframe state/cache helpers and hooks, with identity and state transitions covered independently. This is B1 of the Family B draft Graphite stack and targets the immutable PR-less Family A review baseline.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (not applicable)

Exact Family B tip validation: 2,910 Studio tests, 398 Studio Server tests, both package typechecks, formatting, lint, diff check, file-size gate, and Fallow audit passed.

Deferred review findings

Every blocker and high finding raised on this PR is fixed in the stack. The 6 remaining low/nit findings are parked, verbatim, in .scratch/studio-timeline-family-b/issues/01-pr-2683-deferred-review-findings.md:

  • 🟡 packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:22 — id extraction regex /^#([\w-]+)/ isn't paired with the new idSelector attribute-selector output
  • 🟡 packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:77 — Synthesized flat-tween's top-level ease is dropped when written into keyframeCache
  • 🟡 packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:59 — Ambiguity-flag logic is inlined identically in two places — contract drift risk across foundation slice
  • 🟢 packages/studio/src/player/store/keyframeSlice.ts:36 — expandClips has no bulk-collapse counterpart
  • 🟢 packages/studio/src/hooks/useStudioTestHooks.ts:45 — window.__studioTest is reassigned every effect re-run and stomps on any prior handle
  • 🔵 packages/studio/src/player/store/keyframeSlice.ts:26 — selectedKeyframes stores two disjoint key shapes with no discriminator or helper

Supersedes #2683, which was closed when main was rewritten to unwind an early landing of this stack. Same head commit, same review history.

R1 review follow-ups

Fixed in this PR:

  • The keyframe cache and gsapAnimations no longer disagree on which tweens they admit. The property-group gate is gone from both writers, so a mixed-group tween ({ x, opacity } classifies to no group) can no longer cache diamonds with no source animation behind them.

Not changed, with rationale:

  • replaceSelectedKeyframes / selectRangeKeyframes are skipped as YAGNI. No consumer exists in the stack and the unused-exports gate would flag both.
  • The two disjoint selection-key shapes are already resolved downstack by the JSON envelope in timelineKeyframeIdentity.ts (fix(studio): harden keyframe editing semantics #2787).

The 3 remaining low findings are parked in .scratch/studio-timeline-family-b/issues/09-family-b-v2-r1-deferred.md.

R2 review follow-ups

Fixed in this PR:

  • Every keyframe-cache writer open-coded the clip-relative percentage, and the post-commit writer rounded to 0.1% while the others used 0.001%. Selection keys embed that number, so a commit-time rewrite could orphan a live key. toClipPercentage now owns the rounding and toClipKeyframes owns the whole row (percentage plus the tween percentage and animation identity the lanes read); the parsed write reuses elementCacheKeys instead of open-coding its three key variants.

Deliberate, stated for the record:

  • window.__studioTest (packages/studio/src/hooks/useStudioTestHooks.ts) is a headless-QA shortcut, not a product surface. It is gated on import.meta.env.DEV, cleared on unmount, and never defined in a production build.

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-b-keyframe-state-v2 branch from 5dd41e9 to e4d7bde Compare July 25, 2026 19:45

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R1 adversarial review — head e4d7bde

Verdict: COMMENT. State-shape primitive is sound: pure setters, no Date.now/Math.random/DOM in reducers, immutable Set/Map copies on write, and the ambiguity-flag semantics are captured in two tests. Author has parked six known low/nit findings verbatim in the scratch doc, so I'm surfacing only the ones that are stack-forward defects or drift risks the deferred list didn't already own. Head SHA verified e4d7bde64f3b3e500834231134bc9fdb3858ad41; pre-existing state on main confirms selectedKeyframes:Set and keyframeCache:Map shapes are inherited, so I only fault the new additions where scope worsens the invariant.

P2

1. Selection primitives are single-key only — later stack PRs will patch this slice. packages/studio/src/player/store/keyframeSlice.ts:31-35. toggleSelectedKeyframe(key) + clearSelectedKeyframes() covers add/remove/clear. Any downstream marquee/range/select-all lands with either (a) a loop of toggleSelectedKeyframe (N re-renders, each rebuilding the Set), or (b) a slice patch that adds replaceSelectedKeyframes(keys). Since this PR is explicitly the shared primitive, add replaceSelectedKeyframes(keys: readonly string[]) and selectRangeKeyframes(keys: readonly string[]) now — Lens B / Lens E.

2. easeAmbiguous guard is duplicated verbatim across the two writers — the exact drift risk the deferred 01-pr-2683 doc calls out. packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:60-72 and packages/studio/src/hooks/gsapTweenSynth.ts:15-30. Both blocks encode the same three-clause invariant on the merged keyframe. The invariant is the state contract downstream lanes read from (easeAmbiguous ⇒ suppress inline button), so a divergence between the two writers is a silent data-shape defect, not just a style nit. Extract flagAmbiguousEase(existing, incoming) and call it from both sites. Deferring this to a future PR is fine, but this is the correct PR to fix it — the slice is where the invariant becomes public API. Author flagged 🟡.

3. gsapAnimations silently diverges from keyframeCache on any anim missing propertyGroup. packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:22-25 gates the sourceAnimations write on if (anim.propertyGroup); keyframeCache writes regardless. useGsapTweenCache.ts:333-337 applies the same filter. Net effect: for an element whose only tween has no propertyGroup, keyframeCache has an entry, gsapAnimations is empty, and writeGsapAnimationsForElement(..., undefined) deletes whatever was there. The slice comment (keyframeSlice.ts:38) says "expanded property lanes read this, never keyframeCache" — so an expanded lane on that element renders nothing while the collapsed row still shows diamonds. Either (a) tighten the invariant ("no propertyGroup ⇒ don't cache") uniformly across both writers, or (b) drop the propertyGroup gate on gsapAnimations and let downstream lane renderers filter. Pick one; the current split is undefined behavior. Lens A / Lens E.

4. selectedKeyframes holds two disjoint key shapes with no discriminator — author flagged 🔵, upgrading to P2 because it's the slice's public API. packages/studio/src/player/store/keyframeSlice.ts:15. Collapsed diamonds key as element:pct, expanded as element:group:animation:clipPct. Callers passing a collapsed key against an expanded set won't error — they'll silently miss. This is a state-shape defect the stack will inherit N times. Fix now with a discriminator prefix (c: / e:) or split into two Sets before consumers land, so the top of the stack doesn't cement it.

P3

5. expandClips has no collapseClips counterpart. packages/studio/src/player/store/keyframeSlice.ts:36. Author flagged 🟢. Symmetric pair belongs in the primitive — Lens E.

6. window.__studioTest is reassigned every effect re-run. packages/studio/src/hooks/useStudioTestHooks.ts:25-46. Effect deps [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef] will churn on any parent re-render that doesn't memoize the two callbacks; each churn stomps any handle a dev driver just captured. Guard with if (!window.__studioTest) or install once at module scope. Author flagged 🟢.

7. Set/Map shape is persistence-hostile; document that constraint on the slice. packages/studio/src/player/store/keyframeSlice.ts:31, 47, 49. Neither zustand/persist nor devtools middleware is currently wired (grep-verified), so this is deferred, not blocking. But two new Map/Set fields land in this PR, and any downstream PR that adds persist/devtools/SSR replay will round-trip them to {} with no warning. Add a top-of-file comment: "Set/Map by design — do not add persist middleware without a serializer." Lens D.

Adversarial lenses

  • A (state-shape): #3, #4 fired — divergent cache invariants and mixed key shapes are the P2s.
  • B (selection): #1 fired — no bulk/range primitive.
  • C (reducer purity): clean; only #6 (window write in effect) is a side-effect boundary nit.
  • D (serialization): #7 fired — deferred but worth pinning.
  • E (stack-forward compat): #1, #3, #5 fired — all shape gaps the primitive should close before consumers land.

Approve after #2 (ambiguity-helper extraction) and #3 (propertyGroup gate convergence) land in-stack; #1 and #4 are worth attempting now while the primitive has zero consumers.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at e4d7bde64f3b.

R2 adversarial pass on the foundation slice for Family B. Publishes 5 contracts (keyframeCache, gsapAnimations, focusedEaseSegment, selectedKeyframes, expandedClipIds) that all 7 downstream PRs build on. The concerns cluster around primitive-layer invariants the type system doesn't enforce — they'll propagate through B2-B8 consumers, so worth closing here. Convergent with Via's rollup on gsapAnimations/keyframeCache divergence.

🟢 Verified clean

  • idSelector() correctly emits [id="..."] fallback for digit-leading/dotted/space-containing ids and escapes quotes/backslashes; SAFE_HASH_ID regex ^-?[A-Za-z_][\w-]*$ cannot match anything that would break querySelector
  • deduplicateKeyframes' generic signature threads animationId/easeAmbiguous through without narrowing to GsapPercentageKeyframe
  • trackStudioSegmentEaseEdit's discriminant action: 'open' | 'commit' threaded through AnimationCard + events test
  • createKeyframeSlice's initial Set/Map values are fresh instances; toggle/set/expandClips setters return early with state unchanged on no-op transitions (proper Zustand shallow-eq)

Complements Via's parallel adversarial pass (Family B rollup). Where Via found 0 P1 / 17 P2 / 18 P3, the adversarial subagent pass here surfaced additional depth on the mutation-authority thread and the Promise chain.

Review by Rames D Jusso

Comment thread packages/studio/src/player/store/playerStore.ts
Comment thread packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts
Comment thread packages/studio/src/hooks/useGsapTweenCache.ts
Comment thread packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts Outdated
Comment thread packages/studio/src/hooks/gsapTweenSynth.ts
Comment thread packages/studio/src/hooks/useStudioTestHooks.ts
Comment thread packages/studio/src/player/store/keyframeSlice.ts
An ungrouped tween (mixed property groups classify to propertyGroup
undefined) fed keyframeCache but was skipped by every gsapAnimations
writer, so the collapsed row drew diamonds the expanded lanes had no
source animation to render. Drop the property-group gate at all three
writers; lane consumers already filter by group.

Also route the same-percentage merge in updateKeyframeCacheFromParsed
through deduplicateKeyframes so the easeAmbiguous rule has one owner.
Each keyframe-cache writer re-derived a clip-relative percentage inline, and the
post-commit writer rounded to 0.1% while the others used 0.001%. Selection keys
embed that number, so a commit-time rewrite could orphan a live key.
toClipPercentage owns the rounding, toClipKeyframes owns the whole row (percentage
plus the tween percentage and animation identity the lanes read), and the parsed
write reuses elementCacheKeys instead of open-coding the three key variants.
R3 review follow-ups on the keyframe cache:

- clearKeyframeCacheForFile collected ids from the index.html alias prefix
  too, so a re-scan of one composition file wiped rows a sibling file had
  just written (several files re-scan concurrently). Only the file's own
  prefixed keys name the ids now; clearKeyframeCacheForElement still takes
  the alias and bare key with them.
- toClipKeyframes fell back to a fixed 1s tween duration, which put a
  duration-less tween's keyframes at a percentage no edit path agreed with.
  It now spans the clip, matching resolveEditableTweenDuration.
- collectAnimatableKeyframeProperties takes `object` so call sites drop
  their `as Record<string, unknown>` casts.

Regression tests cover both fixes.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta review from R1 head e4d7bde64 to current head d05ecb109 — three new commits (e34e529 R1 fix, acf6766 R2 refactor, d05ecb1 R3 fix). Also verified the 6 R1 findings you deferred against stack tip 6ee750fee.

Deferral to stack tip — verified

Reading the stack tip head at 6ee750fee (PR #2791), each of the 6 R1 findings you deferred does land there. Per the Graphite-stack fix-hoist model this is fine as long as #2781 doesn't merge to main ahead of the sibling PRs — the fixes are in the stack, not on this SHA. Verdict per finding:

Finding This PR (d05ecb109) Stack tip (6ee750fee)
🟠 F#1 playerStore.reset() omits focusedEaseSegment still stranded (line 517-546) fixed — focusedEaseSegment: null, added to reset
🟠 F#2 updateKeyframeCacheFromParsed strips [id="…"] selectors still uses anim.targetSelector.match(/^#([\w-]+)/)?.[1] (line 21 + line 64) fixed — both sites now call idFromSelector(anim.targetSelector) from gsapShared
🟡 F#3 useGsapAnimationsForElement doesn't clear stale gsapAnimations when animations become empty mostly OK via the allKeyframes.length === 0 clear path — but the hold-skip criteria in sourceAnimations filter and the inline for-loop differ subtly (see note below) fixed — both paths gated on the same isStaticPositionHold(anim) helper
🟡 F#4 updateKeyframeCacheFromParsed wipes valid gsapAnimations for propertyGroup-less animations fixed here in e34e529 — property-group gate removed at all three writers, sourceAnimations.get(id) is populated for every anim that fed the cache (same fix carried at tip)
🟡 F#5 deduplicateKeyframes leaves ease iteration-order-dependent after flagging easeAmbiguous still if (kf.ease) existing.ease = kf.ease; unconditionally fixed — if (existing.easeAmbiguous) delete existing.ease; else if (kf.ease) existing.ease = kf.ease;
🟡 F#6 useStudioTestHooks cleanup = undefined leaves the key on window still .__studioTest = undefined; fixed — delete window.__studioTest; (with rationale comment)
🟢 F#7 KeyframeSlice setters allocate a new Map on every no-op still unconditional new Map(...) fixed — early-return if (data ? state.keyframeCache.get(elementId) === data : !state.keyframeCache.has(elementId)) return state; on both setKeyframeCache and setGsapAnimations

On F#3 specifically — the R1 concern about stale gsapAnimations survival is largely resolved at this head because the allKeyframes.length === 0 branch calls clearKeyframeCacheForElement, and that clears both keyframeCache and gsapAnimations for the element. What remains is a small divergence between the sourceAnimations filter ((animation.keyframes || synthesizeFlatTweenKeyframes(animation))) and the inline hold-skip inside the for-loop (method === "set" || (duration ?? 0) === 0 with x/y-only prop guard). A to({ duration: 0 }) with x/y props but no immediateRender: true clears the inline skip but not the synth's null-return path, so it lands in sourceAnimations but not allKeyframes. That divergence goes away at stack tip where both paths call isStaticPositionHold(anim). Corner case, not a blocker.

Merge-order coupling to flag: if #2781 merges to main ahead of the stack (e.g. bypassing Graphite's stack semantics), findings F#1, F#2, F#5, F#6, F#7 all ship live. F#2 in particular has real teeth here — idSelector() is already emitted by gsapDragCommit, useGestureCommit, and ensureElementAddressable at this SHA (see gsapDragCommit.ts:120, useGestureCommit.ts:171, gsapScriptCommitHelpers.ts:11), so a drag on a digit-leading id would leave post-commit cache stale until a full file re-scan. Trusting the stack merge sequence you've set up.

The three new commits — LGTM

  • e34e529fix(studio): keep gsapAnimations in sync with the keyframe cache. Drops the if (anim.propertyGroup) gate at all three writers; every tween that feeds keyframeCache now also feeds gsapAnimations. Also routes the pre-existing duplicated same-percentage-merge in updateKeyframeCacheFromParsed through deduplicateKeyframes instead of open-coding it. Clean fix; kills my F#4 cleanly and preemptively removes an easeAmbiguous drift risk between the two writers.

  • acf6766refactor(studio): one owner for clip-relative keyframe rows. Extracts toClipPercentage (0.001% precision, absoluteTime - clipStart normalized against clipDuration with fallback for degenerate 0-duration clips) and toClipKeyframes (percentage + tweenPercentage + propertyGroup + animationId shape) into gsapShared.ts, then rewires updateKeyframeCacheFromParsed, useGsapAnimationsForElement, and usePopulateKeyframeCacheForFile to use them. Kills the 0.1% vs 0.001% precision drift between the post-commit writer and the pre-commit writers — real fix, since selection keys embed that number and a commit-time rewrite at coarser precision would orphan a live selection. Also unifies the three cache-key variants through elementCacheKeys(targetPath, id). Tests cover both.

  • d05ecb1fix(studio): scope the per-file keyframe-cache clear to its own keys. Two real fixes:

    1. clearKeyframeCacheForFile used to walk the index.html# alias prefix too, so a re-scan of comp.html could delete a keyframe row that a concurrent re-scan of another composition file had just written into index.html#id. Now only walks the file's own prefix — regression test at gsapKeyframeCacheHelpers.test.ts:87-97. Nice — the concurrent-rescan race would have been very hard to debug from telemetry alone.
    2. toClipKeyframes fell back to a fixed 1 when anim.duration was undefined, misplacing a duration-less tween's keyframes at 25% of a 4-second clip. Now falls back to clipDuration, matching resolveEditableTweenDuration. Regression test at gsapShared.test.ts:143-146.
    3. Type-tightens collectAnimatableKeyframeProperties(entry: object) and drops the as Record<string, unknown> casts at call sites.

I didn't find anything new to flag in the delta — the three commits are surgical and well-tested.

Foundational-slice lens

Per foundational-contract-deep-scrutiny — this is B1/6 (or whatever the M is) of a Family B stack targeting a Family A baseline. The primitives added here (KeyframeSlice, useStudioTestHooks, toClipPercentage, toClipKeyframes, elementCacheKeys) are the foundation the rest of the stack builds on. A defect that lives past R3 propagates through the entire stack. Looked specifically for:

  • Contract seams. KeyframeSlice.focusedEaseSegment has no readers at this SHA — declared here, consumed at the stack tip. Same shape for expandedClipIds (readers arrive at #2791). Adding state ahead of consumers is fine as long as the reset/lifecycle is fixed in the same slice that adds the consumers — which is what you're doing.
  • elementCacheKeys invariants. For sourceFile !== "index.html", writes go to [sourceFile#id, index.html#id, id]. For index.html, [index.html#id, id]. writeGsapAnimationsForElement, clearKeyframeCacheForElement, and the updateKeyframeCacheFromParsed write loop all go through this helper. clearKeyframeCacheForFile intentionally does NOT walk index.html# — that's the R3 concurrent-rescan fix. Reads (readKeyframeSnapshot and callers) all use buildCacheKey(sourceFile, elementId) = sourceFile#elementId — consistent with the writes.
  • Ambiguous-ease policy. easeAmbiguous is set on any same-% collision between different animationIds. Downstream lanes / diamond consumers must check it before using ease. The stack-tip deduplicateKeyframes fix (delete existing.ease when ambiguous) is the correct enforcement point, and it's the SAME deduplicateKeyframes that updateKeyframeCacheFromParsed now calls (thanks to the R1 fix in e34e529) — so at stack tip, the ambiguous-ease drop applies everywhere it should.

What I didn't verify

  • Stack tip's focusedEaseSegment consumers (PropertyPanelFlat, GsapAnimationSection, propertyPanelFlatMotionSection) — trusting that they arrive together with the reset fix at #2791.
  • The 2,910 Studio tests, 398 Studio Server tests you called out — trusting your local validation numbers; CI here shows the same jobs passing.
  • Whether other Family A PRs on main since R1 have changed anything in the seams I looked at — the merge-base bump to 755b68fb and the small delta stat suggest no.

All 30+ CI checks green at head. LGTM from my side on the delta; the deferral shape works as long as the Graphite stack merges as a unit.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at d05ecb10910e.

Verdict

COMMENTED. R1 and R2 fixes for in-diff concerns landed cleanly; the seven residual R2 findings deferred to the stack tip are verified fixed at 6ee750fee (PR #2791). Two nits from this pass, no blockers.

Scope re-verification

728 additions / 255 deletions across 21 files; no Co-Authored-By lines in the body; head d05ecb10910e. The slice publishes exactly the five contracts R1/R2 named — selectedKeyframes, expandedClipIds, focusedEaseSegment, keyframeCache, gsapAnimations — plus their setters. keyframeSlice.ts:53-108 is the new home; playerStore.ts:96,308 fold it in via interface PlayerState extends KeyframeSlice + ...createKeyframeSlice(set). Every previous inline definition is deleted, not shadowed.

R1 follow-up verification

  • #3 propertyGroup gate divergence — RESOLVED IN THIS PR. gsapKeyframeCacheHelpers.ts:31 (sourceAnimations.set(id, ...) unconditional) and useGsapTweenCache.ts:333-338 (animations.filter((a) => a.keyframes || synthesizeFlatTweenKeyframes(a))) both admit ungrouped tweens. Delta commit e34e529 on 2026-07-25 removes both if (anim.propertyGroup) gates.
  • #2 ambiguity-helper extraction — RESOLVED DIFFERENTLY (better). Rather than lifting a flagAmbiguousEase(existing, incoming) helper, Miguel made deduplicateKeyframes (gsapTweenSynth.ts:8-33) the sole implementer and had the second writer call it (gsapKeyframeCacheHelpers.ts:50). One owner instead of two conforming writers is the stronger consolidation.
  • #1 bulk selection primitive — DEFERRED WITH RATIONALE. PR body cites the unused-exports gate; verified no consumer in this PR's diff. Reasonable — the primitive can grow when B2-B8 lands the marquee.
  • #4 two disjoint selection-key shapes — DEFERRED TO #2787. PR body cites timelineKeyframeIdentity.ts JSON envelope. Not verified here, on the honor system for the stack.
  • #5 collapseClips — unchanged (parked 🟢).
  • #6 useStudioTest re-run guard — unchanged; ALSO Rames R2 #6; fixed at tip.
  • #7 Set/Map persist-hostile comment — unchanged (nit).

R2 follow-up verification (Rames' inline findings)

Every non-in-diff residual carries Miguel's "Addressed at stack tip in 6ee750fee (PR #2791)" reply. Verified each at that SHA:

  • 🟠 R2#1 focusedEaseSegment stranded across composition switchplayerStore.ts:534 at 6ee750fee includes focusedEaseSegment: null in reset(). Fixed.
  • 🟠 R2#2 updateKeyframeCacheFromParsed regex strips [id="…"] selectors6ee750fee adds idFromSelector inverse to gsapShared.ts and both regex sites (gsapKeyframeCacheHelpers.ts:21,64) call it. Fixed.
  • 🟡 R2#3 stale gsapAnimations never cleared when source emptyuseGsapTweenCache.ts:308-320 at 6ee750fee reaches clearKeyframeCacheForElement(sourceFile, elementId) on the allKeyframes.length === 0 branch, which nulls both stores. Fixed.
  • 🟡 R2#4 wipe valid gsapAnimations when propertyGroup missing — RESOLVED IN THIS PR by the propertyGroup gate removal (R1#3 fix). sourceAnimations.get(id) now always returns the anim array, so writeGsapAnimationsForElement(..., animations.get(id)) cannot pass undefined and delete a live entry.
  • 🟡 R2#5 iteration-order ease under easeAmbiguousgsapTweenSynth.ts:51-52 at 6ee750fee replaces if (kf.ease) existing.ease = kf.ease with if (existing.easeAmbiguous) delete existing.ease; else if (kf.ease) existing.ease = kf.ease;. Fixed.
  • 🟡 R2#6 useStudioTestHooks cleanup = undefined vs deleteuseStudioTestHooks.ts:60 at 6ee750fee uses delete window.__studioTest with a comment explaining why. Fixed.
  • 🟢 R2#7 setter allocates on no-op writeskeyframeSlice.ts:99-115 at 6ee750fee short-circuits with return state when incoming payload is identity-equal or an absent delete. Fixed.

Editor-UI lens set

  • Silent-catch / error-invariant — none. useStudioTestHooks:30-34 catches import.meta.env access but that's a bundler-portability guard, not a swallowed mutation error.
  • Commit semantics — every setter is single-dispatch; no interleavable multi-step state transitions.
  • Key stabilityselectedKeyframes keys are content-derived (element:pct / element:group:animation:clipPct), not array indices; keyframeCache keyed by sourceFile#elementId and bare elementId. Stable across re-orders.
  • ARIA / keyboard — n/a (state slice, no rendered surface). Downstack lanes own it.
  • Propagation — n/a (no DOM handlers).
  • Semantic-vs-symptom — R1#3 fix (propertyGroup gate) is semantic: writes agree on which tweens the two stores admit, not a per-render patch. R2#5 fix at tip likewise (delete existing.ease is honest about ambiguity, not a UI band-aid).
  • Sibling helpers / DRYtoClipPercentage + toClipKeyframes (gsapShared.ts:225-270) collapse three previously-open-coded call sites (gsapKeyframeCacheHelpers.ts:38, useGsapTweenCache.ts:363,414). Explicit answer to the R2 rounding-drift concern.
  • Parity audit — collapsed diamonds read keyframeCache; expanded property lanes read gsapAnimations. R1#3 + R2#4 fixes make the two agree on which tweens they record. Parity re-established.
  • Cross-mode (edit vs preview)useStudioTestHooks gated on import.meta.env.DEV; never installs in production. keyframeSlice.ts:89 initial is null, no mode-specific branch.
  • Perf audit — the setter no-op flap (R2#7) is real at this head, addressed at tip. Non-blocker in-PR: keyframeCache/gsapAnimations subscribers are the timeline lanes, which already virtualize.
  • PR-body-vs-diff — every claim in the body maps to a code change:
    • "property-group gate is gone from both writers" → gsapKeyframeCacheHelpers.ts:31, useGsapTweenCache.ts:333-338
    • "toClipPercentage now owns the rounding" → gsapShared.ts:225-233
    • "toClipKeyframes owns the whole row" → gsapShared.ts:241-270
    • "parsed write reuses elementCacheKeys" → gsapKeyframeCacheHelpers.ts:60
    • "window.__studioTest gated on import.meta.env.DEV, cleared on unmount" → useStudioTestHooks.ts:29-34,47-49
    • "Every blocker and high finding raised on this PR is fixed in the stack" → both 🟠 fixes verified at 6ee750fee
  • Cross-writer invariant (new axis) — see Non-blocking observations #1 below.

Standards + Spec + Precision + Round-trip

  • Standards (CONTRIBUTING.md §Type-safety): avoid as T, avoid ! non-null, prefer narrowing. Diff has one bare as T in a test fixture (see Standards lens re-run).
  • Spec bi-directional: every body bullet has a code anchor (above); every non-trivial code change has a body anchor. trackStudioKeyframeLaneExpand (events.ts:59-61) is added ahead of its caller (the lane-caret handler is downstack B2) — legitimate for a shared-primitive PR.
  • Sibling-precision divergence: toClipPercentage owns 0.001% (gsapShared.ts:232, Math.round(... * 100000) / 1000), and all three previously-open-coded call sites are converted to call it — the exact drift R2 named. Clean.
  • Middle-man wrap-unwrap: keyframeSlice.ts re-exports KeyframeCacheEntry; playerStore.ts:14 re-re-exports it. Type identity preserved, no serialization boundary in this PR.

Fix-internal remaining-silent-X audit (six categories, adapted for feature)

  • State-accumulation discardsetKeyframeCache / setGsapAnimations copy-on-write into a fresh Map; failure would raise (no swallow), leaving prior state intact.
  • Return-boundary invariant on the timeline's outer renderKeyframeCacheEntry.keyframes[] has no sortedness guarantee on the slice itself; sorting happens inside deduplicateKeyframes (gsapTweenSynth.ts:33). Consumers that read the entry directly (useGsapTweenCache.ts:363 merged path) rely on the writer having sorted. Contract implicit; documented via the deduplicateKeyframes return.
  • Library defaults on the failure axis — Zustand shallow-eq is Set/Map-identity based; every write creates a new reference, which is what triggers subscribers. Correct for this slice.
  • Precedence in overlap rulesdeduplicateKeyframes:29 if (kf.ease) existing.ease = kf.ease is iteration-order dependent when both eases exist and easeAmbiguous is not set (single-animation case). Rames' R2#5 catches the multi-animation case; single-animation is by definition non-conflicting so this is fine. Same logic in updateKeyframeCacheFromParsed after the R1#2 consolidation is now single-owner.
  • Session/resource ownershipuseStudioTestHooks.ts:47-49 returns a cleanup that reassigns undefined, keeping the key enumerable (Rames R2#6; fixed at tip via delete).
  • Discovery/enumeration completenessupdateKeyframeCacheFromParsed still uses ^#([\w-]+)/ (gsapKeyframeCacheHelpers.ts:21,64) as the reader inverse of idSelector — attribute-selector shape is not matched; Rames R2#2 catches this (🟠), fixed at tip via idFromSelector. In this PR alone, digit-leading ids don't get their cache refreshed post-commit.

Standards lens re-run

Mechanical grep on added lines only (from /tmp/pr2781.diff ^+ slice):

Pattern Count Notes
Bare as T (not as const, not as unknown as T) 1 gsapShared.test.ts:640 } as GsapAnimation; — test fixture. CONTRIBUTING.md §Type-safety asks for as unknown as GsapAnimation with a one-line comment, or Partial<GsapAnimation> if callers tolerate it. Test-file nit.
Non-null !. / ![ / !; 0
.message without instanceof 0
Angle-bracket cast <T> (excluding generics) 0

Tests

Coverage matches the added surface. New slice fields get direct-store tests (playerStore.test.ts:29-56 — toggle and idempotent set for expandedClipIds). toClipPercentage and toClipKeyframes both get unit tests (gsapShared.test.ts:82-153), including the zero-duration and duration-less clip edge cases the R2 rounding fix exists to cover. updateKeyframeCacheFromParsed gets a new test that seeds three keys and verifies clearKeyframeCacheForFile("comp.html") doesn't touch index.html#title/title (gsapKeyframeCacheHelpers.test.ts:84-100) — the exact scope-of-clear invariant Miguel introduced in the R2 fix commit. idSelector and deduplicateKeyframes generic (through animationId/easeAmbiguous) both have added tests. Undo/redo integration isn't yet applicable (the slice is a shared primitive; history integration is downstack).

Non-blocking observations

  1. sourceAnimations filter vs allKeyframes inline filter divergence. useGsapTweenCache.ts:333-338 admits an anim if animation.keyframes || synthesizeFlatTweenKeyframes(animation) is truthy; the allKeyframes loop at :366-375 additionally skips static position holds (x/y only, method === "set" || duration === 0). A .to("#el", { x: 100, duration: 0 }) — method "to", no immediateRender, x/y only — passes the first filter (because synthesizeFlatTweenKeyframes returns non-null for non-set methods) but is dropped by the second. Result: writeGsapAnimationsForElement writes it, then allKeyframes.length === 0 → clearKeyframeCacheForElement immediately drops it. Self-correcting within one tick, but a wasted write and a briefly-observable stale reference. 6ee750fee cleans this up by extracting isStaticPositionHold (gsapTweenSynth.ts:18-23) and applying it to both filters. Noting for the record; not blocking here.

  2. Test-file as GsapAnimation cast (gsapShared.test.ts:640). CONTRIBUTING.md §Type-safety asks for the honest form; suggest as unknown as GsapAnimation with a one-line comment ("test fixture — only the fields used by toClipKeyframes are relevant"), or bind the fixture through a Partial<GsapAnimation>-typed helper. Nit.

Peer state

Prior reviews: my R1 at e4d7bde (COMMENTED, 4 P2 / 3 P3), Rames' R2 at e4d7bde64f3b (COMMENTED, 7 inline findings with severities 🟠🟠🟡🟡🟡🟡🟢). Miguel replied to all 7 R2 inlines on 2026-07-27T14:48 with the identical "Addressed at stack tip in 6ee750fee" note — verified accurate at that SHA. CI is fully green at head: Test, Typecheck, Lint, Format, Fallow audit, File size check, CLI smoke (required), Perf: drift/fps/load/parity/scrub, Preview parity, Preflight, Studio: load smoke, Producer: integration/unit, Render on windows-latest, Tests on windows-latest, all shard runners — pass. Mergeable state blocked reflects stack-order coordination, not a defective PR.

Stamped. (COMMENTED — the two 🟠 R2 findings remain at this PR's head; they land fixed at the stack tip, so this PR should merge as part of the stack, not standalone. If Graphite orchestrates the stack merge atomically, the effect on main is clean.)

Review by Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Both R3 nits handled, and I agree on the merge-as-a-unit point.

Nit 2 (bare as GsapAnimation in a test fixture) — fixed in 288815e1a at the stack tip. packages/studio/src/hooks/gsapShared.test.ts now uses as unknown as T with the one-line justification CONTRIBUTING.md asks for, and the two inline { duration } as GsapAnimation casts collapse into a single tween() fixture helper so there is one cast instead of three. The as DomEditSelection in the same describe block got the same treatment.

Nit 1 (sourceAnimations vs the inline allKeyframes filter) — confirmed closed at tip, not just claimed: isStaticPositionHold is defined once in gsapTweenSynth.ts:18 and is the only hold-skip predicate at both sites (useGsapTweenCache.ts:260 and :287), plus keyframeCacheAstLoad.ts:156. No second open-coded variant remains.

While making the fixture change the fallow complexity gate flagged three functions on this branch, so 288815e1a splits each at its natural seam rather than suppressing them:

  • useAutoExpandKeyframedClips — the prune-and-scan moves out of the effect into freshLaneClips.
  • nodeMatchesManifestClip — the four repeated "parse attribute, compare if finite" guards collapse into one table-driven check. Behaviour is unchanged; data-track-index now reads through the same parseFloat + tolerance path as the other two instead of parseInt + !==, which agrees on every integer index.
  • onPathDown — the segment-percentage interpolation moves out into interpolatedKeyframePct.

Verified at 288815e1a: bun run --cwd packages/studio typecheck clean, bunx oxlint packages/studio/src 0 warnings / 0 errors, bunx fallow audit complexity gate green, and bun run --cwd packages/studio test 2916 passed / 18 todo across 260 files.

On the merge shape: agreed, this is a stack and #2781 should not go in standalone. Nothing here is being merged yet.

…tack tip

The R1/R3 residuals on this PR were fixed at the top of the stack, so they
only cleared once every branch above landed. They belong here, next to the
code they correct:

- `idFromSelector` inverts `idSelector` for both regex readers, so the
  post-commit cache refresh stops skipping the CSS-unsafe ids `idSelector`
  exists to support.
- `deduplicateKeyframes` drops `ease` when it is ambiguous; the flag was the
  only honest answer and the last-writer-wins curve belonged to an arbitrary
  colliding tween.
- `isStaticPositionHold` is now the single owner of the hold skip. The
  `sourceAnimations` filter and the `allKeyframes` filter had diverged on
  whether `immediateRender` counts as a property.
- The keyframe-cache setters no-op when the write changes nothing, instead of
  handing every subscriber a fresh Map.
- `reset()` clears `focusedEaseSegment`.
- The test hook `delete`s its window key rather than setting it to undefined,
  so feature detection still works.
- The `toClipKeyframes` fixture uses `as unknown as T` with the justification
  CONTRIBUTING.md asks for.
Dragging the playhead to the start of the composition needed a very slow
drag. The scrub surface begins GUTTER + TRACKS_LEFT_PAD px right of the
viewport edge, and both scrub paths bailed out when the pointer sat left of
that origin rather than clamping. So the last 80px of the drag toward zero
silently did nothing: the playhead stuck at whatever the last in-range sample
reported, and only a drag slow enough to sample inside the thin sliver before
the origin ever reached 0.

Both paths now share getTimelineScrubTime, which clamps to [0, duration]. One
owner, so the live-feedback path and the committed-seek path cannot disagree
about the edge again.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed delta d05ecb109..b386b55f7 at commit b386b55f7.

The R3-residual hoist (3c7400af8) is a clean consolidation — every R1/R3 residual I flagged as "will land at stack tip" now closes here, so this slice stops depending on the rest of Family B landing. Verdicts against my R3 findings:

R3 # R3 finding Status at b386b55f7
F#1 reset() didn't clear focusedEaseSegment ✅ FIXED — playerStore.ts:534 focusedEaseSegment: null, inside the reset block
F#2 Post-commit cache updater used raw /^#([\w-]+)/ regex, dropping CSS-unsafe ids ✅ FIXED — new idFromSelector in gsapShared.ts:92-100 inverts both #id and [id="..."] shapes; both readers in gsapKeyframeCacheHelpers.ts (lines 21, 63) go through it; round-trip test at gsapShared.test.ts:158-164 covers the CSS-unsafe id classes (1box, 1"x, my.class, 01-hook-...)
F#3 sourceAnimations filter and inline allKeyframes filter disagreed on immediateRender ✅ FIXED — single owner isStaticPositionHold in gsapTweenSynth.ts:8-22 filters out immediateRender before checking property surface; both callsites in useGsapTweenCache.ts (lines 364, 465) delegate to it
F#5 deduplicateKeyframes last-writer-wins on colliding ease ✅ FIXED — gsapTweenSynth.ts:45-52 now if (existing.easeAmbiguous) delete existing.ease; else if (kf.ease) existing.ease = kf.ease; — ambiguous means "no single ease" instead of "arbitrary one of the colliding curves"
F#6 Test hook set to undefined instead of delete ✅ FIXED — useStudioTestHooks.ts:48-50 uses delete, so "__studioTest" in window no longer stays true post-unmount
F#7 KeyframeSlice setters emitted new Map even on no-op ✅ FIXED — keyframeSlice.ts:95-97 + keyframeSlice.ts:110-115 short-circuit on state.keyframeCache.get(elementId) === data (or !has(elementId) for the null-clear case) before allocating a fresh Map

Plus the CONTRIBUTING.md-justified as unknown as T on toClipKeyframes fixture with the explanatory comment (gsapShared.test.ts:129-133) — matches the guidance from [[code-review-max-lens-gaps]].

The scrub-clamp commit (b386b55f7) is a clean fix for a real UX bug: dragging left of the scrub origin (GUTTER + TRACKS_LEFT_PAD) used to bail out instead of clamping, so the last ~80px of the drag toward t=0 silently no-op'd. Both scrub paths (useTimelinePlayhead.ts:134-140, useTimelineRangeSelection.ts:289-297) now go through getTimelineScrubTime which clamps to [0, duration] and guards !(pps > 0) + !Number.isFinite(duration) to 0. Tests at timelineLayout.test.ts:110-150 cover the origin, left-of-origin, past-end, degenerate-zoom, and NaN-duration paths. Nothing else calls the old inline formula (grepped packages/studio for - GUTTER - TRACKS_LEFT_PAD — only these two callsites).

Concerns

  • 🟡 Sibling extractIdFromSelector in useGsapTweenCache.ts:14-17 still uses the raw /^#([\w-]+)/ regex — the same anti-pattern R3-F#2 replaced elsewhere. It's called from resolveSelectorElementIds at line 39 (no-DOM fallback) and line 51 (querySelectorAll-throw fallback), so the impact is narrow: with a live preview iframe the DOM path resolves [id="..."] correctly, and only pre-iframe cache populate + invalid-selector recovery hit the raw regex. Still: exactly the same shape the R3 hoist just fixed, in the file family that already imports gsapShared. Suggest import { idFromSelector } from "./gsapShared", delete the local helper, and route both fallback sites through the shared reader. Same call for useGsapKeyframeOps.ts:341 (selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null) — the short-circuit on selection.id masks it in the common case but the fallback is still a raw-regex sibling.

What I didn't verify

  • Whether the useGsapTweenCache.ts:32 bare-id fast-path (selector.match(/^#([\w-]+)$/)) needs symmetric handling. It's anchored ^…$ so [id="..."] correctly falls through to the DOM path — the fast-path is a performance optimization, not a correctness path, so this one likely doesn't matter.

Unrelated CI note

The failing Tests on windows-latest check is packages/engine/src/services/audioMixer.test.ts > processCompositionAudio > keeps the ffmpeg command line short with a large track count (regression for spawn ENAMETOOLONG). HF#2781's diff is entirely inside packages/studio/*, so this can't be a regression from this PR — looks like Windows-only flakiness on the engine's ffmpeg-command-length regression test that landed in an earlier PR (last touched 37b88688e "fix(audio): preserve causes and use portable padding" by James in #2769). Worth checking main at the same SHA to confirm preexisting rather than debugging here.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at b386b55f735a.

Verdict

APPROVE. Both R2 🟠 residuals I held on at d05ecb109 are now closed inside this PR itself — no more depending on stack-tip landings. The second delta commit (b386b55f7, scrub-clamp) is a clean net-add with adequate test coverage. Non-blocker CI failure on Tests on windows-latest is packages/engine/src/services/audioMixer.test.ts (5s test timeout on the ffmpeg-command-length regression) and this PR touches nothing under packages/engine.

Delta since R2 (d05ecb109b386b55f7)

Two commits, ahead_by: 2, behind_by: 0 — no rebase, so no new-base regression surface.

SHA Subject Scope
3c7400af8 fix(studio): close the review findings in this PR instead of at the stack tip Hoists the 6 fixes from #2791's tip down into this PR (KeyframeSlice + gsapShared + tests)
b386b55f7 fix(studio): clamp the timeline scrub to 0 instead of dropping it New scrub-clamp helper + 2 caller updates + test file

R2 🟠 residuals — resolution status at this head

Both closed in 3c7400af8.

  • focusedEaseSegment reset omissionplayerStore.ts:534 now includes focusedEaseSegment: null, inside the reset() block (lines 520-546). The reset happens on composition switch and clears every stack tip's writes uniformly. No other keyframe-mutation path allocates state that needs a symmetric clear.
  • Post-commit id-regex reader unsafe for CSS-hostile ids — the two id extractors in gsapKeyframeCacheHelpers.ts (lines 21, 63) now go through idFromSelector (gsapShared.ts:94-102), which inverts BOTH the #id shape and the [id="…"] attribute-selector shape that idSelector emits for digit-leading / dotted / spaced ids. Round-trip test at gsapShared.test.ts:85-112 covers the CSS-unsafe id classes explicitly (01-hook-hero-word, my.class, 1box, 1"x).

R2 non-blocker observations — status

  • sourceAnimations vs allKeyframes filter divergence on static holdsisStaticPositionHold (gsapTweenSynth.ts:18-23) is now the single owner. It filters immediateRender out of the property surface before checking. Both callsites in useGsapTweenCache.ts (lines 364, 465) delegate to it — no inline filter left to drift.
  • Bare as GsapAnimation in test fixturegsapShared.test.ts:132-138 now uses as unknown as GsapAnimation with the CONTRIBUTING.md-justification comment at lines 128-131 (// Fixture carries only the fields the function under test reads; the double-cast is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).).

Byte-clean audit on prior-audited surface

Every file I audited at d05ecb109 changed in 3c7400af8 and was re-read at b386b55f7:

File Delta @ new head Notes
keyframeSlice.ts +13 No-op guards on setKeyframeCache (lines 98-105) and setGsapAnimations (lines 110-119) — bonus fix beyond the deferred residuals
playerStore.ts +1 focusedEaseSegment: null, in reset() (line 534)
gsapShared.ts +21 New idFromSelector export (lines 94-102)
gsapShared.test.ts +20/-2 Round-trip tests + fixture cast fix
gsapTweenSynth.ts +25/-1 isStaticPositionHold extracted (lines 18-23) + deduplicateKeyframes ease-drop (lines 45-52)
useGsapTweenCache.ts +7/-23 Callers moved to isStaticPositionHold
useStudioTestHooks.ts +3/-1 delete instead of undefined (line 50)
gsapKeyframeCacheHelpers.ts +3/-4 Callers moved to idFromSelector

Adversarial audit on b386b55f7 (scrub-clamp)

Adversarial six-category on the delta:

  1. State discard / clamp semantics. getTimelineScrubTime (timelineLayout.ts:334-345): Math.max(0, Math.min(duration, x / pixelsPerSecond)). Guards !(pixelsPerSecond > 0) and !Number.isFinite(duration) to 0 — the two degenerate cases the pre-fix inline code punted on. Tests at timelineLayout.test.ts:110-151 cover origin, left-of-origin, past-end, pps=0, and NaN duration.
  2. Return invariant. Return type number, never null or NaN — every branch returns a finite number in [0, duration]. Callers can no longer decide to bail; the helper always produces a scrub target.
  3. Library-default / silent-catch. No try/catch, no default-arg surprises. duration is required.
  4. Precedence — arithmetic bug audit. clientX - viewportLeft + scrollLeft - GUTTER - TRACKS_LEFT_PAD matches the pre-fix inline form in both callers verbatim (grep confirms — the fix is a literal extract). Signs correct: content-space x = viewport-relative pointer + scroll offset - gutters.
  5. Session / lifecycle ownership. Callers own the RAF batching (updateScrubDrag's seekRafRef, seekFromX's direct notify). The helper is pure — no side effects, no captured refs — so useCallback deps in both callers remain minimal.
  6. Discovery completeness — parity across scrub surfaces. Grepped packages/studio for the raw form - GUTTER - TRACKS_LEFT_PAD — hits only the two shift-drag range-selection sites (useTimelineRangeSelection.ts:232, 318), which are a different gesture (Shift+drag time range) and intentionally use Math.max(0, x/pps) without an upper clamp. BeatStrip.tsx scrubs beat time (Math.max(0, t)), also intentionally unclamped upward. So the "cannot disagree about the edge" invariant holds for the pair Miguel names.

Standards lens re-run

  • CONTRIBUTING.md guidance (test-fixture double-cast): honored at gsapShared.test.ts:129-131.
  • Fallow / complexity: no new suppressions; existing fallow-ignore-next-line complexity on synthesizeFlatTweenKeyframes (line 60) is untouched.
  • No barrel files: the extract adds named exports directly; no new index files.
  • File-size: useGsapTweenCache.ts net −16, gsapTweenSynth.ts +25 — both stay under the 600-line ceiling the header cites.

Empty count aside, no Standards-lens violations in the delta.

Concerns

  • 🟡 Sibling extractIdFromSelector in useGsapTweenCache.ts:18-21 still uses the raw /^#([\w-]+)/ regex — same anti-pattern R2-F2 replaced elsewhere, in a file that already imports from gsapShared. Called from resolveSelectorElementIds at lines 39 (no-DOM fallback) and 51 (querySelectorAll-throw fallback). Impact is narrow: with a live iframe the DOM path resolves [id="…"] correctly, and only pre-iframe cache populate + invalid-selector recovery hit the raw regex. Rames independently flagged the same at b386b55f7 (n=2 lens convergence). Follow-up: import { idFromSelector } from "./gsapShared", delete the local helper. Pre-existing on main — not a regression from this PR, not blocking.

Peer state

  • Rames reviewed at b386b55f7 (COMMENTED, submitted 2026-07-27T17:48:42Z). Verdict table converges with mine on all six R3-numbered fixes and on the scrub-clamp audit. The 🟡 sibling extractIdFromSelector concern above matches his findings.
  • No CODEOWNER block, no CHANGES_REQUESTED review.

CI

  • 39 of 40 checks pass at this head. Tests on windows-latest (job 90053346540) fails on packages/engine/src/services/audioMixer.test.ts:626 > processCompositionAudio > keeps the ffmpeg command line short with a large track count: Test timed out in 5000ms. This PR's diff is entirely inside packages/studio/* — zero causal relationship to a packages/engine ffmpeg-spawn perf test. Prior head (d05ecb109) passed the same Windows job (job 90002783496). Classic Windows-runner flake, not a regression. main is unprotected; Graphite mergeability check passed.

Envelope hygiene

  • No Co-Authored-By lines in the PR body or in either new commit's message.
  • Both new commits authored + committed by miguel.sierra@heygen.com.
  • Scope of the delta stays inside packages/studio/** — no cross-package sprawl.

Stamped.

Review by Via

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at b386b55f735a.

Verdict

APPROVE. Both R2 🟠 residuals I held on at d05ecb109 are now closed inside this PR itself — no more depending on stack-tip landings. The second delta commit (b386b55f7, scrub-clamp) is a clean net-add with adequate test coverage. Non-blocker CI failure on Tests on windows-latest is packages/engine/src/services/audioMixer.test.ts (5s test timeout on the ffmpeg-command-length regression) and this PR touches nothing under packages/engine.

Delta since R2 (d05ecb109b386b55f7)

Two commits, ahead_by: 2, behind_by: 0 — no rebase, so no new-base regression surface.

SHA Subject Scope
3c7400af8 fix(studio): close the review findings in this PR instead of at the stack tip Hoists the 6 fixes from #2791's tip down into this PR (KeyframeSlice + gsapShared + tests)
b386b55f7 fix(studio): clamp the timeline scrub to 0 instead of dropping it New scrub-clamp helper + 2 caller updates + test file

R2 🟠 residuals — resolution status at this head

Both closed in 3c7400af8.

  • focusedEaseSegment reset omissionplayerStore.ts:534 now includes focusedEaseSegment: null, inside the reset() block (lines 520-546). The reset happens on composition switch and clears every stack tip's writes uniformly. No other keyframe-mutation path allocates state that needs a symmetric clear.
  • Post-commit id-regex reader unsafe for CSS-hostile ids — the two id extractors in gsapKeyframeCacheHelpers.ts (lines 21, 63) now go through idFromSelector (gsapShared.ts:94-102), which inverts BOTH the #id shape and the [id="…"] attribute-selector shape that idSelector emits for digit-leading / dotted / spaced ids. Round-trip test at gsapShared.test.ts:85-112 covers the CSS-unsafe id classes explicitly (01-hook-hero-word, my.class, 1box, 1"x).

R2 non-blocker observations — status

  • sourceAnimations vs allKeyframes filter divergence on static holdsisStaticPositionHold (gsapTweenSynth.ts:18-23) is now the single owner. It filters immediateRender out of the property surface before checking. Both callsites in useGsapTweenCache.ts (lines 364, 465) delegate to it — no inline filter left to drift.
  • Bare as GsapAnimation in test fixturegsapShared.test.ts:132-138 now uses as unknown as GsapAnimation with the CONTRIBUTING.md-justification comment at lines 128-131 (// Fixture carries only the fields the function under test reads; the double-cast is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).).

Byte-clean audit on prior-audited surface

Every file I audited at d05ecb109 changed in 3c7400af8 and was re-read at b386b55f7:

File Delta @ new head Notes
keyframeSlice.ts +13 No-op guards on setKeyframeCache (lines 98-105) and setGsapAnimations (lines 110-119) — bonus fix beyond the deferred residuals
playerStore.ts +1 focusedEaseSegment: null, in reset() (line 534)
gsapShared.ts +21 New idFromSelector export (lines 94-102)
gsapShared.test.ts +20/-2 Round-trip tests + fixture cast fix
gsapTweenSynth.ts +25/-1 isStaticPositionHold extracted (lines 18-23) + deduplicateKeyframes ease-drop (lines 45-52)
useGsapTweenCache.ts +7/-23 Callers moved to isStaticPositionHold
useStudioTestHooks.ts +3/-1 delete instead of undefined (line 50)
gsapKeyframeCacheHelpers.ts +3/-4 Callers moved to idFromSelector

Adversarial audit on b386b55f7 (scrub-clamp)

Adversarial six-category on the delta:

  1. State discard / clamp semantics. getTimelineScrubTime (timelineLayout.ts:334-345): Math.max(0, Math.min(duration, x / pixelsPerSecond)). Guards !(pixelsPerSecond > 0) and !Number.isFinite(duration) to 0 — the two degenerate cases the pre-fix inline code punted on. Tests at timelineLayout.test.ts:110-151 cover origin, left-of-origin, past-end, pps=0, and NaN duration.
  2. Return invariant. Return type number, never null or NaN — every branch returns a finite number in [0, duration]. Callers can no longer decide to bail; the helper always produces a scrub target.
  3. Library-default / silent-catch. No try/catch, no default-arg surprises. duration is required.
  4. Precedence — arithmetic bug audit. clientX - viewportLeft + scrollLeft - GUTTER - TRACKS_LEFT_PAD matches the pre-fix inline form in both callers verbatim (grep confirms — the fix is a literal extract). Signs correct: content-space x = viewport-relative pointer + scroll offset - gutters.
  5. Session / lifecycle ownership. Callers own the RAF batching (updateScrubDrag's seekRafRef, seekFromX's direct notify). The helper is pure — no side effects, no captured refs — so useCallback deps in both callers remain minimal.
  6. Discovery completeness — parity across scrub surfaces. Grepped packages/studio for the raw form - GUTTER - TRACKS_LEFT_PAD — hits only the two shift-drag range-selection sites (useTimelineRangeSelection.ts:232, 318), which are a different gesture (Shift+drag time range) and intentionally use Math.max(0, x/pps) without an upper clamp. BeatStrip.tsx scrubs beat time (Math.max(0, t)), also intentionally unclamped upward. So the "cannot disagree about the edge" invariant holds for the pair Miguel names.

Standards lens re-run

  • CONTRIBUTING.md guidance (test-fixture double-cast): honored at gsapShared.test.ts:129-131.
  • Fallow / complexity: no new suppressions; existing fallow-ignore-next-line complexity on synthesizeFlatTweenKeyframes (line 60) is untouched.
  • No barrel files: the extract adds named exports directly; no new index files.
  • File-size: useGsapTweenCache.ts net −16, gsapTweenSynth.ts +25 — both stay under the 600-line ceiling the header cites.

Empty count aside, no Standards-lens violations in the delta.

Concerns

  • 🟡 Sibling extractIdFromSelector in useGsapTweenCache.ts:18-21 still uses the raw /^#([\w-]+)/ regex — same anti-pattern R2-F2 replaced elsewhere, in a file that already imports from gsapShared. Called from resolveSelectorElementIds at lines 39 (no-DOM fallback) and 51 (querySelectorAll-throw fallback). Impact is narrow: with a live iframe the DOM path resolves [id="…"] correctly, and only pre-iframe cache populate + invalid-selector recovery hit the raw regex. Rames independently flagged the same at b386b55f7 (n=2 lens convergence). Follow-up: import { idFromSelector } from "./gsapShared", delete the local helper. Pre-existing on main — not a regression from this PR, not blocking.

Peer state

  • Rames reviewed at b386b55f7 (COMMENTED, submitted 2026-07-27T17:48:42Z). Verdict table converges with mine on all six R3-numbered fixes and on the scrub-clamp audit. The 🟡 sibling extractIdFromSelector concern above matches his findings.
  • No CODEOWNER block, no CHANGES_REQUESTED review.

CI

  • 39 of 40 checks pass at this head. Tests on windows-latest (job 90053346540) fails on packages/engine/src/services/audioMixer.test.ts:626 > processCompositionAudio > keeps the ffmpeg command line short with a large track count: Test timed out in 5000ms. This PR's diff is entirely inside packages/studio/* — zero causal relationship to a packages/engine ffmpeg-spawn perf test. Prior head (d05ecb109) passed the same Windows job (job 90002783496). Classic Windows-runner flake, not a regression. main is unprotected; Graphite mergeability check passed.

Envelope hygiene

  • No Co-Authored-By lines in the PR body or in either new commit's message.
  • Both new commits authored + committed by miguel.sierra@heygen.com.
  • Scope of the delta stays inside packages/studio/** — no cross-package sprawl.

Stamped.

Review by Via

The local extractIdFromSelector duplicated the `#id`-only regex that
idFromSelector replaced, so both DOM-less paths in
resolveSelectorElementIds (no-iframe fallback and querySelectorAll-throw
recovery) read no id at all for the bracketed `[id="..."]` form writers
emit for CSS-unsafe ids. Deleted the duplicate and imported the shared
reader; both forms now resolve.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Consolidated in 521bba64.

extractIdFromSelector is deleted; resolveSelectorElementIds now calls the shared idFromSelector on both DOM-less paths (no-iframe fallback and querySelectorAll-throw recovery). Narrow impact as you called it, but not zero: the old #id-only regex read no id at all for the [id="..."] form the writers emit for CSS-unsafe ids (digit-leading, dotted), so those elements dropped out of the cache entirely on both paths instead of falling back. Two tests added in useGsapTweenCache.test.ts pinning each path with a bracketed id.

The #id-only fast path at the top is left alone on purpose: it is an exact-match shortcut, and a bracketed selector now resolves correctly through the DOM query (when there is a document) or through idFromSelector (when there is not).

On the scrub clamp, verified against a live studio rather than only in unit tests. Driving the real timeline in a browser, pre-fix vs post-fix on the same three gestures:

gesture before after
fast drag ending 60px left of the content origin 3.699s 0
single jump from +300px to -500px 11.889s 0
press 40px left of the origin (playhead at 15.852s) 15.852s 0

That last row is the reported symptom exactly: the press was ignored, not clamped, so only a slow drag that happened to sample inside the 80px sliver ever landed on 0.

@miguel-heygen
miguel-heygen merged commit 521bba6 into main Jul 28, 2026
58 checks passed
@miguel-heygen
miguel-heygen deleted the codex/studio-timeline-b-keyframe-state-v2 branch July 28, 2026 00:37
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.

3 participants