Skip to content

feat(studio): bulk-edit easing for merged keyframes - #2693

Merged
miguel-heygen merged 2 commits into
mainfrom
codex/studio-timeline-c-bulk-easing-v2
Jul 29, 2026
Merged

feat(studio): bulk-edit easing for merged keyframes#2693
miguel-heygen merged 2 commits into
mainfrom
codex/studio-timeline-c-bulk-easing-v2

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Apply ease edits to every authored animation represented by a merged timeline keyframe.

Why

When colliding keyframes render as one diamond, changing its easing must update the complete selected semantic target. Updating only one hidden animation makes the inspector and timeline disagree and can silently leave mixed easing behind.

How

  • Expand merged-diamond ease operations to the full set of exact animation identities.
  • Reuse the exact targeting model introduced by C1.
  • Preserve the existing connector geometry, hit area, and pointer lifecycle while making inline ease controls operate on all colliding animations.

This is C2 of the independent Family C draft Graphite stack.

Test plan

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

Validated on the exact Family C tip with 94 focused tests, the full Studio suite (2,885 passed; 18 todos), Studio Server files tests (67 passed), both package typechecks, oxfmt, oxlint, diff checks, file-size gates, and Fallow with zero introduced findings.

miguel-heygen commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from 0741937 to 463429b Compare July 27, 2026 20:51
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-exact-targeting-v2 branch 2 times, most recently from 1c92c81 to 2739a21 Compare July 28, 2026 15:03
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from 463429b to 4802f0e Compare July 28, 2026 15:04
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-exact-targeting-v2 branch from 2739a21 to c02a44c Compare July 28, 2026 20:31
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from 4802f0e to 8f5f4d2 Compare July 28, 2026 20:32
@miguel-heygen
miguel-heygen marked this pull request as ready for review July 28, 2026 21:19

@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 8f5f4d2.

Feature completes what C1 laid the foundation for — clean plumbing from the collision list (collidingAnimationTargets on the focused segment) through AnimationCardEaseCurveSection → the new handleUpdateSegmentEase → batched update-keyframe commits. Test coverage across the seams is thorough (AnimationCard bulk vs. single branching, EaseCurveSection copy, GsapAnimationSection element-scoping, propertyPanelFlatMotionSection forwarding, useDomEditSession batch/single/empty).

No blockers. Three inline comments below, plus a question and one observation.

Question — is commitMutation.batch(calls, options) atomic?

The bulk-edit fans out N update-keyframe mutations for the same file. useGsapScriptCommits.ts:391-397 shows .batch calls runBatchCommit(activeProjectId, activeCompPath, file, calls, options) through the file serializer — so the file lock is held across all N. But is runBatchCommit itself all-or-nothing (single read-modify-write of the source with all N patches applied atomically), or does it iterate N sub-writes under one lock? If the latter, a mid-batch failure leaves the file with some colliding tweens updated to the new ease and others still on the old — silent divergence, and the toast-less path (see observation) means the user wouldn't know. I didn't chase this into runBatchCommit; would appreciate a confirmation.

Observation — bulk-edit takes the raw commitMutation, not commitMutationSafely.

useDomEditSession.ts receives commitMutation (raw) from useGsapScriptCommits. Both the single-target and multi-target branches call the raw one, so on save failure there's no showToast(\"Couldn't save animation: …\") + telemetry flow — same as the pre-PR single-edit path, so not a regression, but the bulk path amplifies the blast radius (one failed save = N tweens silently unchanged). If atomicity above is guaranteed, this is minor; if not, worth routing bulk through the safe wrapper.

What I didn't verify:

  • The updateGsapMeta path for flat tweens (onUpdateMeta({ ease }) at AnimationCard) — tested for the flat case in isolation but I didn't trace whether the collision-detection logic in C1 would ever produce a focusedSegment for a flat animation. Flat tweens don't go through deduplicateKeyframes, so their collidingAnimationTargets should always be undefined; the test at AnimationCard.test.tsx line ~236 asserts this indirectly by expecting onUpdateMeta to fire alone. Should be fine.

Review by Rames D Jusso

Comment thread packages/studio/src/components/editor/EaseCurveSection.tsx Outdated
Comment thread packages/studio/src/hooks/useDomEditSession.ts Outdated
Comment thread packages/studio/src/components/editor/GsapAnimationSection.tsx

@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 review — via

Grade: B+
Overall: CORRECT

