Skip to content

fix(studio): sub-composition clip timing and expanded rows - #2845

Merged
miguel-heygen merged 12 commits into
mainfrom
fix/studio-subcomp-clip-timing
Jul 28, 2026
Merged

fix(studio): sub-composition clip timing and expanded rows#2845
miguel-heygen merged 12 commits into
mainfrom
fix/studio-subcomp-clip-timing

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Sub-composition rows in the timeline were timed against the wrong frame of reference, and could vanish entirely on an ordinary seek.

Bugs fixed

  1. Sub-composition keyframes cache at negative and truncated percentages. A sub-comp tween's resolvedStart is composition-local, while the timeline element resolved for it is the sub-comp HOST, whose start is main-timeline absolute. toClipPercentage subtracted those two frames from each other, so a host mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at 88% instead of 100%. A clip-relative percentage can never be negative.
  2. The post-commit cache writer resolved its own timing basis and was missing the sub-comp host fallback the AST load already had, so the two writers disagreed.
  3. Two-level nesting gave every child row the wrong window. buildExpandedElements synthesized DOM-only sub-comp children against the top-level element rather than the parentHost it resolves immediately after, so children inherited the top-level window instead of their own host's.
  4. Drilling into a sub-composition made the host row disappear. The drill-in replaced the host row with its children. Expansion is also driven by the playhead alone (paused auto-expand), so an ordinary seek made the host row vanish, taking its keyframe lane with it: diamonds render per row from keyframeCache.get(elementKey), so no row means no diamonds. Reproduced live with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0, seek 0.2 gave 3.

Why

Bug 1 puts keyframes outside the clip they belong to, which is not a display nicety: the cached percentage is what the diamonds, the lanes, and the retime math all read. Bug 4 makes keyframes disappear during normal playback scrubbing.

How

resolveClipTimingBasis now returns the clip start in the frame the tween's own times are measured in, and moves to gsapShared so the post-commit writer shares it instead of resolving its own. A sub-comp inner element that falls back to its host's window starts at 0 in that window.

Expansion becomes additive: the host row stays and its children are appended below it. The synthetic fractional lanes already used for children sit strictly between the host's lane and the next integer, so the host keeps its own row without colliding. The time-keyed auto-expand itself is unchanged.

Test plan

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

Studio suite green on this branch: 3011 passing, 0 failures. Typecheck, lint and format clean.

Not covered

  • Third of a 7-PR stack.
  • A related root-composition defect lives in packages/core, not Studio: a decorative element carrying data-composition-id ahead of the real root wins document order, which collapses every authored scene into one timeline row. It is deliberately not in this stack, because it changes runtime resolution for every consumer and needs its own PR gated on the producer render baselines.

…me percentages

A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds,
so on a segment narrower than 24px it overhung them and won their hit test at
fit zoom. Gate the overlay on the clear span between the two diamonds and let
the button keep its 16x16 box below that.

Also renames the pointer target suite to say it asserts the classes that
produce the size, not the measured geometry, which happy-dom cannot see.
…ide press

Split hex-draft ownership so the hex input is the sole author of its
own text while editing (updateColorDraft no longer stamps a canonical
hex back over every keystroke), fixing snap-back on backspace and the
silent wrong-colour clobber on non-repeating hex values. Route hex
typing through the shared gesture transaction so outside-click and
Escape settle/cancel it like the other inspector fields, instead of
relying on a private onBlur commit that never fires once the panel
unmounts on outside-click.
Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
The panel does not trap focus and leaves the rest of the editor operable,
so aria-modal would tell assistive tech the whole app is inert while it is
open. role=dialog plus aria-controls and Escape is the correct non-modal
disclosure shape.
The gesture resolver gated on six digits while parseCssColor accepts both
lengths, so #F00 previewed as nothing and committed nothing. The old onBlur
path parsed it, making this a behavioural loss rather than a pre-existing gap.

Also asserts that an incomplete hex is restored on outside-click, not merely
left uncommitted.
A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.

resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
buildExpandedElements synthesized DOM-only sub-composition children against
the top-level element rather than the parentHost it resolves immediately
after. Under two-level nesting every child row therefore inherited the
top-level window instead of its own host's, so the rows drew at the wrong
offset and duration.
The sub-composition drill-in replaced the host row with its children. Since
expansion is also driven by the playhead alone (paused auto-expand), an
ordinary seek into a sub-composition made the host row disappear, taking its
keyframe lane with it: diamonds render per row from keyframeCache.get(
elementKey), so no row means no diamonds. Reproduced live with no drag at
all, seek 0 gave 3 diamonds, seek 7.68 gave 0, seek 0.2 gave 3.

Make the expansion additive instead. The host row stays and its children are
appended directly below it. The synthetic fractional lanes already used for
children sit strictly between the host's lane and the next integer, so the
host keeps its own row without colliding with anything.

The time-keyed auto-expand itself is unchanged.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-subcomp-clip-timing branch from da08719 to d48440a Compare July 28, 2026 16:09
@miguel-heygen
miguel-heygen force-pushed the fix/studio-color-hex-shortcuts-panel branch from 7be28e4 to 86f633f Compare July 28, 2026 16:09

@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 d48440a.

Load-bearing correctness on all four bugs, with the two-frame semantic shift (element .start is main-timeline absolute, tween resolvedStart is composition-local) captured properly in the new resolveClipTimingBasis docblock. The bug-1 test at gsapShared.test.ts:143 ("puts a tween on the host's first frame at 0%, never below zero") is exactly the shape of regression test that would have caught the -12% cache miss the first time. The seek-0/7.68/0.2 repro test at useExpandedTimelineElements.test.ts:393 is a nice port of the live-observed defect straight into the harness.

The move to gsapShared is the right consolidation — before this PR, updateKeyframeCacheFromParsed at gsapKeyframeCacheHelpers.ts:23-35 had its own inline basis lookup (elements.find(...)) while keyframeCacheAstLoad.ts had the sub-comp-host fallback, so the two writers really could disagree on where a keyframe belonged. One helper, one truth. Verified all four call sites now go through the shared path: useTimelineEditCallbacks.ts:269 (drag commit), gsapKeyframeCacheHelpers.ts:37 (post-commit), keyframeCacheAstLoad.ts:136 (AST load), useGsapTweenCache.ts:262 (main hook).

Concerns

  • resolveClipTimingBasis fallback for a missing host defaults elDuration to 1. gsapShared.ts:322 returns { elStart: 0, elDuration: host?.duration ?? 1 } when neither the element nor a host resolves. Any tween that lands on this path (a "ghost" element the store doesn't know about — should not happen but is defensively defaulted) will have every keyframe rebased against a 1-second clip: a resolvedStart of 5 → toClipPercentage(5, 0, 1) = 500%. Not new — the pre-move code did the same — but worth trapping with an assertion or a console.warn in dev builds so a real production case surfaces instead of silently landing 500%-percentages in the cache. Non-blocking.

  • buildExpandedElements fallback when parentHost is missing. useExpandedTimelineElements.ts:265 uses parentHost ?? topLevelElement when synthesizing DOM-only children. If the nested sub-comp host has no manifest entry (only a DOM presence), the fallback still lands on topLevelElement — the same bug the fix addresses for the manifest-present case. Unclear whether this can happen in practice (a data-composition-src element without a manifest clip); if it can, the test at :425 covers only the manifest-present shape. Non-blocking; worth understanding whether there's a real path here or the manifest is always the source of truth.

Nits

  • The unit-window fallback {elStart: 0, elDuration: 1} at gsapShared.ts:322 is defensive but has the same shape as the pre-PR elDuration: 1 default the PR body calls out as the original bug ("without a basis, elDuration defaulted to 1 and clip-relative keyframe percentages blew past 100%"). It's fine for a ghost element to fall through here, but the choice deserves a one-line note explaining the fallback IS the terminal case, not the sub-comp fallback.

What I didn't verify

  • Manual repro of the drill-in-vanishes-on-seek bug in a real Studio session — trusting the "3011 passing" + the resolveTimelineExpansionRawId-based test coverage.
  • Sibling PR #2846's use of resolveClipTimingBasis — reviewing it next as the top of the stack.

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.

Three fixes in one primitive: resolveClipTimingBasis moves to gsapShared, gains expandedParentStart/parentCompositionId-aware mount subtraction, and is now the single source of truth for all three keyframe-cache writers (gsapKeyframeCacheHelpers, useGsapTweenCache, keyframeCacheAstLoad) plus the drag-retime path (useTimelineEditCallbacks). Row expansion becomes additive so the host row survives paused auto-expand, and DOM-only children synthesize against parentHost instead of the top-level element. Math is right, coverage is targeted, PR body faithfully describes the four defects.

Findings

P2 — Deep-drill (≥2 levels) middle-host row still vanishes
packages/studio/src/player/hooks/useExpandedTimelineElements.ts:294-296 preserves only the outermost topLevelElement in the additive filter ((el.key ?? el.id) === parentKey). Every intermediate composition host between topLevelId and siblingParentId is in parentMap, has a key ≠ parentKey, and is filtered out. In playhead-driven auto-expand, findActiveExpandableCompositionId returns the deepest active composition (useExpandedTimelineElements.ts:87-95), so drilling from A > B > C picks siblingParentId = B, keeps A, replaces A's original expansion with [B's children], and drops B — which is exactly the class of defect Bug 4 fixes at level 1. B's diamonds render from keyframeCache.get(B.key), so its lane vanishes on a plain seek that lands inside B. Recommendation: extend the OR clause to walk the drill chain, e.g. (el.key ?? el.id) === parentKey || drillChainKeys.has(el.key ?? el.id) || !parentMap.has(el.domId ?? el.id), where drillChainKeys collects every ancestor from siblingParentId up to topLevelId. The spans DOM-only children over their nested host's window test synthesizes sub-host only in the manifest (not in elements), so the current suite doesn't exercise this.

