feat(studio): expand sub-composition groups + children in the timeline (#1761)

* feat(studio): element groups — source mutations

Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.

* fix(studio): hoverable group interior + non-sticky drill-in

Two group selection bugs with animated members:

1) Empty space inside a group's overlay didn't hover/select the group. Members
   animated outside the wrapper's static box (110px box vs 340px member union),
   so elementsFromPoint hit only the full-bleed background there. Add a
   member-union hit-test fallback: a point inside a group's live member bounds
   resolves to that group (innermost wins).

2) After drilling into a group and selecting a child, nothing else was
   selectable — out-of-scope resolved to null. Make drill-in non-sticky:
   interacting outside the drilled group re-resolves normally and exits the
   drill-in, so a later click on the group selects it as a unit again.

* feat(studio): enable animation editing for static inline timelines

The unsupported-pattern banner now clears for static window.__timelines["id"] =
gsap.timeline() (the parser reports it editable), and the banner copy is retargeted
to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]).

* fix(studio): correct keyframes + expansion for sub-composition timeline clips

Two gaps for elements inside a sub-composition:

1) Clip keyframes rendered off-clip. The keyframe cache computes clip-relative
   percentages from the element's start/duration, but sub-comp internals aren't in
   the timeline elements list, so duration defaulted to 1s and percentages blew
   past 100%. Resolve the timing basis from the sub-comp HOST's bounds (via
   domClipChildren, since the host's data-composition-src is stripped in the
   rendered DOM). Shared resolveClipTimingBasis used by both cache populators,
   which now re-run when the sub-comp children appear.

2) Only GROUPED sub-comp children expanded. Generalize the DOM-children collector
   to gather id'd children of the sub-comp inner-root (grouped OR ungrouped),
   descending through id-less structural wrappers; one level into groups for
   drill-in. Ungrouped pills now expand into timeline rows too.
