Skip to content

feat(studio): timeline inline expansion + __clipTree runtime primitive#1468

Merged
miguel-heygen merged 1 commit into
mainfrom
feat/timeline-inline-expansion
Jun 16, 2026
Merged

feat(studio): timeline inline expansion + __clipTree runtime primitive#1468
miguel-heygen merged 1 commit into
mainfrom
feat/timeline-inline-expansion

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Timeline inline expansion + expanded-clip editing

When you select a child element inside a sub-composition, the timeline replaces the parent scene clip with that depth's siblings as individual, editable clips. Deselect (or click the empty preview area) collapses back. Built on a new window.__clipTree runtime primitive.

Runtime — packages/core/src/runtime/clipTree.ts

  • New window.__clipTree: a read-only hierarchical ClipNode tree (id/parentId/children + backing element) built from all [data-start] elements, so Studio can derive parent/child relationships for inline expansion.
  • Built before the timeline postMessage so Studio sees it on first sync; rebuilt only when the set of [data-start] elements changes — keyed on an id+tag signature in document order, so same-count swaps (one sub-comp unloads as another loads) still trigger a rebuild — not every transport tick.
  • Intentionally minimal: the node carries only what consumers read. No write/patch API — timing edits go through the host's normal save → reloadPreview path. (Earlier drafts had updateTiming/getNode/getAbsoluteTime/onChange/postMessage with zero or redundant callers — stripped.)

Studio

  • useExpandedTimelineElements — pure useMemo deriving the expanded view from selectedElementId + clipParentMap. No useEffect, no store writes. Each expanded child is rebased onto its own sub-comp host (expandedParentStart + sourceFile), correct at any nesting depth.
  • findMatchingTimelineElementId — returns a sourceFile#id qualified id for sub-comp children that have no top-level timeline element, so the expansion hook can resolve them.
  • NLELayout — move/resize/delete/split on expanded clips rebase absolute→local time via expandedParentStart, remap id ← domId, and route through the exact same handlers top-level clips use. Edits save to the sub-comp source and reflect via reloadPreview — there is no separate live-DOM patch path and no reactive observer; reloadPreview is the canonical update. Clicking the empty preview area deselects.
  • Razor tool enabled by default; analytics: studio_razor_split (mode single/all + count) on each split, and studio_expanded_clip_edit (action move/resize/delete/split) on each edit applied through the expansion surface.
  • gsapTargetCache — O(1) cached Set+WeakSet GSAP-target lookup replacing the O(n²) inline scan.

GSAP / keyframe correctness (traced per path)

All edit paths resolve the target file from the element/selection sourceFile and scan all window.__timelines (which include inlined sub-comp timelines):

  • Split GSAP retarget → useRazorSplit uses element.sourceFile. ✅
  • Keyframe delete/move/ease/toggle → useGsapScriptCommits / StudioPreviewArea use selection.sourceFile. ✅
  • Keyframe cache → keyed sourceFile#id, scans all timelines. ✅
  • GSAP-targeted drag guard → reads all timelines, covers expanded clips. ✅
  • Multi-level nesting (sub-comp inside sub-comp) — was a corruption bug (edits keyed off the top-level host, wrong file + offset); now each child rebases onto its real immediate sub-comp host. Unit-tested (useExpandedTimelineElements.test.ts, 1-, 2-, and 3-level cases).

Verification

  • Delete on expanded clips — verified e2e (agent-browser): removed from the sub-composition HTML, index.html and other scenes untouched.
  • Expansion / sub-comp expansion / move / resize / deselect — confirmed manually across hero, feature-grid, stats-panel, and inline scenes.
  • Build, typecheck, lint, oxfmt, fallow gate (zero introduced findings) all pass. Unit coverage for findMatchingTimelineElementId, buildExpandedElements, and the razor-split analytics event.