P2 — Sub-comp inner with parentCompositionId but parent absent from elements silently reverts to main-timeline frame
packages/studio/src/hooks/gsapShared.ts:298-303. mount = direct.expandedParentStart ?? parent?.start ?? 0. When parentCompositionId is set but the parent element is not in elements (e.g. a transient store update where the child observes before the parent, or the parent has been pruned), parent is undefined, mount falls to 0, and elStart becomes the raw main-timeline direct.start — the exact frame mismatch bug 1 fixes at the domClipChildren fallback. It would then read dropAbsTime in useTimelineEditCallbacks.ts:276 as main-timeline while resolveTweenStart(anim) remains composition-local, corrupting the retime decision. Recommendation: when the direct hit declares a parentCompositionId we can't resolve, prefer the domClipChildren host-mount fall-through over mount=0, or at least add a defensive test that pins the current behavior.

Nit — Retime round-trip not covered end-to-end
packages/studio/src/hooks/gsapShared.test.ts:166-173 proves the basis round-trips through toClipPercentage, but the composition-local semantics also silently flow into useTimelineEditCallbacks.ts:269-282 (dropAbsTime) → resolveKeyframeRetime, and from there into handleGsapUpdateMeta/handleGsapResizeKeyframedTween which persist position. Sub-comp drag-retime is fixed by proxy but has no dedicated regression test; a future semantics tweak to resolveClipTimingBasis (e.g. an inverted mount sign) would corrupt persisted tween.position on sub-comp drags without any assertion firing. One integration test that drags a sub-comp keyframe and asserts the persisted position stays composition-local would close this.

Nit — Hard-coded "index.html" in the host-key fallback
packages/studio/src/hooks/gsapShared.ts:307. `index.html#${hostId}` is pre-existing behavior (unchanged from the old keyframeCacheAstLoad version), and the el.domId === hostId OR-branch typically covers it in practice, but a sub-comp host authored in a non-root file with no domId would fall through. Worth parameterising to the caller's sourceFile in a follow-up.

Verdict

COMMENT. Fix is correct, minimal-scope, well-factored — all three cache writers converge on one basis primitive, and the semantics shift is internally consistent across toClipKeyframesuseTimelineEditCallbacks. B+ (bug 4's class of defect still persists at ≥2 nesting depth; the fix is scoped strictly to the outermost host).

Review by Via

Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.

Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
@miguel-heygen
miguel-heygen changed the base branch from fix/studio-color-hex-shortcuts-panel to main July 28, 2026 19:42
@miguel-heygen
miguel-heygen merged commit 6e6babe into main Jul 28, 2026
43 of 44 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-subcomp-clip-timing branch July 28, 2026 19:56
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
…-clip-timing

fix(studio): sub-composition clip timing and expanded rows
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