Skip to content

fix(parsers): moving a clip on the timeline moves its inner animations too - #4761

Merged
miguel-heygen merged 11 commits into
mainfrom
fix/core-retime-moves-descendant-tweens
Sep 30, 2026
Merged

miguel-heygen merged 11 commits into
mainfrom
fix/core-retime-moves-descendant-tweens

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

Moving or stretching a clip on the Studio timeline now carries every animation aimed at things inside that clip, not only the ones aimed at the clip itself. Animations on elements outside the clip are never rewritten.

Why

The timeline's move and retime write-back (shift-positions / scale-positions) matched tweens by an exact string compare against the clip's #id. A scene that animates its own children ("#scene h1", "#child", a class used only inside the scene, a list of targets) kept those animations at their old times, so after a drag the scene appeared with its title already landed and its boxes already moved.

Related work

Standalone fix; not part of the placement-model work.

How

  • One rule, in @hyperframes/parsers (clipTweenMatcher): a tween moves with the clip when its selector is the clip's own selector, or when its full target set is the clip or sits inside it, with no nearer timed (data-start) clip of its own. The acorn writer, the recast writer and the SDK's setTiming all use it, so there is one owner. clipQueryRoot (same file) owns where the clips are looked up: the <template> that holds the GSAP script, else the document, because linkedom keeps a template's children under the template where document queries do not reach them. The studio-server route and the SDK both use it. The SDK passes the exact selectors it matched before (#id, [data-hf-id] in both quote styles) as the clip's own selectors, so nothing it moved before is lost.
  • Tweens with no written time stay unwritten. A chained tween (tl.from(b, …) with no position) follows the tween before it. hasExplicitTime (same file) decides this for all four writer loops and for the SDK: a move leaves such a tween alone, and a stretch scales its duration but never pins a position on it.
  • A tween whose targets are only partly known is never carried. Both parsers now mark an array target with a part they cannot resolve (["#scene h1", window.logo]) with hasPartialSelector, and the matcher refuses it. The tween stays visible in the timeline and the property panel. This check runs before the own-selector match, so ["#scene", window.logo] does not move the logo either.
  • A tween whose targets are partly inside and partly outside the clip does not move, so moving one clip never rewrites anything outside it (chained tweens after it can still start later; see Known limits). Splitting such a tween in two would rewrite user code, so it is left out.
  • A tween aimed inside a nested timed clip belongs to that nested clip: moving the parent leaves it alone, since the nested clip's own data-start is not moved either. A group move of a parent and its nested clip therefore shifts each tween once.
  • Selectors that are not valid CSS (proxy and dwell labels) never match the DOM, so they only move on an exact match, as before.
  • Each clip timing edit syncs its animations once. When Studio's SDK timing write commits (a composition opened on its own), the SDK has already moved the tweens, so Studio no longer also posts the server shift-positions / scale-positions for that edit. The committed result now carries the bytes before and after, and sdkTimingGsapSync reports a sync only when the timeline script in those bytes actually changed (it walks the parsed bytes with the same composition walker the SDK uses, so a script inside a <template> counts); otherwise the server pass runs. The single-clip move and resize and the group move and resize all read it. findTimelineScript (in the acorn parser) is now the one rule for which script holds a file's timeline, used by the SDK and the server, so a gsap.config script placed first no longer makes the SDK sync a different script than the server. With no SDK session (the master view) or a declined SDK write, the server pass stays the one sync. Before this, a +2s move in such a composition landed the clip's tweens at +4s, and the following stretch scaled from the wrong start.

Known limits

  • An animation outside the clip that is chained right after the moved content (no written time) moves with the clip. GSAP starts a chained tween where the timeline ends at that point, so when the clip's content moves later or gets longer, the chained outside tween follows it. Example: a CTA chained after a scene plays at 5.5s; stretch the scene from 4s to 7s and it plays at 7s. Holding it in place needs the live timeline's real start times and is a follow-up.
  • Targets are resolved against the whole page, so a scoped query (scene.querySelectorAll(".x")) reads as the global .x and stays put when .x also exists outside.
  • Only elements present in the file are seen; elements a script creates later are not.
  • A group move of sibling clips does not move a tween whose targets span two of the moved clips (each clip sees mixed targets).
  • A clip with no data-start attribute does not carry its descendants on its first move (the move writes the attribute).
  • Relative positions and labels ("<", "+=1", "intro") are skipped, for descendants as for the clip's own tweens.