Manual-confirm checklist before relying on these (drag/seek paths the automated harness can't drive)

  • Split an expanded clip with the razor tool → two clips, correct local times, both present in the sub-comp HTML, GSAP animation not corrupted.
  • Delete keyframe on an expanded clip → removed from the child's GSAP animation in the sub-comp script only.
  • Move/ease-change/toggle keyframe on an expanded clip → mutates the correct child animation.
  • Move + resize an expanded clip → data-start/data-duration persist to the sub-comp HTML at correct local time, and the change shows after the preview reloads.

Behavior change for existing users

  • STUDIO_RAZOR_TOOL_ENABLED default flipped false → true — the razor/blade split tool is now on by default (overridable via the VITE_STUDIO_*_RAZOR_TOOL env flags).

Known limitation

  • The s-key split shortcut looks up the selection in raw (non-expanded) elements, so it can't resolve an expanded clip. It now shows a toast nudging toward the razor tool (B) instead of failing silently; the razor-tool click path covers split for expanded clips.

Review feedback addressed (Rames D Jusso canonical-rubric pass)

  • Swap-blind rebuild heuristic__clipTree rebuild now keys on an id+tag signature, not a bare element count, so same-count sub-comp swaps rebuild correctly.
  • read-only was convention-onlyClipTree/ClipNode are now readonly (roots + children + element); the builder uses an internal mutable shape, so accidental consumer writes fail at compile time.
  • 3+ level nesting untested — added a 3-level case locking in deepest-host rebasing.
  • Analytics gap on expansion editsstudio_expanded_clip_edit now fires for move/resize/delete/split on expanded clips.
  • s-key silent no-op — now toasts a razor-tool nudge.
  • Razor default flip undocumented — called out above under Behavior change.

@miguel-heygen miguel-heygen force-pushed the feat/timeline-inline-expansion branch 6 times, most recently from a7a965e to ff6db19 Compare June 15, 2026 22:41

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

Verified at HEAD ff6db19. Canonical engineering rubric pass — HF-domain coherence (Studio UX, GSAP/timeline animation semantics, NLE editing patterns, sub-comp host conventions) deferred to @vai.

Load-bearing claims — verified

  • __clipTree built before first postRuntimeMessage — confirmed at packages/core/src/runtime/init.ts:1588-1601; tree assignment precedes message dispatch. No first-sync race.
  • Stripped-API claim cleangrep -E "updateTiming|getAbsoluteTime|onClipTreeChange" across packages/ returns empty. No stale callers, no mocks, no doc drift.
  • reloadPreview is the canonical update pathNLELayout.tsx:233-269 (handleMove/Resize/Delete/Split) rebases via toLocalElement (subtract expandedParentStart, swap id ← domId) and routes through the SAME useTimelineEditContext handlers top-level clips use. No MutationObserver, no direct preview-iframe DOM mutation.
  • 2-level nesting test exercises the corruption shape, not a happy pathuseExpandedTimelineElements.test.ts:44-65 asserts expandedParentStart === 12 (nested host B) and sourceFile === "b.html" — explicitly NOT A's 10 / a.html. Good test rigor.
  • gsapTargetCache invalidation — identity-compare timelines !== _gsapCachedTimelines (gsapTargetCache.ts:57) rebuilds on every new iframe __timelines object. Set<string> + WeakSet<Element> pairing is intentional and both arms are needed (ID-based vs element-identity).

Concerns (non-blocking, suggest addressing)

  • packages/core/src/runtime/init.ts:1588 — rebuild heuristic misses same-count-different-shape. Trigger is clipTreeElCount !== currentStartElCount. Catches add/remove but NOT swap — e.g. one sub-comp finishes loading while another unloads with equal [data-start] count, or a sub-comp re-keys children with the same total count. The comment explicitly markets "sub-composition finishes loading" as the trigger, which is exactly the swap shape. Cheap secondary signal: hash of Array.from(document.querySelectorAll('[data-start]')).map(e => e.id).sort().join(','), or bump on any __timelines registry-key change. As shipped, a stale tree on swap would silently send the wrong parentMap to Studio.
  • packages/studio/src/hooks/useAppHotkeys.ts:194s-key shortcut silent-fail on expanded clips. Reads raw elements, not expandedElements. When selectedElementId is a qualified sourceFile#id (from findMatchingTimelineElementId at studioHelpers.ts:175), elements.find(...) won't match and the handler falls through with no feedback. Body acknowledges this; suggest a toast or status-bar nudge ("Use razor tool to split inside a sub-comp") rather than silent no-op — discoverability cost is otherwise real.
  • packages/core/src/runtime/clipTree.ts:114 — "read-only" is by convention. ClipTree.roots is a plain ClipNode[] with mutable children arrays + a non-frozen element ref. Current sole consumer (useTimelineSyncCallbacks.ts:90-96) only reads, but the body promises a read-only SDK surface. Suggest Object.freeze on roots + nodes, or at minimum a Readonly<ClipNode> type alias in window.d.ts so accidental writes fail at compile time.
  • 3+ level nesting untested. Body claims correctness "at any nesting depth," only 2-level is tested. findTopLevelAncestor is iterative (while (parentMap.has(current))) with cycle guard, so 3+ should work — but cheap to add a 3-level case and lock it in.
  • Analytics gap for expansion-path edits. studio_razor_split lands but move / resize / delete on expanded clips fire nothing. If product wants adoption signal for the new expansion surface, the razor event alone undercounts.
  • STUDIO_RAZOR_TOOL_ENABLED default flipped false → true (manualEditingAvailability.ts:73). UX-default change for existing users — no callout in PR body. Worth a changelog line.

What I did not verify

  • HF-domain coherence (animation re-target correctness across edit paths, sub-comp host conventions, GSAP timeline semantics, NLE editing UX) — deferred to @vai per lane split.
  • E2E behavior for edit paths beyond delete (move/resize/split) — manual-confirm-only per body, not exercised by CI.

Routing

Stamp pending @vai's HF-domain lane + the rebuild-heuristic concern at minimum. Once Vai signs off on the animation/Studio surface and the swap-trigger concern lands (or is explicitly waived with rationale), <@U0ARJFN5S6Q> good to stamp.

Canonical-rubric pass by Rames D Jusso — HF-domain coherence deferred to @vai

@miguel-heygen miguel-heygen force-pushed the feat/timeline-inline-expansion branch from ff6db19 to 5edd99d Compare June 15, 2026 23:38
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks for the rubric pass — addressed all six non-blocking concerns in 5edd99d:

  1. Rebuild heuristic missed same-count swaps__clipTree rebuild now keys on an id+tag signature in document order (computeClipTreeSignature) instead of a bare [data-start] count, so a sub-comp unloading as another loads (or a same-count re-key) triggers a rebuild. init.test.ts still green.
  2. s-key silent no-op on expanded clips — now shows a toast ("Use the razor tool (B) to split clips inside a sub-composition") when the selection is a qualified sourceFile#id with no raw-element match, instead of falling through silently.
  3. read-only was convention-onlyClipTree.roots/ClipNode are now readonly (roots, children, and the element ref). The builder uses an internal MutableClipNode shape and returns it as the readonly public type, so accidental consumer writes fail at compile time.
  4. 3+ level nesting untested — added a 3-level case to useExpandedTimelineElements.test.ts asserting the child rebases onto the deepest host (C: 13/c.html), not the intermediate B or top-level A.
  5. Analytics gap on expansion-path edits — added studio_expanded_clip_edit (action move/resize/delete/split), fired from the NLELayout wrappers on the expanded path only. Covered by a unit test.
  6. Razor default flip undocumented — added a "Behavior change for existing users" callout to the PR body.

On the GSAP/timeline-semantics lane deferred to @vai: I traced move/resize re-targeting end-to-end against the test project — persist → scaleGsapPositions → visibility gating all resolve the correct sub-comp file and local time. One note for that review: scaleGsapPositions scales tween positions around the clip start, so entrance tweens authored off their clip start (rare, but present in one of my fixtures) keep their offset on resize rather than snapping to the new start. That's existing behavior shared with top-level clips, not new here — flagging in case we want snap-to-start as a follow-up.

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

R2 tight delta — baseline R1 was at HEAD ff6db199, this pass at HEAD 5edd99d1.

This is a delta-only verification of my R1 findings + scan for regressions. HF-domain coherence (timeline-expansion semantics, NLE UX, player-store ergonomics) deferred to @vai.

R1 finding verification

# Finding Verdict Evidence at 5edd99d1
1 Rebuild-trigger heuristic gap (count-only missed same-count sub-comp swaps) ✅ resolved packages/core/src/runtime/init.ts:1563-1572clipTreeSignature keys on ${el.id}:${el.tagName} in document order; init.ts:1595-1609 rebuilds on signature change, not count. Catches the swap case I flagged.
2 s-key shortcut silent-fail on expanded clips ✅ resolved packages/studio/src/hooks/useAppHotkeys.ts:209-214 — detects qualified sourceFile#id selector and surfaces showToast("Use the razor tool (B) to split clips inside a sub-composition", "info"). Cleaner than my disabled-cursor suggestion.
3 ClipTree "read-only" by convention only ⚠️ partial packages/core/src/runtime/clipTree.ts:16-24 — public ClipNode / ClipTree interfaces now declare readonly on id, parentId, children: readonly ClipNode[], roots. TS-side immutability covered. Runtime objects are still mutable under the readonly cast — a third-party iframe script can still (window.__clipTree.roots[0].children as any[]).push(...). Not blocking because the consumer at useExpandedTimelineElements.ts:6-15 (findTopLevelAncestor) carries a proper visited cycle guard, so a corrupted runtime tree can't infinite-loop Studio. Worth a follow-up Object.freeze(tree.roots) for true defense-in-depth; calling this acceptable for landing.

New concerns from the delta

None blocking. Notes:

  • packages/studio/src/player/hooks/useExpandedTimelineElements.ts:6-15 — the findTopLevelAncestor visited-set guard is exactly the right defense against a corrupted parentMap from a hostile/buggy iframe. Good to see this in.
  • packages/studio/src/player/hooks/useExpandedTimelineElements.ts:44-50clampChildToParent correctly drops children entirely outside the display window. Math LGTM.
  • packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts covers 1/2/3-level nesting editBasis resolution — that's the load-bearing rebase-math correctness path. Reasonable coverage.
  • Nit: packages/studio/src/hooks/gsapTargetCache.ts:5-7 keeps module-level mutable state for the cache. Fine under today's single-preview-iframe assumption (identity miss rebuilds the WeakSet/Set), but worth a comment if multi-preview ever becomes a thing.

Summary

R1 findings 1 & 2 cleanly resolved. Finding 3 is partial — TS-side readonly plus consumer-side cycle guard close the practical risk, with a Object.freeze follow-up worth tracking. Greenlight on the canonical-engineering lane pending @vai's HF-domain pass.

R2 delta by Rames D Jusso — HF-domain coherence deferred to @vai

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

Stamping at 5edd99d1 per Rames D Jusso (canonical-engineering R2) + Via (HF-domain R2) dual clearance.

Per-R1 blocker shape closed:

  • Rebuild-trigger heuristic (Rames D Jusso #1 / Via blocker)init.ts:1563-1572 swap signature on ${id}:${tagName} in document order via computeClipTreeSignature(). Same-count [A,B] → [A,C] swap diverges signature → fires createClipTree → flows through useTimelineSyncCallbacks.ts:87-97useExpandedTimelineElements.ts:147-151. Silent cross-file corruption window closed. Helper replaced, not guarded.
  • s-key silent no-op (Rames D Jusso #2)useAppHotkeys.ts:209-216 toasts "Use the razor tool (B)" when selectedElementId carries qualified sourceFile#id. Signal flows.
  • ClipTree read-only (Rames D Jusso #3 / Via)clipTree.ts:15-34 public ClipNode/ClipTree carry readonly modifiers; private MutableClipNode carries build-time mutation. Consumer write-through fails at compile time. Runtime Object.freeze deferred — non-blocker per Via (cycle guard at useExpandedTimelineElements.ts:6-15).

Bonus delta:

  • trackStudioExpandedClipEdit({action}) wired into all 4 NLELayout handlers (NLELayout.tsx:232,248,261,271) — closes Via's analytics gap.
  • 3-level nesting unit test in useExpandedTimelineElements.test.ts — locks the deepest-host rebasing claim from the PR body.

Required CI green: Lint, Typecheck, Test, Test: runtime contract, CLI smoke, SDK unit+contract+smoke, Studio: load smoke, Preview parity, Perf drift/scrub/fps/load/parity, Build, Fallow audit. File size check failing is the known soft-check; regression-shards in-progress but non-blocking on the runtime-contract surface per Via.

Stale-base posture clean (merge-base 3f3293da, #1464/#1448 v3/#1459 already in base).

— Rames Jusso

@miguel-heygen miguel-heygen force-pushed the feat/timeline-inline-expansion branch from 5edd99d to 195bd97 Compare June 16, 2026 00:10
When a child element inside a sub-composition is selected, the timeline
replaces the parent scene clip with the deepest-level siblings. Deselect
or selecting outside collapses back. Expanded clips are fully editable —
move, resize, delete, and split — addressed by their real DOM id with
timeline time rebased onto the sub-comp they live in.

Runtime:
- New window.__clipTree API: a read-only hierarchical ClipNode tree
  (id/parentId/children + backing element) so Studio can derive
  parent/child relationships for inline expansion.

Studio:
- useExpandedTimelineElements derives the expanded view from
  selectedElementId + clipParentMap (pure useMemo, no useEffect).
  Each child rebases onto its immediate sub-comp host (start +
  sourceFile), so multi-level nesting targets the right file.
- NLELayout routes expanded-clip edits through the same handlers
  top-level clips use, in local coordinates — edits save to the
  sub-comp source and reflect via reloadPreview (no separate DOM-patch
  path). This is the canonical update; there is no reactive observer.
- findMatchingTimelineElementId resolves sub-comp children with no
  top-level element to `sourceFile#id`.
- Razor tool enabled by default; studio_razor_split analytics event
  fired on single and split-all.
- O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a
  cached Set+WeakSet O(1) lookup.
@miguel-heygen miguel-heygen force-pushed the feat/timeline-inline-expansion branch from 195bd97 to f428650 Compare June 16, 2026 01:38
@miguel-heygen miguel-heygen merged commit 8cbf438 into main Jun 16, 2026
47 checks passed
@miguel-heygen miguel-heygen deleted the feat/timeline-inline-expansion branch June 16, 2026 02:16
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