mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
* perf(studio): define timeline viewport budgets and fixtures * test(studio): gate timeline viewport performance in Chromium * refactor(studio): isolate clip drag lifecycle * refactor(studio): extract timeline render contracts * perf(studio): centralize timeline viewport geometry * perf(studio): follow playhead across virtualized rows * perf(studio): add timeline clip-window index primitive * perf(studio): virtualize timeline clip windows * perf(studio): stop timeline scroll work when row virtualization is off The row virtualization stack made the timeline publish a viewport snapshot on every scroll frame and swap `renderClipContent` across every mounted clip at gesture start and settle. Both are windowing concessions, and neither was gated on the flag, so the build users actually run paid for them while mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247. Gate both on the row virtualization flag. The scroll path now stops at the door when the flag is off, so `isScrolling` stays false and resize-driven and programmatic syncs still publish through the immediate path. The flag moves into its own module: the scroll-viewport hook needs to read it, and the virtualization hook already imports the viewport snapshot type back, which would have closed an import cycle. Also release the perf fixture lease from the fixture rather than from the test-hook effect. Loading a fixture writes player state, which changed that effect's dependency identities and tore it down on the next frame, so the lease was revoked moments after it was taken and live iframe discovery overwrote the fixture before the gate could measure it. The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements) next to the existing flag-on one. It refuses the 50,000-element combination, verifies from the mounted DOM that the server under test matches the requested flag, and skips the DOM-size budgets for the unvirtualized build rather than relaxing them, so a skipped budget never reads as a passed one. Verified against a live Studio dev server on the fixture project: flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass flag off, after: interactionP95 33.6ms, longest task 0ms, 5/5 runs pass flag on, after: interactionP95 33.2ms, 4/5 runs pass, exit 0 The flag-on arm's fourth run reproducibly reports a 55-58ms long task against a 50ms budget. That is the residual tail of the window swap itself, tracked separately and not addressed here. * ci(studio): run the timeline viewport gate on studio changes The gate has existed since the row virtualization stack landed but nothing under `.github/` referenced it, so it only ever ran when someone ran it by hand. That is how the flag-off scroll regression reached eight merged-ready PRs without anything noticing. Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one per flag state, and runs both arms of the gate against them. Two servers are needed because row virtualization is read from `import.meta.env` at module load, so one process cannot serve both builds. Scoped to a new `studio` paths filter rather than the broad `code` one: the gate only says anything about `packages/studio`, `packages/core` and `packages/studio-server`. Adds a `ci` tier. It applies the constrained budgets without any emulation, because a hosted runner is already slower and noisier than the machine the strict numbers were recorded on, while the existing `low-resource` tier would throttle it a further 4x and measure the throttle rather than the build. The fixture composition is tracked under `tests/e2e/fixtures` but Studio resolves projects from the gitignored `data/projects`, so the job copies it into place instead of a project directory being committed. Both arms run in about 7 seconds each locally, so the job cost is almost entirely dependency install and the workspace build it shares with `studio-load-smoke`. * fix(ci): preserve both timeline gate evidence arms * ci(studio): report timeline gate arm statuses * ci(studio): require timeline gate evidence artifacts * fix(studio): keep dense keyframes readable * fix(ci): resolve timeline stack audit findings
583 lines
27 KiB
TypeScript
583 lines
27 KiB
TypeScript
import { Fragment, useId } from "react";
|
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
import { TimelineClip } from "./TimelineClip";
|
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
|
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
|
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout";
|
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
import {
|
|
isMultiDragActive,
|
|
isMultiDragPassenger,
|
|
multiDragDeltaSeconds,
|
|
type MultiDragPreviewInput,
|
|
multiDragPassengerOffsetPx,
|
|
} from "./timelineMultiDragPreview";
|
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
|
|
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
draggedElement: TimelineElement | null;
|
|
multiDragPreview: MultiDragPreviewInput | null;
|
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
}
|
|
|
|
export function TimelineLanes({
|
|
pps,
|
|
contentOrigin,
|
|
contentGutter,
|
|
trackContentWidth,
|
|
theme,
|
|
displayTrackOrder,
|
|
rowGeometry,
|
|
virtualRows,
|
|
rowsVirtualized,
|
|
clipIndex,
|
|
renderTimeRange,
|
|
pinnedClipIdentities,
|
|
trackOrder,
|
|
tracks,
|
|
trackStyles,
|
|
laneCounts,
|
|
selectedElementId,
|
|
selectedElementIds,
|
|
hoveredClip,
|
|
draggedClip,
|
|
draggedElement,
|
|
multiDragPreview,
|
|
blockedClipRef,
|
|
suppressClickRef,
|
|
scrollRef,
|
|
renderClipContent,
|
|
renderClipOverlay,
|
|
onDrillDown,
|
|
onSelectElement,
|
|
setHoveredClip,
|
|
setShowPopover,
|
|
setRangeSelection,
|
|
setResizingClip,
|
|
setDraggedClip,
|
|
setSelectedElementId,
|
|
syncClipDragAutoScroll,
|
|
shiftClickClipRef,
|
|
getPreviewElement,
|
|
getTrackStyle,
|
|
keyframeCache,
|
|
gsapAnimations,
|
|
selectedKeyframes,
|
|
currentTime,
|
|
onSeek,
|
|
onSelectSegment,
|
|
onClickKeyframe,
|
|
onShiftClickKeyframe,
|
|
onContextMenuKeyframe,
|
|
onMoveKeyframe,
|
|
onContextMenuClip,
|
|
onContextMenuLane,
|
|
beatAnalysis,
|
|
onToggleTrackHidden,
|
|
onTogglePropertyGroupKeyframe,
|
|
onResizeElement,
|
|
onMoveElement,
|
|
onRazorSplit,
|
|
onRazorSplitAll,
|
|
}: TimelineLanesProps) {
|
|
// Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
|
|
// beside the main one) cannot both mint `...-track-0` and have every caret's
|
|
// aria-controls resolve to whichever mounted first. React's useId embeds
|
|
// colons, which are legal in an id and in aria-controls but need escaping in
|
|
// a CSS `#id` selector, so they come out here and the prefix stays plain.
|
|
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
const toggleClipExpandedTracked = (key: string) => {
|
|
const willExpand = !expandedClipIds.has(key);
|
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
toggleClipExpanded(key);
|
|
};
|
|
const multiDragDelta =
|
|
multiDragPreview && isMultiDragActive(multiDragPreview)
|
|
? multiDragDeltaSeconds(multiDragPreview)
|
|
: 0;
|
|
const actorWindows =
|
|
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
|
|
? [
|
|
{
|
|
range: {
|
|
start: renderTimeRange.start - multiDragDelta,
|
|
end: renderTimeRange.end - multiDragDelta,
|
|
},
|
|
identities: multiDragPreview.selectedKeys,
|
|
},
|
|
]
|
|
: [];
|
|
return (
|
|
<div
|
|
role="list"
|
|
aria-label="Timeline tracks"
|
|
className={rowsVirtualized ? "absolute inset-0" : undefined}
|
|
>
|
|
{
|
|
// fallow-ignore-next-line complexity
|
|
virtualRows.map(({ index: row, rowKey }) => {
|
|
const trackNum = displayTrackOrder[row];
|
|
if (trackNum === undefined) return null;
|
|
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
const rowHeight = rowGeometry.getRowHeight(row);
|
|
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
const renderElements = rowsVirtualized
|
|
? queryTimelineClipIndex(
|
|
clipIndex,
|
|
trackNum,
|
|
renderTimeRange,
|
|
pinnedClipIdentities,
|
|
actorWindows,
|
|
)
|
|
: els;
|
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
const isPendingTrack =
|
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
// All lanes use the same uniform color — no alternating stripes.
|
|
const rowBackground = theme.rowBackground;
|
|
// The beat-dot strip occupies the top of this track's lane (active track,
|
|
// or the music track when nothing is selected). When shown, keyframe
|
|
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
const beatStripOnTrack =
|
|
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
|
(selectedElementId
|
|
? els.some((e) => (e.key ?? e.id) === selectedElementId)
|
|
: els.some(isMusicTrack));
|
|
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
|
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
|
// The one keyframed element this track shows lanes for (selected, else
|
|
// most lanes). A track can hold several elements; scoping to one keeps
|
|
// their keyframes from cramming into a single row.
|
|
const keyframeClip = resolveTrackKeyframeClip(
|
|
els,
|
|
laneCounts,
|
|
selectedElementId,
|
|
selectedElementIds,
|
|
);
|
|
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
|
const keyframeClipExpanded =
|
|
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
|
|
// Minted here because this is the only place that sees BOTH ends of
|
|
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
|
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
|
return (
|
|
<TimelineTrackRow
|
|
key={rowKey}
|
|
index={row}
|
|
rowKey={rowKey}
|
|
rowCount={displayTrackOrder.length}
|
|
top={rowGeometry.getRowTop(row)}
|
|
height={rowHeight}
|
|
virtualized={rowsVirtualized}
|
|
background={rowBackground}
|
|
borderColor={theme.rowBorder}
|
|
>
|
|
<TimelineTrackHeader
|
|
trackNumber={trackNum}
|
|
// What gets announced. `trackNum` is a fractional z-order sort
|
|
// key, so it stays out of every label and in every callback.
|
|
trackDisplayNumber={displayNumber}
|
|
trackLabel={
|
|
els[0]?.label ??
|
|
els[0]?.domId ??
|
|
els[0]?.id ??
|
|
`Track${trackDisplaySuffix(displayNumber)}`
|
|
}
|
|
lanesId={lanesId}
|
|
contentOrigin={contentOrigin}
|
|
keyframeClip={keyframeClip}
|
|
clipCount={els.length}
|
|
isExpanded={keyframeClipExpanded}
|
|
animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
|
|
currentTime={currentTime}
|
|
isTrackHidden={isTrackHidden}
|
|
isAudioTrack={isAudioTrack}
|
|
theme={theme}
|
|
onToggleClipExpanded={() => {
|
|
if (keyframeClipKey) {
|
|
toggleClipExpandedTracked(keyframeClipKey);
|
|
}
|
|
}}
|
|
onToggleTrackHidden={onToggleTrackHidden}
|
|
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
onSeek={onSeek}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: trackContentWidth,
|
|
marginLeft: contentGutter, // room for a 0% diamond left of t=0
|
|
opacity: isTrackHidden ? 0.35 : 1,
|
|
transition: "opacity 120ms ease",
|
|
}}
|
|
className="relative"
|
|
onContextMenu={(e: React.MouseEvent) => {
|
|
// Clip / keyframe-diamond context menus preventDefault at the
|
|
// target before this bubble handler runs — respect them so a
|
|
// right-click on a clip never also opens the gap menu.
|
|
if (e.defaultPrevented || !onContextMenuLane) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const time = (e.clientX - rect.left) / pps;
|
|
if (time < 0) return;
|
|
e.preventDefault();
|
|
onContextMenuLane(e, trackNum, time);
|
|
}}
|
|
>
|
|
{/* Faint beat lines in every track's background (behind the clips);
|
|
the active move-snap target is highlighted. */}
|
|
<BeatBackgroundLines
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
pps={pps}
|
|
highlightTime={
|
|
draggedClip?.started && draggedClip.snapType === "beat"
|
|
? draggedClip.snapTime
|
|
: null
|
|
}
|
|
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
/>
|
|
{/* Beat dots on the active track (the one holding the selection),
|
|
falling back to the music track when nothing is selected. */}
|
|
{beatStripOnTrack && (
|
|
<BeatStrip
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
pps={pps}
|
|
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
/>
|
|
)}
|
|
{isPendingTrack && (
|
|
<div
|
|
className="absolute inset-0 flex items-center"
|
|
style={{
|
|
paddingLeft: 16,
|
|
color: ts.label,
|
|
fontSize: 11,
|
|
letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
New track
|
|
</div>
|
|
)}
|
|
{
|
|
// fallow-ignore-next-line complexity
|
|
renderElements.map((el) => {
|
|
const clipStyle = getTrackStyle(el.tag);
|
|
const elementKey = getTimelineElementIdentity(el);
|
|
// Only the track's active keyframe clip shows expanded lanes;
|
|
// other clips (incl. siblings on a shared track) show compact
|
|
// diamonds on their own bar instead.
|
|
const isTrackKeyframeClip = elementKey === keyframeClipKey;
|
|
const showsLanes = isTrackKeyframeClip && keyframeClipExpanded;
|
|
const capabilities = getTimelineEditCapabilities(el);
|
|
const isSelected =
|
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
const isComposition = !!el.compositionSrc;
|
|
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
|
|
// fold in the map index, or a splice/reorder remounts every clip
|
|
// at/after the change (DOM flash, drag interruption).
|
|
const clipKey = elementKey;
|
|
const isDraggingClip =
|
|
draggedClip?.started === true &&
|
|
draggedElement != null &&
|
|
getTimelineElementIdentity(draggedElement) === elementKey;
|
|
if (isDraggingClip) return null;
|
|
const previewElement = getPreviewElement(el);
|
|
// Passenger of a live multi-drag: slide by the SAME formation
|
|
// delta (the grabbed clip's group-clamped delta) via a
|
|
// compositor transform on a same-geometry wrapper (absolute
|
|
// inset-0 → identical offset parent, so the clip's own
|
|
// left/top are preserved), plus the ghost's elevated z/opacity.
|
|
const isPassenger =
|
|
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
|
|
const passengerOffsetPx = isPassenger
|
|
? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
|
|
: 0;
|
|
const clip = (
|
|
<TimelineClip
|
|
key={clipKey}
|
|
onContextMenu={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
onContextMenuClip?.(e, el);
|
|
}}
|
|
el={previewElement}
|
|
pps={pps}
|
|
clipY={CLIP_Y}
|
|
clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined}
|
|
isSelected={isSelected}
|
|
isHovered={hoveredClip === clipKey}
|
|
isDragging={false}
|
|
isActive={isTimelineClipActive(previewElement, currentTime)}
|
|
hasCustomContent={!!renderClipContent}
|
|
capabilities={capabilities}
|
|
theme={theme}
|
|
isComposition={isComposition}
|
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
onHoverEnd={() => setHoveredClip(null)}
|
|
onResizeStart={
|
|
// fallow-ignore-next-line complexity
|
|
(edge, e) => {
|
|
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
if (edge === "end" && !capabilities.canTrimEnd) return;
|
|
e.stopPropagation();
|
|
blockedClipRef.current = null;
|
|
setShowPopover(false);
|
|
setRangeSelection(null);
|
|
setResizingClip({
|
|
element: el,
|
|
edge,
|
|
originClientX: e.clientX,
|
|
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
previewStart: el.start,
|
|
previewDuration: el.duration,
|
|
previewPlaybackStart: el.playbackStart,
|
|
started: false,
|
|
});
|
|
}
|
|
}
|
|
onPointerDown={
|
|
// fallow-ignore-next-line complexity
|
|
(e) => {
|
|
if (e.button !== 0) return;
|
|
if (usePlayerStore.getState().activeTool === "razor") return;
|
|
if (e.shiftKey) {
|
|
shiftClickClipRef.current = {
|
|
element: el,
|
|
anchorX: e.clientX,
|
|
anchorY: e.clientY,
|
|
};
|
|
return;
|
|
}
|
|
const target = e.currentTarget as HTMLElement;
|
|
const rect = target.getBoundingClientRect();
|
|
const blockedIntent = resolveBlockedTimelineEditIntent({
|
|
width: rect.width,
|
|
offsetX: e.clientX - rect.left,
|
|
handleWidth: CLIP_HANDLE_W,
|
|
capabilities,
|
|
});
|
|
if (
|
|
blockedIntent &&
|
|
((blockedIntent === "move" && onMoveElement) ||
|
|
(blockedIntent !== "move" && onResizeElement))
|
|
) {
|
|
blockedClipRef.current = {
|
|
element: el,
|
|
intent: blockedIntent,
|
|
originClientX: e.clientX,
|
|
originClientY: e.clientY,
|
|
started: false,
|
|
};
|
|
return;
|
|
}
|
|
if (!onMoveElement || !capabilities.canMove) return;
|
|
blockedClipRef.current = null;
|
|
setShowPopover(false);
|
|
setRangeSelection(null);
|
|
setDraggedClip({
|
|
element: el,
|
|
originClientX: e.clientX,
|
|
originClientY: e.clientY,
|
|
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
pointerClientX: e.clientX,
|
|
pointerClientY: e.clientY,
|
|
pointerOffsetX: e.clientX - rect.left,
|
|
pointerOffsetY: e.clientY - rect.top,
|
|
previewStart: el.start,
|
|
previewTrack: el.track,
|
|
desiredTrack: el.track,
|
|
insertRow: null,
|
|
snapTime: null,
|
|
snapType: null,
|
|
started: false,
|
|
});
|
|
syncClipDragAutoScroll(e.clientX, e.clientY);
|
|
}
|
|
}
|
|
onClick={
|
|
// fallow-ignore-next-line complexity
|
|
(e) => {
|
|
e.stopPropagation();
|
|
if (suppressClickRef.current) return;
|
|
const { activeTool } = usePlayerStore.getState();
|
|
if (activeTool === "razor" && onRazorSplit) {
|
|
const clipRect = (
|
|
e.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
const clickOffsetX = e.clientX - clipRect.left;
|
|
const splitTime = previewElement.start + clickOffsetX / pps;
|
|
const clampedTime = Math.max(
|
|
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
Math.min(
|
|
previewElement.start +
|
|
previewElement.duration -
|
|
SPLIT_BOUNDARY_EPSILON_S,
|
|
splitTime,
|
|
),
|
|
);
|
|
if (e.shiftKey && onRazorSplitAll) {
|
|
onRazorSplitAll(clampedTime);
|
|
} else {
|
|
onRazorSplit(el, clampedTime);
|
|
}
|
|
return;
|
|
}
|
|
// Plain click single-selects: drop any marquee multi-selection.
|
|
// Only a click on the PRIMARY selection toggles it off — a click
|
|
// on a marquee-selected clip narrows the selection to that clip.
|
|
const hadMultiSelection = selectedElementIds.size > 0;
|
|
usePlayerStore.getState().clearSelectedElementIds();
|
|
const nextElement =
|
|
selectedElementId === elementKey && !hadMultiSelection ? null : el;
|
|
setSelectedElementId(nextElement ? elementKey : null);
|
|
onSelectElement?.(nextElement);
|
|
}
|
|
}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
if (suppressClickRef.current) return;
|
|
if (isComposition && onDrillDown) onDrillDown(el);
|
|
}}
|
|
>
|
|
{renderClipChildren(
|
|
previewElement,
|
|
clipStyle,
|
|
renderClipContent,
|
|
renderClipOverlay,
|
|
)}
|
|
{!showsLanes && keyframeCache?.get(elementKey) && (
|
|
<TimelineClipDiamonds
|
|
keyframesData={keyframeCache.get(elementKey)!}
|
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
clipHeightPx={rowHeight - 2 * CLIP_Y}
|
|
clipDuration={previewElement.duration}
|
|
beatsActive={beatStripOnTrack}
|
|
accentColor={clipStyle.accent}
|
|
isSelected={isSelected}
|
|
currentPercentage={
|
|
previewElement.duration > 0
|
|
? ((currentTime - previewElement.start) / previewElement.duration) *
|
|
100
|
|
: 0
|
|
}
|
|
elementId={elementKey}
|
|
selectedKeyframes={selectedKeyframes}
|
|
onClickKeyframe={(_elId, target) =>
|
|
onClickKeyframe?.(previewElement, target)
|
|
}
|
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
onSelectSegment={onSelectSegment}
|
|
suppressClickRef={suppressClickRef}
|
|
/>
|
|
)}
|
|
</TimelineClip>
|
|
);
|
|
// Mounted for the track's keyframe clip in BOTH disclosure
|
|
// states, so the header caret's aria-controls resolves while
|
|
// collapsed too; collapsed just feeds it no animations, so
|
|
// the wrapper renders empty. The key is stable across a
|
|
// multi-drag: without it the passenger branch below remounts
|
|
// this subtree and interrupts the gesture.
|
|
const propertyLanes = isTrackKeyframeClip && (
|
|
<TimelinePropertyLanes
|
|
key={`${clipKey}-property-lanes`}
|
|
id={lanesId}
|
|
animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
|
|
// clipTimingStart, not the raw start: an expanded sub-comp
|
|
// child's start is host-absolute while its tweens are
|
|
// local to its own file.
|
|
clipStart={clipTimingStart(previewElement)}
|
|
clipDuration={previewElement.duration}
|
|
clipLeftPx={previewElement.start * pps}
|
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
accentColor={clipStyle.accent}
|
|
isSelected={isSelected}
|
|
currentPercentage={
|
|
previewElement.duration > 0
|
|
? ((currentTime - previewElement.start) / previewElement.duration) * 100
|
|
: 0
|
|
}
|
|
elementId={elementKey}
|
|
selectedKeyframes={selectedKeyframes}
|
|
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
|
|
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
|
|
onShiftClickKeyframe={(target) =>
|
|
onShiftClickKeyframe?.(elementKey, target)
|
|
}
|
|
onContextMenuKeyframe={(e, target) =>
|
|
onContextMenuKeyframe?.(e, elementKey, target)
|
|
}
|
|
onMoveKeyframe={(target, toClipPercentage) =>
|
|
onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
|
|
Promise.resolve(false)
|
|
}
|
|
suppressClickRef={suppressClickRef}
|
|
/>
|
|
);
|
|
// Keep one keyed top-level child per element. Returning an
|
|
// array here makes React reconcile the outer array by
|
|
// position, so a window shift remounts otherwise stable
|
|
// clip keys and can tear down focus mid-reveal.
|
|
if (!isPassenger) {
|
|
return (
|
|
<Fragment key={clipKey}>
|
|
{clip}
|
|
{propertyLanes}
|
|
</Fragment>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
key={clipKey}
|
|
className="absolute inset-0"
|
|
style={{
|
|
transform: `translateX(${passengerOffsetPx}px)`,
|
|
opacity: 0.85,
|
|
zIndex: 20,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{clip}
|
|
{propertyLanes}
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
</TimelineTrackRow>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
);
|
|
}
|