Thesis check: The diff delivers what the title says. C2 wires the collidingAnimationTargets payload C1 already accumulates through focusedEaseSegment into AnimationCard, and routes the ease commit through a new onUpdateSegmentEase bulk callback whose backend is a single gsapCommitMutation.batch(...) producing one atomic undo entry labeled "Update segment ease". The discriminator focusedCollidingAnimationTargets.length > 1 is safe because accumulateCollidingAnimationTargets (in gsapTweenSynth.ts) only populates the array with [primary, …others] on the first collision, so length is either 0/undefined (no collision, single-id path) or ≥2 (merged, bulk path). Single-target callers (handleUpdateKeyframeEase) now flow through the same handleUpdateSegmentEase helper, collapse via calls.length === 1 to the identical prior gsapCommitMutation(sel, mut, {label: "Update keyframe ease", softReload: true}) invocation — no regression in the single-keyframe path.

P0/P1 findings: none.

P2/P3 findings:

  • [P2] packages/studio/src/components/editor/gsapAnimationCallbacks.ts:127onUpdateSegmentEase is passed through withTrackedGsapAnimationCallbacks un-wrapped (raw callbacks.onUpdateSegmentEase), while its peers onUpdateKeyframeEase (line 142) and onSetAllKeyframeEases (line 148) both wrap with track("select", "Keyframe ease") / track("select", "All keyframe eases"). Telemetry gap on the parallel branch: users bulk-editing merged easing skip the design-input tracking channel. trackStudioSegmentEaseEdit({action:"commit",ease}) still fires from AnimationCard.tsx:315, so there's some signal — but the design-input funnel goes dark for exactly the new affordance.
    • Failure scenario: PM measures "keyframe ease selects per session" on the design-input dashboard, sees adoption of the merged-easing feature look artificially low because every multi-property commit is invisible.
  • [P2] packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx:743usePlayerStore.getState().reset(); at module top level (outside afterEach/beforeEach). Contrast the sibling GsapAnimationSection.test.tsx:487 where reset() is correctly inside afterEach. Module-level reset runs once at import; if a prior file in the same worker mutated the store, the added "flat tween bulk segment" test starts with dirty state and can flake.
    • Failure scenario: parallel test worker reorders → useDomEditSession.test.tsx's bulk-segment test leaves focusedEaseSegment non-null → this file's setState({focusedEaseSegment: ...}) still sets its own, but any incidental state (e.g., selectedElementId) leaks into renderInto's FlatMotionSection and changes card mount order.
  • [P2] packages/studio/src/components/editor/PropertyPanel.tsx:560 vs packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx:146 — elementId derivation is duplicated but not identical: PropertyPanel uses `${element.sourceFile || "index.html"}#${element.id}` (with a fallback), while the flat panel uses `${element.sourceFile}#${element.id}` (no fallback). DomEditSelection.sourceFile is typed string (required), but empty string is not excluded by the type. If any resolver ever emits an empty sourceFile, PropertyPanel routes focus correctly ("index.html#hero") while FlatMotionSection routes to "#hero" — which will never match the writer's "index.html#hero" payload, and merged-easing silently disables in flat mode. Extract to a shared renderedElementIdOf(selection) helper so the two branches can't drift.

