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
262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
|
import {
|
|
classifyPropertyGroup,
|
|
type GsapAnimation,
|
|
type PropertyGroupName,
|
|
} from "@hyperframes/core/gsap-parser";
|
|
import { toClipKeyframes } from "../../hooks/gsapShared";
|
|
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
|
|
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
|
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
|
|
export interface TimelinePropertyLanesProps {
|
|
/**
|
|
* Id of the wrapper below, so the layer's disclosure caret can point
|
|
* `aria-controls` at the lanes a sighted user sees it reveal. Minted by
|
|
* TimelineLanes, which owns both this subtree and the caret's.
|
|
*/
|
|
id?: string;
|
|
animations: readonly GsapAnimation[];
|
|
clipStart: number;
|
|
clipDuration: number;
|
|
clipLeftPx: number;
|
|
clipWidthPx: number;
|
|
accentColor: string;
|
|
isSelected: boolean;
|
|
currentPercentage: number;
|
|
elementId: string;
|
|
selectedKeyframes: ReadonlySet<string>;
|
|
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
|
|
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
|
suppressClickRef?: RefObject<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Keys that ride along in a tween's property bag without being animated: a
|
|
* transform modifier, Studio's internal endpoint marker, and GSAP's reserved
|
|
* `data`. Same exclusion list the parser's classifyTweenPropertyGroup applies —
|
|
* without it `{ x, transformOrigin }` would draw a spurious "Other" lane.
|
|
*/
|
|
const NON_ANIMATED_PROPERTIES = new Set(["transformOrigin", "_auto", "data"]);
|
|
|
|
function isAnimatedProperty(property: string): boolean {
|
|
return !NON_ANIMATED_PROPERTIES.has(property);
|
|
}
|
|
|
|
function hasGroupProperty(
|
|
properties: Record<string, number | string>,
|
|
group: PropertyGroupName,
|
|
): boolean {
|
|
return Object.keys(properties).some(
|
|
(property) => isAnimatedProperty(property) && classifyPropertyGroup(property) === group,
|
|
);
|
|
}
|
|
|
|
/** The tween's editable keyframes: its real keyframes, or the start→end pair
|
|
* synthesized for a flat tween. Empty for a tween that animates nothing. */
|
|
function animationKeyframes(animation: GsapAnimation) {
|
|
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
|
|
}
|
|
|
|
/**
|
|
* Every property group a tween draws a lane for, classified PER PROPERTY.
|
|
* `animation.propertyGroup` is the parser's whole-tween verdict and is
|
|
* `undefined` for anything spanning more than one group — but `{ x, opacity }`
|
|
* is the canonical HyperFrames entrance tween, and reading that verdict gave it
|
|
* no caret, no reserved row and no diamonds. classifyPropertyGroup is total, so
|
|
* an unrecognised property still lands in "other" rather than vanishing.
|
|
*
|
|
* Single owner: the rendered lanes (sourceGroups) and the reserved row heights
|
|
* (computeLaneCounts) both count groups through here, or they drift.
|
|
*/
|
|
export function animationLaneGroups(animation: GsapAnimation): PropertyGroupName[] {
|
|
const groups = new Set<PropertyGroupName>();
|
|
for (const keyframe of animationKeyframes(animation)) {
|
|
for (const property of Object.keys(keyframe.properties)) {
|
|
if (isAnimatedProperty(property)) groups.add(classifyPropertyGroup(property));
|
|
}
|
|
}
|
|
return Array.from(groups);
|
|
}
|
|
|
|
/**
|
|
* Which tween a panel edit to `prop` belongs to.
|
|
*
|
|
* Matches on the groups the tween's KEYFRAMES animate, not on the parser's
|
|
* whole-tween `propertyGroup` verdict: that field is undefined for a legacy
|
|
* mixed tween such as `{ x, opacity }`, so matching it dropped every such
|
|
* tween and sent the edit to the selection's default animation instead, which
|
|
* is a different tween than the lane the user is looking at.
|
|
* {@link animationLaneGroups} is the single owner the rendered lanes count
|
|
* groups through, so resolving here through the same helper keeps the panel
|
|
* and the lanes on one answer.
|
|
*/
|
|
export function resolveAnimIdForProperty(
|
|
prop: string,
|
|
animations: readonly GsapAnimation[] | undefined,
|
|
fallbackAnimId: string | undefined,
|
|
): string {
|
|
const group = classifyPropertyGroup(prop);
|
|
const groupAnim = animations?.find((a) => animationLaneGroups(a).includes(group));
|
|
return groupAnim?.id ?? fallbackAnimId ?? "";
|
|
}
|
|
|
|
/** A tween contributes a property lane when it animates at least one property
|
|
* on at least one editable keyframe (real or synthesized). */
|
|
export function animationContributesLane(animation: GsapAnimation): boolean {
|
|
return animationLaneGroups(animation).length > 0;
|
|
}
|
|
|
|
function sourceGroups(animations: readonly GsapAnimation[]) {
|
|
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
|
|
for (const animation of animations) {
|
|
for (const group of animationLaneGroups(animation)) {
|
|
const groupAnimations = groups.get(group) ?? [];
|
|
groupAnimations.push(animation);
|
|
groups.set(group, groupAnimations);
|
|
}
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/** Resolve the ease from THIS keyframe's own source tween. A lane can merge
|
|
* several tweens, so a shared lane-level fallback would label a segment with a
|
|
* different animation's ease than the one the ease editor targets (it routes
|
|
* by animationId). */
|
|
function keyframeEase(keyframe: { ease?: string }, animation: GsapAnimation): string | undefined {
|
|
return keyframe.ease ?? animation.keyframes?.easeEach ?? animation.ease;
|
|
}
|
|
|
|
/**
|
|
* One lane row per keyframe of `group`. The clip-% re-basing goes through the
|
|
* shared toClipKeyframes so lane rows land on the exact same percentage the
|
|
* keyframe cache writes: this file used to derive it inline and skipped that
|
|
* helper's rounding, which is the one precision every keyframe-cache writer has
|
|
* to agree on (selection keys embed the number).
|
|
*/
|
|
function groupKeyframes(
|
|
animations: readonly GsapAnimation[],
|
|
group: PropertyGroupName,
|
|
clipStart: number,
|
|
clipDuration: number,
|
|
): TimelineDiamondKeyframe[] {
|
|
const keyframes: TimelineDiamondKeyframe[] = [];
|
|
for (const animation of animations) {
|
|
const inGroup = animationKeyframes(animation).filter((keyframe) =>
|
|
hasGroupProperty(keyframe.properties, group),
|
|
);
|
|
for (const keyframe of toClipKeyframes(inGroup, animation, clipStart, clipDuration)) {
|
|
keyframes.push({
|
|
...keyframe,
|
|
// The LANE's group, not the tween's own classification: a mixed-property
|
|
// tween classifies to undefined yet still feeds every group it touches.
|
|
propertyGroup: group,
|
|
ease: keyframeEase(keyframe, animation),
|
|
});
|
|
}
|
|
}
|
|
return keyframes;
|
|
}
|
|
|
|
export function getTimelinePropertyLanes(
|
|
animations: readonly GsapAnimation[],
|
|
clipStart: number,
|
|
clipDuration: number,
|
|
) {
|
|
if (clipDuration <= 0) return [];
|
|
return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
|
|
group,
|
|
animations: groupAnimations,
|
|
keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
|
|
})).filter((lane) => lane.keyframes.length > 0);
|
|
}
|
|
|
|
export function TimelinePropertyLanes({
|
|
id,
|
|
animations,
|
|
clipStart,
|
|
clipDuration,
|
|
clipLeftPx,
|
|
clipWidthPx,
|
|
accentColor,
|
|
isSelected,
|
|
currentPercentage,
|
|
elementId,
|
|
selectedKeyframes,
|
|
onSelectSegment,
|
|
onClickKeyframe,
|
|
onShiftClickKeyframe,
|
|
onContextMenuKeyframe,
|
|
onMoveKeyframe,
|
|
suppressClickRef,
|
|
}: TimelinePropertyLanesProps) {
|
|
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
|
|
// a fresh keyframesData literal per lane) on every render would re-render every
|
|
// diamond in every expanded clip on each playhead tick.
|
|
const lanes = useMemo(
|
|
() =>
|
|
clipWidthPx < 20 || clipDuration <= 0
|
|
? []
|
|
: getTimelinePropertyLanes(animations, clipStart, clipDuration),
|
|
[animations, clipStart, clipDuration, clipWidthPx],
|
|
);
|
|
const laneData = useMemo(
|
|
() =>
|
|
lanes.map((lane) => ({
|
|
...lane,
|
|
keyframesData: { format: "percentage" as const, keyframes: lane.keyframes },
|
|
})),
|
|
[lanes],
|
|
);
|
|
|
|
// One STATIC wrapper, never `relative`: a static box establishes no containing
|
|
// block, so every absolutely-positioned lane below still resolves against the
|
|
// track-content div and the rendered geometry is byte-identical to the bare
|
|
// fragment this replaced. It is also rendered when there are no lanes at all
|
|
// (collapsed layer), so `id` stays resolvable in both disclosure states.
|
|
return (
|
|
<div id={id}>
|
|
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
|
<div
|
|
key={group}
|
|
role="group"
|
|
aria-label={`${group} keyframes`}
|
|
data-property-group={group}
|
|
data-timeline-property-lane=""
|
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
className="absolute"
|
|
style={{
|
|
left: clipLeftPx,
|
|
top: getTimelineLaneTop(laneIndex),
|
|
width: clipWidthPx,
|
|
height: LANE_H,
|
|
}}
|
|
>
|
|
<TimelineDiamondLane
|
|
keyframesData={keyframesData}
|
|
clipWidthPx={clipWidthPx}
|
|
clipHeightPx={LANE_H}
|
|
clipDuration={clipDuration}
|
|
accentColor={accentColor}
|
|
isSelected={isSelected}
|
|
currentPercentage={currentPercentage}
|
|
elementId={elementId}
|
|
selectedKeyframes={selectedKeyframes}
|
|
onSelectSegment={onSelectSegment}
|
|
onClickKeyframe={onClickKeyframe}
|
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
suppressClickRef={suppressClickRef}
|
|
groupAware
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|