mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -176,6 +176,37 @@ export async function fetchParsedAnimations(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
||||
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
||||
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
||||
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
||||
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
||||
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
||||
*/
|
||||
function resolveClipTimingBasis(
|
||||
elementId: string,
|
||||
sourceFile: string,
|
||||
elements: ReadonlyArray<{
|
||||
domId?: string;
|
||||
key?: string;
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}>,
|
||||
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
||||
): { elStart: number; elDuration: number } {
|
||||
const direct = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
);
|
||||
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
||||
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
||||
const host = hostId
|
||||
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
||||
: undefined;
|
||||
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
||||
}
|
||||
|
||||
export function useGsapAnimationsForElement(
|
||||
projectId: string | null,
|
||||
sourceFile: string,
|
||||
@@ -192,6 +223,11 @@ export function useGsapAnimationsForElement(
|
||||
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
|
||||
const lastFetchKeyRef = useRef("");
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Re-run the per-element cache populate when sub-comp DOM children appear, so a
|
||||
// sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
|
||||
const domClipChildrenKey = usePlayerStore((s) =>
|
||||
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const targetKey = target?.id ?? target?.selector ?? "";
|
||||
@@ -351,12 +387,13 @@ export function useGsapAnimationsForElement(
|
||||
|
||||
// Resolve the element's time range from the player store so we can
|
||||
// convert tween-relative keyframe percentages to clip-relative ones.
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(
|
||||
elementId,
|
||||
sourceFile,
|
||||
elements,
|
||||
domClipChildren,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 1;
|
||||
|
||||
const allKeyframes: Array<
|
||||
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
|
||||
@@ -419,7 +456,8 @@ export function useGsapAnimationsForElement(
|
||||
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
|
||||
// so write a duplicate entry under the bare key for cross-component lookups.
|
||||
setKeyframeCache(elementId, merged);
|
||||
}, [elementId, sourceFile, animations]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
||||
|
||||
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
||||
}
|
||||
@@ -442,13 +480,19 @@ export function usePopulateKeyframeCacheForFile(
|
||||
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
||||
): void {
|
||||
const elementCount = usePlayerStore((s) => s.elements.length);
|
||||
// Re-run when sub-comp DOM children appear (they supply the host bounds the
|
||||
// clip-relative keyframe percentages are computed against; without this the
|
||||
// cache is computed once before they exist and the percentages stay wrong).
|
||||
const domClipChildrenKey = usePlayerStore((s) =>
|
||||
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
|
||||
);
|
||||
const lastFetchKeyRef = useRef("");
|
||||
|
||||
const runtimeScanDoneRef = useRef("");
|
||||
const astFetchDoneRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
|
||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
|
||||
if (fetchKey === lastFetchKeyRef.current) return;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
runtimeScanDoneRef.current = "";
|
||||
@@ -461,7 +505,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
if (!parsed) return;
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
clearKeyframeCacheForFile(sf);
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const doc = iframeRef?.current?.contentDocument;
|
||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||
for (const anim of parsed.animations) {
|
||||
@@ -482,11 +526,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
// Attribute the tween to every element it animates (handles class /
|
||||
// group / descendant selectors, not just `#id`).
|
||||
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 1;
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
||||
const clipKeyframes = kfData.keyframes.map((kf) => {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
||||
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
||||
@@ -524,7 +564,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
// iframeRef is read for DOM selector resolution but intentionally not a dep
|
||||
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, sourceFile, version, elementCount]);
|
||||
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
|
||||
|
||||
// Separate effect for runtime keyframe discovery — polls until the iframe
|
||||
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,6 +165,23 @@ interface PlayerState {
|
||||
setClipManifest: (clips: ClipManifestClip[] | null) => void;
|
||||
clipParentMap: Map<string, string>;
|
||||
setClipParentMap: (map: Map<string, string>) => void;
|
||||
/**
|
||||
* Sub-composition DOM descendants (groups + their children) that have no
|
||||
* `data-start`, so they're absent from the clip manifest/tree. Collected
|
||||
* studio-side from the live preview so the timeline can expand a sub-comp row
|
||||
* to show its DOM-only children. Keeps the manifest lean (timed clips only).
|
||||
*/
|
||||
domClipChildren: DomClipChild[];
|
||||
setDomClipChildren: (children: DomClipChild[]) => void;
|
||||
}
|
||||
|
||||
/** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */
|
||||
export interface DomClipChild {
|
||||
id: string;
|
||||
parentId: string;
|
||||
/** The manifest sub-comp host clip id this descendant ultimately lives under. */
|
||||
hostId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BeatHistoryEntry {
|
||||
@@ -296,6 +313,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setClipManifest: (clips) => set({ clipManifest: clips }),
|
||||
clipParentMap: new Map(),
|
||||
setClipParentMap: (map) => set({ clipParentMap: map }),
|
||||
domClipChildren: [],
|
||||
setDomClipChildren: (children) => set({ domClipChildren: children }),
|
||||
|
||||
setIsPlaying: (playing) => {
|
||||
if (get().isPlaying === playing) return;
|
||||
@@ -380,6 +399,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
beatPersist: null,
|
||||
clipManifest: null,
|
||||
clipParentMap: new Map(),
|
||||
domClipChildren: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user