This commit is contained in:
Miguel Ángel
2026-06-27 11:28:06 -04:00
committed by GitHub
parent 2e02bcf77a
commit 6a729b7e03
5 changed files with 207 additions and 22 deletions
@@ -122,4 +122,47 @@ describe("buildExpandedElements", () => {
expect(child.key).toBe("index.html#eyebrow");
expect(child.key).toBe(expectedStoreKey);
});
// Sub-comp internals (group + pills) have no data-start, so they're not in the
// manifest. They arrive as DOM children and must still expand under their host.
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
const elements = [
el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
];
const manifest = [
clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
];
// pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
// are in the manifest; they're DOM children with parent links.
const parentMap = new Map([
["group-1", "scene-host"],
["pill-1", "group-1"],
["pill-2", "group-1"],
["pill-3", "group-1"],
]);
const domClipChildren = [
{ id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" },
{ id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" },
{ id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" },
{ id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" },
];
// Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
const out = buildExpandedElements(
elements,
manifest,
parentMap,
"scene-host",
"group-1",
domClipChildren,
);
const pills = out.filter((e) => e.domId?.startsWith("pill-"));
expect(pills).toHaveLength(3);
// Children span the host's bounds and rebase onto the host's file.
expect(pills[0]!.start).toBe(5);
expect(pills[0]!.duration).toBe(6);
expect(pills[0]!.sourceFile).toBe("scene.html");
// The host row is replaced by its children.
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
});
});
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
@@ -111,6 +111,32 @@ function buildChildElements(
return result;
}
// Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing
// of their own — they're "always on" within their sub-comp host, so synthesize
// clips spanning the host's full bounds. The host element supplies start/duration
// and the composition file edits write to.
function domSiblingClips(
domClipChildren: DomClipChild[],
siblingParentId: string,
host: TimelineElement,
): ClipManifestClip[] {
return domClipChildren
.filter((c) => c.parentId === siblingParentId)
.map((c) => ({
id: c.id,
label: c.label,
start: host.start,
duration: host.duration,
track: host.track,
kind: "element" as const,
tagName: null,
compositionId: null,
parentCompositionId: host.id ?? null,
compositionSrc: host.compositionSrc ?? null,
assetUrl: null,
}));
}
// Exported for tests.
export function buildExpandedElements(
elements: TimelineElement[],
@@ -118,11 +144,20 @@ export function buildExpandedElements(
parentMap: Map<string, string>,
topLevelId: string,
siblingParentId: string,
domClipChildren: DomClipChild[] = [],
): TimelineElement[] {
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId);
// Prefer real manifest children; fall back to DOM-only sub-comp children
// (groups/pills) that have no data-start and thus never enter the manifest.
const siblings = (() => {
const fromManifest = manifest.filter(
(c) => c.id != null && parentMap.get(c.id) === siblingParentId,
);
if (fromManifest.length > 0) return fromManifest;
return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
})();
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
// The sub-comp host the children actually live in: top-level host for 1-level
@@ -154,6 +189,7 @@ export function useExpandedTimelineElements(): TimelineElement[] {
const elements = usePlayerStore((s) => s.elements);
const clipManifest = usePlayerStore((s) => s.clipManifest);
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
const domClipChildren = usePlayerStore((s) => s.domClipChildren);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
return useMemo(() => {
@@ -166,6 +202,13 @@ export function useExpandedTimelineElements(): TimelineElement[] {
const immediateParent = clipParentMap.get(rawId)!;
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent);
}, [elements, clipManifest, clipParentMap, selectedElementId]);
return buildExpandedElements(
elements,
clipManifest,
clipParentMap,
topLevel,
immediateParent,
domClipChildren,
);
}, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]);
}
@@ -10,7 +10,7 @@
import { useCallback } from "react";
import { liveTime, usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineElement, DomClipChild } from "../store/playerStore";
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
import {
parseTimelineFromDOM,
@@ -85,8 +85,8 @@ export function useTimelineSyncCallbacks({
| (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
| null;
const clipTree = iframeWin?.__clipTree;
const parentMap = new Map<string, string>();
if (clipTree) {
const parentMap = new Map<string, string>();
const walk = (nodes: typeof clipTree.roots) => {
for (const node of nodes) {
if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
@@ -94,11 +94,50 @@ export function useTimelineSyncCallbacks({
}
};
walk(clipTree.roots);
usePlayerStore.getState().setClipParentMap(parentMap);
}
// Descend into each sub-composition host: its internal elements (group
// wrappers + their children) carry no `data-start`, so the clip
// tree/manifest never enumerate them. Surface them studio-side as DOM
// children + parent links so the timeline can expand a sub-comp/group
// row to show them. Manifest stays lean (timed clips only).
const domClipChildren: DomClipChild[] = [];
if (iframeDoc) {
for (const clip of data.clips) {
if (clip.kind !== "composition" || !clip.id) continue;
const hostEl = iframeDoc.getElementById(clip.id);
if (!hostEl) continue;
const hostId = clip.id;
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
// Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they
// expand into timeline rows. Descends through id-less structural wrappers
// (the inlined sub-comp body), and one level into groups for drill-in.
const collect = (parentEl: Element, parentId: string) => {
for (const child of Array.from(parentEl.children)) {
if (!child.id) {
collect(child, parentId); // unwrap id-less structural containers
continue;
}
const isGroup = child.hasAttribute("data-hf-group");
domClipChildren.push({
id: child.id,
parentId,
hostId,
label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
});
parentMap.set(child.id, parentId);
if (isGroup) collect(child, child.id);
}
};
collect(innerRoot, hostId);
}
}
usePlayerStore.getState().setClipParentMap(parentMap);
usePlayerStore.getState().setDomClipChildren(domClipChildren);
} catch {
// cross-origin or __clipTree not available — parentMap stays empty
// cross-origin or __clipTree not available — maps stay empty
}
const usedHostEls = new Set<Element>();
const els: TimelineElement[] = filtered.map((clip, index) => {
const hostEl = iframeDoc