mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): timeline inline expansion + __clipTree runtime primitive
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.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
|
||||
const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
||||
id: "x",
|
||||
label: "x",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
kind: "element",
|
||||
tagName: "div",
|
||||
compositionId: null,
|
||||
parentCompositionId: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const el = (over: Partial<TimelineElement>): TimelineElement =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as TimelineElement;
|
||||
|
||||
describe("buildExpandedElements", () => {
|
||||
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
||||
// host s3 at absolute 16 → stats-panel.html; children live in that file.
|
||||
const elements = [el({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" })];
|
||||
const manifest = [
|
||||
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
||||
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
||||
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["stat-1", "s3"],
|
||||
["stat-2", "s3"],
|
||||
]);
|
||||
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
||||
const child = out.find((e) => e.domId === "stat-1")!;
|
||||
expect(child.expandedParentStart).toBe(16);
|
||||
expect(child.sourceFile).toBe("stats.html");
|
||||
});
|
||||
|
||||
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
||||
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
||||
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
||||
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
||||
const manifest = [
|
||||
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
||||
clip({ id: "C", start: 13, duration: 2 }),
|
||||
clip({ id: "C2", start: 14, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["B", "A"],
|
||||
["C", "B"],
|
||||
["C2", "B"],
|
||||
]);
|
||||
|
||||
// Expanding C's siblings: topLevel A, immediate parent B.
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
||||
const child = out.find((e) => e.domId === "C")!;
|
||||
expect(child.expandedParentStart).toBe(12); // B's start, not A's 10
|
||||
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
||||
});
|
||||
|
||||
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
||||
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
||||
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
||||
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
||||
const manifest = [
|
||||
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
clip({ id: "B", start: 12, duration: 5, compositionSrc: "b.html" }),
|
||||
clip({ id: "C", start: 13, duration: 3, compositionSrc: "c.html" }),
|
||||
clip({ id: "D", start: 13.5, duration: 1 }),
|
||||
clip({ id: "D2", start: 14, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["B", "A"],
|
||||
["C", "B"],
|
||||
["D", "C"],
|
||||
["D2", "C"],
|
||||
]);
|
||||
|
||||
// Expanding D's siblings: topLevel A, immediate parent C.
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "A", "C");
|
||||
const child = out.find((e) => e.domId === "D")!;
|
||||
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
|
||||
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
||||
|
||||
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
||||
let current = parentMap.get(id);
|
||||
if (!current) return null;
|
||||
const visited = new Set<string>();
|
||||
visited.add(id);
|
||||
while (parentMap.has(current)) {
|
||||
if (visited.has(current)) return current;
|
||||
visited.add(current);
|
||||
current = parentMap.get(current)!;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function extractDomId(key: string): string {
|
||||
const hashIdx = key.lastIndexOf("#");
|
||||
return hashIdx >= 0 ? key.slice(hashIdx + 1) : key;
|
||||
}
|
||||
|
||||
function resolveRawId(
|
||||
selectedId: string | null,
|
||||
manifest: ClipManifestClip[],
|
||||
parentMap: Map<string, string>,
|
||||
): string | null {
|
||||
if (!selectedId) return null;
|
||||
const rawId = extractDomId(selectedId);
|
||||
if (parentMap.has(rawId)) return rawId;
|
||||
if (parentMap.has(selectedId)) return selectedId;
|
||||
const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
|
||||
if (clip?.id && parentMap.has(clip.id)) return clip.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterToTopLevel(
|
||||
elements: TimelineElement[],
|
||||
parentMap: Map<string, string>,
|
||||
): TimelineElement[] {
|
||||
if (parentMap.size === 0) return elements;
|
||||
return elements.filter((el) => !parentMap.has(el.domId ?? el.id));
|
||||
}
|
||||
|
||||
function clampChildToParent(
|
||||
child: ClipManifestClip,
|
||||
parentStart: number,
|
||||
parentEnd: number,
|
||||
): { start: number; duration: number } | null {
|
||||
const childEnd = child.start + child.duration;
|
||||
if (child.start >= parentEnd || childEnd <= parentStart) return null;
|
||||
const clampedStart = Math.max(child.start, parentStart);
|
||||
const clampedDuration = Math.min(childEnd, parentEnd) - clampedStart;
|
||||
return clampedDuration > 0 ? { start: clampedStart, duration: clampedDuration } : null;
|
||||
}
|
||||
|
||||
interface DisplayBounds {
|
||||
start: number;
|
||||
end: number;
|
||||
track: number;
|
||||
}
|
||||
|
||||
// `display` bounds come from the top-level scene clip (where the expanded row is
|
||||
// drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
|
||||
// start anchors local-time edits and its compositionSrc is the file edits write to.
|
||||
// They differ only for sub-comp-inside-sub-comp nesting.
|
||||
function buildChildElements(
|
||||
siblings: ClipManifestClip[],
|
||||
display: DisplayBounds,
|
||||
editBasis: { start: number; sourceFile: string | undefined },
|
||||
): TimelineElement[] {
|
||||
const result: TimelineElement[] = [];
|
||||
for (const child of siblings) {
|
||||
const clamped = clampChildToParent(child, display.start, display.end);
|
||||
if (!clamped) continue;
|
||||
const base = createTimelineElementFromManifestClip({
|
||||
clip: child,
|
||||
fallbackIndex: result.length,
|
||||
});
|
||||
result.push({
|
||||
...base,
|
||||
start: clamped.start,
|
||||
duration: clamped.duration,
|
||||
track: display.track + result.length,
|
||||
expandedParentStart: editBasis.start,
|
||||
domId: child.id ?? undefined,
|
||||
selector: child.id ? `#${child.id}` : undefined,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
timingSource: "authored" as const,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exported for tests.
|
||||
export function buildExpandedElements(
|
||||
elements: TimelineElement[],
|
||||
manifest: ClipManifestClip[],
|
||||
parentMap: Map<string, string>,
|
||||
topLevelId: string,
|
||||
siblingParentId: string,
|
||||
): 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);
|
||||
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
// The sub-comp host the children actually live in: top-level host for 1-level
|
||||
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
||||
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
||||
const editBasis = {
|
||||
start: parentHost?.start ?? topLevelElement.start,
|
||||
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
||||
};
|
||||
|
||||
const parentKey = topLevelElement.key ?? topLevelElement.id;
|
||||
const expanded = buildChildElements(
|
||||
siblings,
|
||||
{
|
||||
start: topLevelElement.start,
|
||||
end: topLevelElement.start + topLevelElement.duration,
|
||||
track: topLevelElement.track,
|
||||
},
|
||||
editBasis,
|
||||
);
|
||||
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
return elements
|
||||
.filter((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id))
|
||||
.flatMap((el) => ((el.key ?? el.id) === parentKey ? expanded : [el]));
|
||||
}
|
||||
|
||||
export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const clipManifest = usePlayerStore((s) => s.clipManifest);
|
||||
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const rawId = resolveRawId(selectedElementId, clipManifest, clipParentMap);
|
||||
if (!rawId) return filterToTopLevel(elements, clipParentMap);
|
||||
|
||||
const immediateParent = clipParentMap.get(rawId)!;
|
||||
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
||||
return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent);
|
||||
}, [elements, clipManifest, clipParentMap, selectedElementId]);
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export function useTimelineSyncCallbacks({
|
||||
return;
|
||||
}
|
||||
|
||||
usePlayerStore.getState().setClipManifest(data.clips);
|
||||
|
||||
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
||||
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
||||
const filtered = data.clips.filter(
|
||||
@@ -77,6 +79,26 @@ export function useTimelineSyncCallbacks({
|
||||
} catch {
|
||||
iframeDoc = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const iframeWin = iframeRef.current?.contentWindow as
|
||||
| (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
|
||||
| null;
|
||||
const clipTree = iframeWin?.__clipTree;
|
||||
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);
|
||||
if (node.children.length > 0) walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(clipTree.roots);
|
||||
usePlayerStore.getState().setClipParentMap(parentMap);
|
||||
}
|
||||
} catch {
|
||||
// cross-origin or __clipTree not available — parentMap stays empty
|
||||
}
|
||||
const usedHostEls = new Set<Element>();
|
||||
const els: TimelineElement[] = filtered.map((clip, index) => {
|
||||
const hostEl = iframeDoc
|
||||
|
||||
Reference in New Issue
Block a user