mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
fix(studio): lane every tween and attribute tweens to their real target
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.
Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.
Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.
Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
This commit is contained in:
@@ -6,6 +6,7 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
||||
@@ -527,7 +528,10 @@ export function TimelineLanes({
|
||||
<TimelinePropertyLanes
|
||||
key={`${clipKey}-property-lanes`}
|
||||
animations={gsapAnimations.get(elementKey) ?? []}
|
||||
clipStart={previewElement.start}
|
||||
// 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)}
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
type TimelinePropertyLanesProps,
|
||||
} from "./TimelinePropertyLanes";
|
||||
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
||||
import { groupLabel } from "./trackHeaderLaneValues";
|
||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import { resolveTimelineKeyframeTarget } from "../../components/nle/useTimelineEditCallbacks";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -121,7 +124,111 @@ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
|
||||
{ percentage: 50, properties: { x: 50 } },
|
||||
]);
|
||||
|
||||
/** A tween the parser leaves unclassified because it spans several groups. */
|
||||
function ungroupedAnimation(
|
||||
id: string,
|
||||
properties: Record<string, number | string>,
|
||||
): GsapAnimation {
|
||||
return {
|
||||
id,
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TimelinePropertyLanes", () => {
|
||||
// `{ x, opacity }` is the canonical HyperFrames entrance tween. The parser
|
||||
// classifies it to `undefined` (two groups), which used to erase it from the
|
||||
// lanes entirely — no caret, no reserved row, nothing to edit.
|
||||
it("lanes a mixed-group tween once per group it animates", () => {
|
||||
const lanes = getTimelinePropertyLanes(
|
||||
[ungroupedAnimation("entrance", { x: 0, opacity: 1 })],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(lanes.map((lane) => lane.group).sort()).toEqual(["position", "visual"]);
|
||||
for (const lane of lanes) {
|
||||
expect(lane.keyframes.map((keyframe) => keyframe.percentage)).toEqual([0, 100]);
|
||||
expect(lane.keyframes.every((keyframe) => keyframe.animationId === "entrance")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("lanes a tween whose properties are all unknown as one 'other' lane", () => {
|
||||
const lanes = getTimelinePropertyLanes(
|
||||
[ungroupedAnimation("rounded", { borderRadius: 12, fontSize: 24 })],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0]?.group).toBe("other");
|
||||
expect(groupLabel("other", lanes[0]!.keyframes[0]!.properties)).toBe("BorderRadius");
|
||||
});
|
||||
|
||||
// An expanded sub-composition child sits on the MASTER timeline at a
|
||||
// host-absolute start while its tweens are parsed from its own file and are
|
||||
// local to it. clipTimingStart is what brings the two into one frame.
|
||||
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
||||
const child = { start: 16.5, duration: 2, expandedParentStart: 16 };
|
||||
const local = animation("pill-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]);
|
||||
local.position = 0.5;
|
||||
local.resolvedStart = 0.5;
|
||||
local.duration = 2;
|
||||
|
||||
const percentages = getTimelinePropertyLanes(
|
||||
[local],
|
||||
clipTimingStart(child),
|
||||
child.duration,
|
||||
).flatMap((lane) => lane.keyframes.map((keyframe) => keyframe.percentage));
|
||||
|
||||
expect(percentages).toHaveLength(2);
|
||||
for (const percentage of percentages) {
|
||||
expect(percentage).toBeGreaterThanOrEqual(0);
|
||||
expect(percentage).toBeLessThanOrEqual(100);
|
||||
}
|
||||
// Falsifier: the raw host-absolute start is what used to be passed.
|
||||
expect(
|
||||
getTimelinePropertyLanes([local], child.start, child.duration)[0]?.keyframes[0]?.percentage,
|
||||
).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("still lanes a single-group tween exactly once", () => {
|
||||
const lanes = getTimelinePropertyLanes(
|
||||
[animation("position-tween", "position", [{ percentage: 0, properties: { x: 0, y: 0 } }])],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(lanes.map((lane) => lane.group)).toEqual(["position"]);
|
||||
});
|
||||
|
||||
// A lane can merge several tweens, so an edit routed from it must carry the
|
||||
// clicked keyframe's own animation identity — group matching alone is
|
||||
// ambiguous once two tweens feed the same lane.
|
||||
it("routes a mixed-tween lane edit to the tween that owns the keyframe", () => {
|
||||
const mixed = ungroupedAnimation("entrance", { x: 40, opacity: 1 });
|
||||
const sibling = animation("drift", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 9 } },
|
||||
]);
|
||||
const lanes = getTimelinePropertyLanes([mixed, sibling], 0, 1);
|
||||
const position = lanes.find((lane) => lane.group === "position");
|
||||
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(100, position?.keyframes ?? [], [
|
||||
{ id: "entrance" },
|
||||
{ id: "drift", propertyGroup: "position" },
|
||||
]),
|
||||
).toEqual({ animId: "entrance", tweenPct: 100 });
|
||||
});
|
||||
|
||||
it("returns a position lane with synthesized endpoints for a flat tween", () => {
|
||||
const lanes = getTimelinePropertyLanes(
|
||||
[flatAnimation("position-tween", "position", { x: 420 })],
|
||||
|
||||
@@ -29,11 +29,25 @@ export interface TimelinePropertyLanesProps {
|
||||
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) => classifyPropertyGroup(property) === group);
|
||||
return Object.keys(properties).some(
|
||||
(property) => isAnimatedProperty(property) && classifyPropertyGroup(property) === group,
|
||||
);
|
||||
}
|
||||
|
||||
/** The tween's editable keyframes: its real keyframes, or the start→end pair
|
||||
@@ -42,19 +56,41 @@ function animationKeyframes(animation: GsapAnimation) {
|
||||
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
|
||||
}
|
||||
|
||||
/** A tween contributes a property lane when it has a group and at least one
|
||||
* editable keyframe (real or synthesized). */
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 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 !!animation.propertyGroup && animationKeyframes(animation).length > 0;
|
||||
return animationLaneGroups(animation).length > 0;
|
||||
}
|
||||
|
||||
function sourceGroups(animations: readonly GsapAnimation[]) {
|
||||
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
|
||||
for (const animation of animations) {
|
||||
if (!animation.propertyGroup || !animationContributesLane(animation)) continue;
|
||||
const groupAnimations = groups.get(animation.propertyGroup) ?? [];
|
||||
groupAnimations.push(animation);
|
||||
groups.set(animation.propertyGroup, groupAnimations);
|
||||
for (const group of animationLaneGroups(animation)) {
|
||||
const groupAnimations = groups.get(group) ?? [];
|
||||
groupAnimations.push(animation);
|
||||
groups.set(group, groupAnimations);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ const OPACITY = animation("opacity-tween", "visual", [
|
||||
]);
|
||||
|
||||
interface RenderHeaderOptions {
|
||||
keyframeClip?: TimelineElement;
|
||||
animations?: GsapAnimation[];
|
||||
clipCount?: number;
|
||||
currentTime?: number;
|
||||
@@ -83,7 +84,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
||||
trackNumber={0}
|
||||
trackLabel="Hero card"
|
||||
contentOrigin={LABEL_COL_W}
|
||||
keyframeClip={ELEMENT}
|
||||
keyframeClip={next.keyframeClip ?? ELEMENT}
|
||||
clipCount={next.clipCount ?? 1}
|
||||
isExpanded={next.expanded !== false}
|
||||
animations={next.animations ?? [POSITION, OPACITY]}
|
||||
@@ -110,6 +111,53 @@ function click(host: HTMLElement, label: string) {
|
||||
}
|
||||
|
||||
describe("TimelineTrackHeader", () => {
|
||||
// An expanded sub-composition child sits on the MASTER timeline at a
|
||||
// host-absolute start, but its tweens are parsed from its own file and are
|
||||
// local to it. Feeding the raw start straight into the clip-% math put every
|
||||
// lane keyframe far outside the clip.
|
||||
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
||||
const child: TimelineElement = {
|
||||
id: "pill",
|
||||
tag: "div",
|
||||
start: 16.5,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
expandedParentStart: 16,
|
||||
sourceFile: "scene.html",
|
||||
};
|
||||
const local: GsapAnimation = {
|
||||
id: "pill-tween",
|
||||
targetSelector: "#pill",
|
||||
method: "to",
|
||||
position: 0.5,
|
||||
resolvedStart: 0.5,
|
||||
duration: 2,
|
||||
properties: {},
|
||||
propertyGroup: "position",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Playhead at the clip's midpoint (master time), so the 100% keyframe is
|
||||
// ahead of it. On the raw host-absolute basis every keyframe rebased to a
|
||||
// large negative percentage and nothing was ever ahead of the playhead.
|
||||
const view = renderHeader({
|
||||
keyframeClip: child,
|
||||
animations: [local],
|
||||
currentTime: 17.5,
|
||||
});
|
||||
|
||||
expect(
|
||||
view.host.querySelector<HTMLButtonElement>('button[aria-label="Next Position keyframe"]')
|
||||
?.disabled,
|
||||
).toBe(false);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
// The header shows one clip's lanes, so how many clips the track holds is
|
||||
// otherwise invisible from the label column. A single-clip track stays silent.
|
||||
it("shows the track's clip count only once the track holds more than one clip", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Music } from "../../icons/SystemIcons";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
||||
import { TrackClipCount } from "./TrackClipCount";
|
||||
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
||||
@@ -281,7 +282,9 @@ export function TimelineTrackHeader({
|
||||
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
||||
: 0;
|
||||
const lanes = keyframeClip
|
||||
? getTimelinePropertyLanes(animations, keyframeClip.start, keyframeClip.duration)
|
||||
? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
|
||||
// host-absolute while its tweens are local to its own file.
|
||||
getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
|
||||
: [];
|
||||
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
|
||||
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { LANE_H, TRACK_H } from "./timelineLayout";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -50,4 +51,38 @@ describe("useTimelineTrackLayout", () => {
|
||||
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// The row height reserved here and the lanes actually rendered are two
|
||||
// readings of the same question. They used to be two inline copies of the
|
||||
// group-set rule, and a mixed-group tween made them disagree: zero reserved
|
||||
// rows under two rendered lanes.
|
||||
it("reserves exactly as many rows as the lanes a mixed-group tween renders", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
];
|
||||
const mixed: GsapAnimation = {
|
||||
id: "entrance",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: { x: 420, opacity: 1 },
|
||||
};
|
||||
const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
|
||||
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
||||
|
||||
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
||||
function Probe() {
|
||||
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
|
||||
expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
|
||||
expect(layout?.laneCounts.get("clip-1")).toBe(2);
|
||||
expect(layout?.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
import { animationLaneGroups } from "./TimelinePropertyLanes";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
@@ -56,9 +56,9 @@ function computeLaneCounts(
|
||||
const clipId = element.key ?? element.id;
|
||||
const propertyGroups = new Set<string>();
|
||||
for (const animation of gsapAnimations.get(clipId) ?? []) {
|
||||
if (animation.propertyGroup && animationContributesLane(animation)) {
|
||||
propertyGroups.add(animation.propertyGroup);
|
||||
}
|
||||
// Same helper the rendered lanes count through, so a reserved row and a
|
||||
// drawn lane can never disagree.
|
||||
for (const group of animationLaneGroups(animation)) propertyGroups.add(group);
|
||||
}
|
||||
laneCounts.set(clipId, propertyGroups.size);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user