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:
Miguel Ángel
2026-06-15 22:16:29 -04:00
committed by GitHub
parent 07030294e0
commit 8cbf4384e1
25 changed files with 843 additions and 126 deletions
@@ -3,6 +3,7 @@ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { isMusicTrack } from "../../utils/timelineInspector";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { useMountEffect } from "../../hooks/useMountEffect";
import { EditPopover } from "./EditModal";
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
@@ -28,7 +29,10 @@ import {
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import {
useResolvedTimelineEditCallbacks,
type TimelineEditOverrides,
} from "./useResolvedTimelineEditCallbacks";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
@@ -45,7 +49,7 @@ export {
getDefaultDroppedTrack,
} from "./timelineLayout";
interface TimelineProps extends TimelineDropCallbacks {
interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
onSeek?: (time: number) => void;
onDrillDown?: (element: TimelineElement) => void;
renderClipContent?: (
@@ -67,6 +71,10 @@ export const Timeline = memo(function Timeline({
onAssetDrop,
onBlockDrop,
onDeleteElement: _onDeleteElement,
onMoveElement: onMoveElementOverride,
onResizeElement: onResizeElementOverride,
onBlockedEditAttempt: onBlockedEditAttemptOverride,
onSplitElement: onSplitElementOverride,
onSelectElement,
theme: themeOverrides,
}: TimelineProps = {}) {
@@ -80,14 +88,18 @@ export const Timeline = memo(function Timeline({
onDeleteAllKeyframes,
onChangeKeyframeEase,
onMoveKeyframe,
} = useTimelineEditContext();
} = useResolvedTimelineEditCallbacks({
onMoveElement: onMoveElementOverride,
onResizeElement: onResizeElementOverride,
onBlockedEditAttempt: onBlockedEditAttemptOverride,
onSplitElement: onSplitElementOverride,
});
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
useMusicBeatAnalysis();
const elements = usePlayerStore((s) => s.elements);
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
// Merge user edits + remap beats from audio-file → composition coordinates.
const beatEdits = usePlayerStore((s) => s.beatEdits);
const adjustedBeatAnalysis = useMemo(
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
@@ -176,21 +188,21 @@ export const Timeline = memo(function Timeline({
const effectiveDuration = useMemo(() => {
const safeDur = Number.isFinite(duration) ? duration : 0;
if (elements.length === 0) return safeDur;
const maxEnd = Math.max(...elements.map((el) => el.start + el.duration));
if (rawElements.length === 0) return safeDur;
const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
const result = Math.max(safeDur, maxEnd);
return Number.isFinite(result) ? result : safeDur;
}, [elements, duration]);
}, [rawElements, duration]);
const tracks = useMemo(() => {
const map = new Map<number, typeof elements>();
for (const el of elements) {
const map = new Map<number, typeof expandedElements>();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
list.push(el);
map.set(el.track, list);
}
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [elements]);
}, [expandedElements]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
@@ -247,8 +259,9 @@ export const Timeline = memo(function Timeline({
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const selectedElement = useMemo(
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
[elements, selectedElementId],
() =>
expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
[expandedElements, selectedElementId],
);
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
selectedElementRef.current = selectedElement;
@@ -283,7 +296,7 @@ export const Timeline = memo(function Timeline({
effectiveDuration,
pps,
timelineReady,
elementsLength: elements.length,
elementsLength: expandedElements.length,
setZoomMode,
setManualZoomPercent,
onSeek,
@@ -332,7 +345,7 @@ export const Timeline = memo(function Timeline({
useEffect(() => {
syncShortcutHintVisibility();
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
}, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
@@ -362,7 +375,7 @@ export const Timeline = memo(function Timeline({
onBlockDrop,
});
if (!timelineReady || elements.length === 0) {
if (!timelineReady || expandedElements.length === 0) {
return (
<TimelineEmptyState
isDragOver={isDragOver}
@@ -482,7 +495,7 @@ export const Timeline = memo(function Timeline({
}
}}
onContextMenuKeyframe={(e, elId, pct) => {
const el = elements.find((x) => (x.key ?? x.id) === elId);
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
@@ -102,7 +102,7 @@ export const TimelineClip = memo(function TimelineClip({
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
>
{/* Left accent stripe */}
{/* Left accent stripe — wider + brighter for expanded sub-comp children */}
<div
aria-hidden="true"
style={{
@@ -110,9 +110,9 @@ export const TimelineClip = memo(function TimelineClip({
left: 0,
top: 0,
bottom: 0,
width: 3,
width: el.expandedParentStart !== undefined ? 4 : 3,
background: trackStyle.accent,
opacity: isSelected ? 0.7 : 0.3,
opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
zIndex: 2,
pointerEvents: "none",
@@ -0,0 +1,30 @@
import { useMemo } from "react";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
// Props a parent (e.g. NLELayout) may pass to <Timeline> to intercept edits —
// the rest of the callback bag still comes from TimelineEditContext.
export type TimelineEditOverrides = Pick<
TimelineEditCallbacks,
"onMoveElement" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
>;
// Merge any prop overrides over the context callbacks. Used so NLELayout can
// wrap move/resize/split (to rebase expanded sub-comp clips) while every other
// callback falls through to the context unchanged.
export function useResolvedTimelineEditCallbacks(
overrides: TimelineEditOverrides,
): TimelineEditCallbacks {
const ctx = useTimelineEditContext();
const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides;
return useMemo(
() => ({
...ctx,
onMoveElement: onMoveElement ?? ctx.onMoveElement,
onResizeElement: onResizeElement ?? ctx.onResizeElement,
onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
onSplitElement: onSplitElement ?? ctx.onSplitElement,
}),
[ctx, onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement],
);
}
@@ -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
@@ -1,6 +1,7 @@
import { create } from "zustand";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import type { BeatEditState } from "../../utils/beatEditing";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
@@ -50,6 +51,13 @@ export interface TimelineElement {
timelineLocked?: boolean;
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
timelineRole?: string;
/**
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child
* lives in. Presence marks the element as expanded; edits subtract it to get
* the child's local (sourceFile-relative) time. Works at any nesting depth.
*/
expandedParentStart?: number;
}
export type ZoomMode = "fit" | "manual";
@@ -138,21 +146,22 @@ interface PlayerState {
/** Undo/redo stacks for beat edits (in-memory, session-only). */
beatUndo: BeatHistoryEntry[];
beatRedo: BeatHistoryEntry[];
/** Apply a beat edit and record it for undo. */
commitBeatEdits: (next: BeatEditState | null, label: string) => void;
/** Undo/redo the most recent beat edit; returns its label or null if none. */
undoBeatEdits: () => string | null;
redoBeatEdits: () => string | null;
/** Clear beat edit history (e.g. when the music track changes). */
resetBeatHistory: () => void;
/** Callback that persists current beats to disk; registered by the analysis hook. */
beatPersist: (() => void) | null;
setBeatPersist: (fn: (() => void) | null) => void;
clipManifest: ClipManifestClip[] | null;
setClipManifest: (clips: ClipManifestClip[] | null) => void;
clipParentMap: Map<string, string>;
setClipParentMap: (map: Map<string, string>) => void;
}
interface BeatHistoryEntry {
restore: BeatEditState | null; // state to restore when this entry is applied
at: number; // original edit timestamp (for global undo ordering)
restore: BeatEditState | null;
at: number;
label: string;
}
@@ -271,6 +280,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return entry.label;
},
clipManifest: null,
setClipManifest: (clips) => set({ clipManifest: clips }),
clipParentMap: new Map(),
setClipParentMap: (map) => set({ clipParentMap: map }),
setIsPlaying: (playing) => {
if (get().isPlaying === playing) return;
set({ isPlaying: playing });
@@ -338,12 +352,12 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
selectedKeyframes: new Set(),
selectedElementIds: new Set(),
keyframeCache: new Map(),
// Beat state is project-specific — clear it so a project switch can't
// apply the previous project's beats/undo/persist to the new one.
beatAnalysis: null,
beatEdits: null,
beatUndo: [],
beatRedo: [],
beatPersist: null,
clipManifest: null,
clipParentMap: new Map(),
}),
}));