Test plan

  • gsapWriter.parity.test.ts, both writers, shift and scale on one composition: descendant selector, child id, list target and inside-only class move; an outside element, a class spanning inside and outside, a tween inside a nested timed clip, a chained tween (keeps no position; its duration scales) and an array with an unresolvable part stay.
  • Route test in files.test.ts: shift-positions then scale-positions on a template-wrapped composition move #card h1 and #line, and leave #side and #dot, which sits in a nested timed clip.
  • SDK mutate.gsap.test.ts: setTiming on a scene carries #scene h1, and leaves a chained tween, a partly known array and an outside element alone, and leaves an outside tween chained after the scene's content unwritten (the known limit). A second test moves a clip in a <template>-wrapped composition and carries its [data-hf-id] tween and #card h1.
  • Parity rows added for an outside tween chained after carried ones (stays chained, and a row per writer pins that it then starts at 5.5 instead of 3.5, the known limit), ["#scene", window.logo] (stays), and a clip without data-start whose tween targets it through a class (moves).
  • A <template> file with a gsap.config script before the timeline script: an SDK move then stretch lands each tween once (Studio test), and SDK setTiming syncs the timeline script there (SDK test). Before, the SDK moved no tween and the stretch then scaled from the new start.
  • Studio useTimelineEditing.test.tsx: a move, a resize and a group move in a sub-composition, each through the SDK and through the server (no SDK session), read the final file bytes from an in-memory project whose GSAP route runs the real server writer. Before the Studio fix the three SDK cases fail with doubled times (#scene at 5 instead of 3, #scene h1 at 5.5, durations 4 instead of 2); the server cases pass before and after. The three older tests that expected a server post after an SDK commit are replaced by these.
  • Each rule was broken on purpose and a test caught it: counting implicit positions as written fails the 4 parity cases and 2 SDK tests; carrying partly known targets fails the 4 parity cases and the SDK test; letting any ancestor clip own a tween (instead of the nearest) fails the 4 parity cases and the route test; with the earlier follower pins still in, the 6 parity cases and the SDK test that pin the known limit fail; querying the SDK's document instead of the script's template fails the template SDK test; checking partial targets after the own-selector match fails the 4 parity cases; dropping the "target is the clip" branch fails the 2 new parity cases. Reading only document-level scripts in the sync check (no descent into the <template>) fails the template config-script Studio test.
  • Manual walk in the built Studio (scene 1s to 5s, a chained line after the title, a nested timed clip, a class used inside and outside, an outside box): dragging the scene to 3s moved its own tween and the title to 3 and left the chained line unwritten; stretching it to 7s scaled those durations by 1.75, and the chained line kept no position (it plays at 4.75, right after the title); the nested clip's tween, the shared class and the outside box kept their times. Moving the nested clip alone moved only its tween. Nothing outside the scene got a written position.
  • Walk in the built Studio with the composition opened on its own: dragging the scene +2s wrote each tween once (own tween and title at 3, never 5) with no server GSAP post; the stretch and the nested move then scaled and moved from those values. The master view still syncs through the server with the same numbers.
  • Comments follow CONTRIBUTING.md "Comments"

Before

Main, just after dragging the scene 2s later, at 3.5s: the scene has just appeared, but its title has already landed and the gold box has already moved.

Main after the drag, at 3.5s

Main after the stretch, at 4s: same, the inner animations still sit at their original times.

Main after the stretch, at 4s

After

This branch, same drag, at 3.5s: the title is sliding in and the gold box waits for its cue. After stretching the scene to 6s and reloading, at 4s and 5.5s, the inner animations play at their scaled times.

Branch after the drag, at 3.5s

Branch after the stretch, reloaded, at 4s

Branch after the stretch, reloaded, at 5.5s

  • Walks of the reviewer's fixtures in the built Studio (the sub-composition opened on its own, plain and with a gsap.config script first; the single-file master view, plain and config first), each start re-checked in real GSAP from the saved file: after the +2s move, the scene's timed tweens sit at 3 and the outside #cta still plays at 5.5 and the chained outside #side goes from 3 to 5. After the +3s stretch, the chained outside #side and #cta move to 6.5 and 7, as the known limit says. Each edit wrote the animations once, and nothing outside the scene got a written position.

Comment thread packages/studio/src/hooks/timelineTimingSync.ts Fixed
Comment thread packages/studio/src/hooks/useTimelineEditing.test.tsx Fixed
@miguel-heygen
miguel-heygen marked this pull request as draft September 30, 2026 19:58
@miguel-heygen
miguel-heygen marked this pull request as ready for review September 30, 2026 19:58
@miguel-heygen
miguel-heygen added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit f1c1f1d Sep 30, 2026
130 of 131 checks passed
@miguel-heygen
miguel-heygen deleted the fix/core-retime-moves-descendant-tweens branch September 30, 2026 20:15
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