Nits:

  • packages/studio/src/hooks/useDomEditSession.ts — the deletion of the section comment banners (// ── Types ──, // ── Hook ──, // ── Selection ──, // ── Agent modal ──, // ── Preview interaction ──, // ── GSAP-aware geometry intercepts + animated property commit ──) is unnecessary churn in a feature PR — those banners were navigation aids in a 500-line hook. Consider reverting the cosmetic-only deletions.
  • packages/studio/src/hooks/useDomEditSession.ts:483-486if (calls.length === 1) { const call = calls[0]; if (call) void gsapCommitMutation(...); return; }. The if (call) guard is dead: length-check guarantees calls[0] is defined. Harmless but signals uncertainty about invariants that TS already proves.
  • packages/studio/src/hooks/useDomEditSession.ts:487void gsapCommitMutation.batch?.(calls, options). The optional chain is a silent-fail guard for a method that useGsapScriptCommits unconditionally assigns (line 391 of that file). If .batch were ever undefined at runtime, the batch commit silently does nothing (no error, no toast). Prefer removing the ?. and letting TypeScript enforce presence via the CommitMutation type.
  • packages/studio/src/components/editor/AnimationCard.tsx:62-64focusedCollidingAnimationTargets is shadow state derived from focusedSegment.collidingAnimationTargets. Since it's only cleared on onToggle (and manual toggle-vs-refocus is a per-render decision), consider deriving via useMemo from focusedSegment + expandedKfPct instead of duplicating into a separate useState. Minor.

Positive callouts:

  • Single-target path collapses through the same helper (handleUpdateSegmentEase([{animationId, tweenPercentage}], ease)) — same undo label, same mutation shape, same softReload. No hidden divergence between the "single-keyframe-ease" and "bulk-segment-ease" flows.
  • Undo/redo entries are per-batch (runBatchCommit → finalizeSuccessfulMutation fires once with the last call's selection/mutation) — a bulk edit undoes as one operation, which matches user intent.
  • The elementId filter on GsapAnimationSection (line 60-61 diff) closes the shared-animation-id bug where a class-selector tween would light up cards for every element carrying that class. Test at GsapAnimationSection.test.tsx:530 explicitly covers this.
  • EaseCurveSection's "Applies to N properties" hint only shows when collidingAnimationTargets.length > 1 (line 425-429 of EaseCurveSection.tsx), matching the bulk-dispatch discriminator exactly. Signal and action are consistent.
  • Test coverage is thorough: focus consumption per-element, bulk vs single dispatch, flat-tween meta path, single-id colliding fallback, empty-target guard, and the batch call shape (arguments passed to .batch() verified verbatim).

Sweep-fix breadth check:

  • Peer sites grepped for ease writers in packages/studio/src/: onUpdateKeyframeEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases, onUpdateMeta({ease}), onUpdateMeta({easeEach}).
  • Peer sites covered by PR: keyframed animation ease commit via KeyframeEaseList → EaseCurveSection (bulk-aware); wiring plumbed through StudioRightPanel.tsx, DomEditContext.tsx, PropertyPanel.tsx, PropertyPanelFlat.tsx, GsapAnimationSection.tsx, and AnimationCard.tsx.
  • Peer sites MISSED: flat-tween ease (AnimationCard.tsx:325-348, the SelectField/EaseCurveSection branch that fires onUpdateMeta({ease})/onUpdateMeta({easeEach})) does not consume focusedCollidingAnimationTargets. This is likely intentional — a truly flat tween has no per-segment ease to merge — but the PR description doesn't explicitly document this exclusion. If merged-flat-tween diamonds are a real UI state, they'd bypass bulk-easing. Confirm with a comment or add a follow-up.

Standards-lens mechanical checklist:

  • Empty-count OK: PASS (if (!selection || targets.length === 0) return; at useDomEditSession.ts:475).
  • Error-boundary present: N/A (no new async component).
  • Disposal ordered: PASS (focusedCollidingAnimationTargets cleared on manual toggle).
  • Telemetry hooks fired: PARTIAL — trackStudioSegmentEaseEdit fires; design-input track("select", "Keyframe ease") does not fire for the bulk path (see P2 #1).
  • No bare as T: PASS.
  • No non-null !: PASS.
  • No untyped-catch .message: PASS.

Review by Via

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-exact-targeting-v2 branch from c02a44c to ab97f8f Compare July 28, 2026 22:01
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from 8f5f4d2 to 5197e76 Compare July 28, 2026 22:01
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-exact-targeting-v2 branch from ab97f8f to 7e6b3e6 Compare July 28, 2026 22:34
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from 5197e76 to c3879a2 Compare July 28, 2026 22:34

@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-reviewed 8f5f4d21..c3879a2b.

All three prior concerns are addressed, and two of them landed better than I asked for:

  • EaseCurveSection.tsx:427 copy fixed to "Applies to N animations" with matching test update. ✅
  • useDomEditSession.ts:487 — the silent-drop .batch?.(...) is replaced with an explicit if (batch) { batch(...); return } else { serial fallback } plus a new test at useDomEditSession.test.tsx:410 that Reflect.deleteProperty(gsapCommitMutation, "batch") and asserts every tween is written. The comment ("A wrapped commit (a gesture transaction, say) carries no batch transport") captures the real case the optional-chain would have silently swallowed. Better than the "drop ?." I suggested.
  • The ${sourceFile}#${id} string alignment is centralised into scopedElementKey(element) in packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:174-189. The docblock names the exact class of bug the helper is preventing ("Four call sites built this string by hand and two of them omitted the index.html fallback"), and the diff confirms that PropertyPanelFlat.tsx:258 and propertyPanelFlatMotionSection.tsx:146 were in fact building ${undefined}#foo for elements with no sourceFile — those are two real prior bugs the helper closes, on top of the one I flagged.

Concerns

  • Preflight (format:check) is redoxfmt --check fails on packages/studio/src/hooks/useDomEditSession.ts. A quick bun run format:fix should clear it; the file is the very one you rewrote for the batch-fallback fix.
  • preview-regression is redPREVIEW_PARITY_RESULT: skipped counts as failure. Stacked-PR preview parity gate; may just need a rebase / re-run once #2692's landing state settles, but flagging so it doesn't sit unaddressed.

What I didn't verify

  • Any element.sourceFile === null (not just undefined) path — scopedElementKey uses element.sourceFile || "index.html" which handles both null and undefined identically, but I didn't grep every producer to confirm null is unreachable.

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.

R2 review — via

HEAD verified: c3879a2bd3c63dcdb5f17e5e65b9ca72ac84e8a5
Grade: A- (down half a notch for CI-red on the fix-touched file)
Overall: COMMENTED — one CI blocker to clear, then this is a stamp.

Delta-reviewed 8f5f4d21..c3879a2b against R1 (mine + Rames').

R1 findings delta

  • [P2 Via / telemetry gap on onUpdateSegmentEase] — RESOLVED. packages/studio/src/components/editor/gsapAnimationCallbacks.ts:127-132 now wraps onUpdateSegmentEase with track("select", "Segment ease"), matching the peers onUpdateKeyframeEase (line 148) and onSetAllKeyframeEases (line 153). Test coverage at gsapAnimationCallbacks.test.ts:83-86,123 asserts track:select:Segment ease fires exactly once before the mutation. Design-input funnel no longer goes dark on the bulk path.

  • [P2 Via / module-scope reset() in flat-motion test] — RESOLVED. packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx:12-17 moves usePlayerStore.getState().reset() into afterEach, with a comment ("The store is module-global, so a test that parks a focused ease segment would otherwise leak it into every test that runs after it") capturing the exact failure mode. Matches the sibling GsapAnimationSection.test.tsx:36-39 shape.

  • [P2 Via / elementId derivation drift PropertyPanel vs. FlatMotionSection] — RESOLVED. Both call sites now delegate to scopedElementKey(element) from packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts:166-171. Docblock (lines 161-165) explicitly names the class of bug it's preventing ("two of them omitted the index.html fallback"). Call sites: PropertyPanel.tsx:561, propertyPanelFlatMotionSection.tsx:147. Extraction fix quality is above bar — a docblock-attributed helper is what I hoped for.

  • [P2 Rames / "Applies to N properties" counts animations, not properties] — RESOLVED. EaseCurveSection.tsx:427 copy now reads Applies to {n} animations, matching Rames' most-accurate suggestion. Test at EaseCurveSection.test.tsx updated in the same commit.

  • [P2 Rames / .batch?.(...) silent-fails a real bug into no-op] — RESOLVED, and landed better than asked. useDomEditSession.ts:482-497 now:

    1. if (calls.length === 1) → single commitMutation (matches prior semantics).
    2. const batch = gsapCommitMutation.batch; if (batch) { void batch(calls, options); return; } — no optional chain.
    3. Else falls back to serial .commitMutation calls per tween, with a comment ("A wrapped commit (a gesture transaction, say) carries no batch transport…an optional-chained call here would silently drop the whole edit instead") that captures the real case the ?. would have swallowed.
      New test at useDomEditSession.test.tsx:411-422 Reflect.deleteProperty(gsapCommitMutation, "batch") and asserts every tween is written via serial calls. This is Rames' Option 2, executed correctly.
  • [P2 Rames / producer↔consumer two-string scoping] — RESOLVED for the happy path. scopedElementKey (line 166) emits ${sourceFile || "index.html"}#${id} which matches buildTimelineElementKey's domId-happy branch in timelineElementHelpers.ts:295-296 (${scope}#${params.domId}). Consumer-side drift is now closed, and the docblock names the exact bug prevented. Rames upgraded this to ✅ in his R2 and I agree.

    • Residual (non-blocking, nit): buildTimelineElementKey still has a colon-shape selector-fallback (${scope}:${selector}:${idx}, line 297) and an id-fallback (${scope}:${id}:${fallbackIndex}, line 298) that scopedElementKey does not mirror. If a producer element ever reaches those branches (selector-only, no domId), focusedEaseSegment.elementId wouldn't match the consumer's ${scope}#${element.id}. In practice studio elements have domIds so this is unreachable, but a future refactor could unify both sides via a single helper if the selector-only path becomes hot.
  • [Body-Q Rames / is runBatchCommit atomic?] — NOT ADDRESSED in the commit or in comments from Miguel. useGsapScriptCommits.ts:348-359 runBatchCommit posts ALL N mutations via one mutateGsapScriptBatch(pid, targetPath, mutations) request (single POST to /api/projects/.../gsap-mutations-batch/... — see line 65), and only finalizeSuccessfulMutation runs once for the last call. Client-side is a single request under the same file-serializer lock; whether the server-side handler applies all N as one RMW vs. iterates N sub-writes is out of this diff's visibility. If server-side is iterative, a mid-batch failure leaves the file with some tweens on the new ease and others on the old — no toast, no revert. Not blocking, but worth a follow-up confirmation from whoever owns the batch endpoint.

Fresh-pass findings (new code the fix introduced)

  • [P1] CI Preflight is red on the fix-touched file. oxfmt --check fails on packages/studio/src/hooks/useDomEditSession.ts (run https://github.com/heygen-com/hyperframes/actions/runs/30405054272/job/90428578062). One bun run format:fix should clear it. Same call-out Rames raised in his R2 — real merge blocker, but self-healing.
  • [P2] preview-regression is redPREVIEW_PARITY_RESULT: skipped counts as failure. Since #2693 sits atop #2692 in the stack and #2692 just approved a minute before this HEAD, a rebase/re-run once the stack settles is likely enough. Flagged so it doesn't linger.
  • [P3, scopedElementKey nit] the helper uses || (falsy) rather than ?? (nullish) — correct for the intent since empty-string sourceFile should also default to "index.html", but no defensive fallback for empty element.id (returns ${scope}#). In practice unreachable, so no ask.
  • Fresh-pass 12-lens sweep: clean. Empty-count guard PASS (if (!selection || targets.length === 0) return; at useDomEditSession.ts:466). Serial-fallback branch covered by test. element scope in useCallback deps correct. No new as, no new !, no untyped catch. Fast-Refresh: gsapAnimationCallbacks.ts exports only functions/types, no components — safe. No new .map() into unbounded scroll container. Scope carve-out (single-target fast-path, calls.length === 1) is honored in code — verified at useDomEditSession.ts:482-485 matches the prior handleUpdateKeyframeEase shape exactly.

Cross-stack context (#2692 gate)

Base of #2693 is codex/studio-timeline-c-exact-targeting-v2 at 7e6b3e64 = #2692's tip. #2692 was just approved (2026-07-28T22:51:12Z, vanceingalls) and is MERGEABLE, so bundle-merging the stack (#2692#2693#2694#2695 via Graphite) is the intended landing path. scopedElementKey lives on #2693, not #2692, so the mid-stack landing gap concern doesn't apply here — the helper is visible from this PR's diff (gsapKeyframeCacheHelpers.ts new export).

Positive callouts

  • Fix quality is uniformly above the ask — every P2 landed with test coverage that exercises the specific failure mode (batch-missing, module-store leak, telemetry emission, N-count copy). Nothing was rubber-stamped.
  • scopedElementKey's docblock names the drift bug directly ("two of them omitted the index.html fallback") — sets the discipline for future refactors and closes two prior latent bugs on top of the one my R1 flagged (per Rames' independent audit).
  • The batch → serial fallback path is not just tested but reasoned about in-code (the comment about wrapped commits / gesture transactions) — matches Rames' "explain the invariant" bar.

Verdict

Substantively clean, but CI red on oxfmt for the fix-touched file. Run bun run format:fix on packages/studio/src/hooks/useDomEditSession.ts, push, and this stamps once Preflight goes green.

Review by Via

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch 2 times, most recently from 29d16df to d93ce4c Compare July 28, 2026 23:21
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-exact-targeting-v2 branch from 7e6b3e6 to e0ad04d Compare July 28, 2026 23:22
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from d93ce4c to 2c854ec Compare July 28, 2026 23:53
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-exact-targeting-v2 branch from e0ad04d to 497a640 Compare July 28, 2026 23:53
@miguel-heygen
miguel-heygen changed the base branch from codex/studio-timeline-c-exact-targeting-v2 to main July 28, 2026 23:55
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-c-bulk-easing-v2 branch from 7f51aa9 to 23ab104 Compare July 29, 2026 01:40
@miguel-heygen
miguel-heygen merged commit 23ab104 into main Jul 29, 2026
45 checks passed
@miguel-heygen
miguel-heygen deleted the codex/studio-timeline-c-bulk-easing-v2 branch July 29, 2026 01:59
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