diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx
index c061da300..52e345b86 100644
--- a/packages/studio/src/player/components/LayerDisclosureRow.tsx
+++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx
@@ -1,16 +1,19 @@
import { CaretRight } from "@phosphor-icons/react";
import type { TimelineElement } from "../store/playerStore";
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
+import { TrackClipCount } from "./TrackClipCount";
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
// here, not on the clip bar, and re-expands a collapsed layer.
export function LayerDisclosureRow({
keyframeClip,
+ clipCount,
isExpanded,
gutterBackground,
onToggleClipExpanded,
}: {
keyframeClip: TimelineElement;
+ clipCount: number;
isExpanded: boolean;
gutterBackground: string;
onToggleClipExpanded: () => void;
@@ -53,6 +56,7 @@ export function LayerDisclosureRow({
{name}
+
);
}
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
index 094b54a5f..96feb1794 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
@@ -60,6 +60,7 @@ const OPACITY = animation("opacity-tween", "visual", [
interface RenderHeaderOptions {
animations?: GsapAnimation[];
+ clipCount?: number;
currentTime?: number;
expanded?: boolean;
onSeek?: (time: number) => void;
@@ -82,6 +83,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
trackLabel="Hero card"
contentOrigin={LABEL_COL_W}
keyframeClip={ELEMENT}
+ clipCount={next.clipCount ?? 1}
isExpanded={next.expanded !== false}
animations={next.animations ?? [POSITION, OPACITY]}
currentTime={next.currentTime ?? 0}
@@ -109,6 +111,17 @@ function click(host: HTMLElement, label: string) {
}
describe("TimelineTrackHeader", () => {
+ // 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", () => {
+ const view = renderHeader({ clipCount: 1 });
+ expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
+
+ view.rerender({ clipCount: 3 });
+ expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
+ act(() => view.root.unmount());
+ });
+
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx
index c0fac5b49..4ef4c5db4 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx
@@ -1,30 +1,20 @@
import { useState } from "react";
import { Eye, EyeSlash } from "@phosphor-icons/react";
-import {
- classifyPropertyGroup,
- type GsapAnimation,
- type PropertyGroupName,
-} from "@hyperframes/core/gsap-parser";
-import {
- clipToTweenPercentage,
- getKeyframeNavigationState,
-} from "../../components/editor/KeyframeNavigation";
+import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { Music } from "../../icons/SystemIcons";
-import {
- absoluteToPercentageForAnimation,
- isTimeWithinTween,
- resolveTweenDuration,
- resolveTweenStart,
-} from "../../utils/globalTimeCompiler";
import type { TimelineElement } from "../store/playerStore";
-import type {
- TimelineEditCallbacks,
- TimelinePropertyGroupKeyframeToggle,
-} from "./timelineCallbacks";
+import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
+import { TrackClipCount } from "./TrackClipCount";
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
+import {
+ resolveLaneHeaderState,
+ type KeyframeNavigationState,
+ type TimelinePropertyLane,
+} from "./trackHeaderLaneState";
+import { valueReadout } from "./trackHeaderLaneValues";
interface TimelineTrackHeaderProps {
trackNumber: number;
@@ -33,6 +23,8 @@ interface TimelineTrackHeaderProps {
/** The track's active keyframe clip (selected, else primary) — the one whose
* disclosure + property rows this header shows, whether expanded or not. */
keyframeClip: TimelineElement | null;
+ /** Clips on this track, so the header can say how many the row holds. */
+ clipCount: number;
isExpanded: boolean;
animations: readonly GsapAnimation[];
currentTime: number;
@@ -47,104 +39,6 @@ interface TimelineTrackHeaderProps {
onSeek?: (time: number) => void;
}
-function roundValue(value: number): string {
- return String(Math.round(value * 100) / 100);
-}
-
-function propertyValueAt(
- animation: GsapAnimation,
- property: string,
- tweenPercentage: number,
-): number | string | undefined {
- const keyframes = animation.keyframes?.keyframes ?? [];
- const values = keyframes
- .filter((keyframe) => property in keyframe.properties)
- .map((keyframe) => ({
- percentage: keyframe.percentage,
- value: keyframe.properties[property],
- }));
- const before = values.filter((value) => value.percentage <= tweenPercentage).at(-1);
- const after = values.find((value) => value.percentage >= tweenPercentage);
- if (!before) return after?.value;
- if (!after) return before.value;
- if (
- typeof before.value !== "number" ||
- typeof after.value !== "number" ||
- before.percentage === after.percentage
- ) {
- return before.value;
- }
- const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
- return before.value + (after.value - before.value) * progress;
-}
-
-function valuesAt(
- animation: GsapAnimation,
- group: PropertyGroupName,
- tweenPercentage: number,
-): Record {
- const propertyNames = new Set();
- for (const keyframe of animation.keyframes?.keyframes ?? []) {
- for (const property of Object.keys(keyframe.properties)) {
- if (classifyPropertyGroup(property) === group) propertyNames.add(property);
- }
- }
- const values: Record = {};
- for (const property of propertyNames) {
- const value = propertyValueAt(animation, property, tweenPercentage);
- if (value !== undefined) values[property] = value;
- }
- return values;
-}
-
-function groupLabel(group: PropertyGroupName, properties: Record): string {
- if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
- return "Opacity";
- }
- if (group !== "other") return `${group[0]?.toUpperCase() ?? ""}${group.slice(1)}`;
- const property = Object.keys(properties)[0];
- return property ? `${property[0]?.toUpperCase() ?? ""}${property.slice(1)}` : "Other";
-}
-
-type LaneValues = Record;
-
-function defaultValueReadout(values: LaneValues): string {
- return Object.values(values)
- .map((value) => (typeof value === "number" ? roundValue(value) : value))
- .join(", ");
-}
-
-function positionValueReadout(values: LaneValues): string | null {
- const x = values.x;
- const y = values.y;
- return typeof x === "number" && typeof y === "number"
- ? `${roundValue(x)}, ${roundValue(y)}`
- : null;
-}
-
-function rotationValueReadout(values: LaneValues): string | null {
- return typeof values.rotation === "number" ? `${roundValue(values.rotation)}°` : null;
-}
-
-function visualValueReadout(values: LaneValues): string | null {
- const opacity = values.opacity ?? values.autoAlpha;
- return typeof opacity === "number"
- ? `${roundValue(Math.abs(opacity) <= 1 ? opacity * 100 : opacity)}%`
- : null;
-}
-
-const GROUP_VALUE_READOUTS: Partial<
- Record string | null>
-> = {
- position: positionValueReadout,
- rotation: rotationValueReadout,
- visual: visualValueReadout,
-};
-
-function valueReadout(group: PropertyGroupName, values: Record): string {
- return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values);
-}
-
function VisibilityButton({
hidden,
trackNumber,
@@ -184,13 +78,19 @@ function VisibilityButton({
function LegacyTrackHeader({
trackNumber,
trackLabel,
+ clipCount,
showTrackLabel,
isTrackHidden,
isAudioTrack,
onToggleTrackHidden,
}: Pick<
TimelineTrackHeaderProps,
- "trackNumber" | "trackLabel" | "isTrackHidden" | "isAudioTrack" | "onToggleTrackHidden"
+ | "trackNumber"
+ | "trackLabel"
+ | "clipCount"
+ | "isTrackHidden"
+ | "isAudioTrack"
+ | "onToggleTrackHidden"
> & { showTrackLabel: boolean }) {
return (
<>
@@ -202,6 +102,7 @@ function LegacyTrackHeader({
{trackLabel}
)}
+ {showTrackLabel && }
[number];
-type KeyframeNavigationState = ReturnType<
- typeof getKeyframeNavigationState
->;
-
-function findNearestLaneKeyframe(lane: TimelinePropertyLane, clipPercentage: number) {
- return lane.keyframes.reduce<(typeof lane.keyframes)[number] | null>(
- (nearest, keyframe) =>
- !nearest ||
- Math.abs(keyframe.percentage - clipPercentage) < Math.abs(nearest.percentage - clipPercentage)
- ? keyframe
- : nearest,
- null,
- );
-}
-
-function findAnimationAtTime(animations: TimelinePropertyLane["animations"], currentTime: number) {
- return animations.find((candidate) => {
- const start = resolveTweenStart(candidate);
- return start !== null && isTimeWithinTween(currentTime, start, resolveTweenDuration(candidate));
- });
-}
-
-function resolveLaneAnimation(
- lane: TimelinePropertyLane,
- navigation: KeyframeNavigationState,
- nearestKeyframe: TimelinePropertyLane["keyframes"][number] | null,
- animationAtPlayhead: GsapAnimation | undefined,
-) {
- const animationId = navigation.currentKeyframe?.animationId ?? nearestKeyframe?.animationId;
- return animationAtPlayhead ?? lane.animations.find((candidate) => candidate.id === animationId);
-}
-
-function resolveLaneTweenPercentage(
- navigation: KeyframeNavigationState,
- animation: GsapAnimation | undefined,
- animationKeyframes: TimelinePropertyLane["keyframes"],
- currentTime: number,
- clipPercentage: number,
-) {
- return (
- navigation.currentKeyframe?.tweenPercentage ??
- (animation ? absoluteToPercentageForAnimation(currentTime, animation) : null) ??
- clipToTweenPercentage(animationKeyframes, clipPercentage)
- );
-}
-
-function valuesForLaneAnimation(
- animation: GsapAnimation | undefined,
- lane: TimelinePropertyLane,
- tweenPercentage: number,
-) {
- return animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
-}
-
-function createLaneToggleTarget(
- animation: GsapAnimation | undefined,
- lane: TimelinePropertyLane,
- tweenPercentage: number,
- values: LaneValues,
- navigation: KeyframeNavigationState,
-): TimelinePropertyGroupKeyframeToggle | null {
- return animation
- ? {
- animationId: animation.id,
- propertyGroup: lane.group,
- tweenPercentage,
- properties: values,
- remove: navigation.currentKeyframe !== null,
- }
- : null;
-}
-
-function resolveLaneHeaderState(
- lane: TimelinePropertyLane,
- currentTime: number,
- clipPercentage: number,
-) {
- const navigation = getKeyframeNavigationState(lane.keyframes, clipPercentage);
- const nearestKeyframe = findNearestLaneKeyframe(lane, clipPercentage);
- const animationAtPlayhead = findAnimationAtTime(lane.animations, currentTime);
- const animation = resolveLaneAnimation(lane, navigation, nearestKeyframe, animationAtPlayhead);
- const animationKeyframes = lane.keyframes.filter(
- (keyframe) => keyframe.animationId === animation?.id,
- );
- const tweenPercentage = resolveLaneTweenPercentage(
- navigation,
- animation,
- animationKeyframes,
- currentTime,
- clipPercentage,
- );
- const values = valuesForLaneAnimation(animation, lane, tweenPercentage);
- const label = groupLabel(lane.group, values);
- const toggleTarget = createLaneToggleTarget(animation, lane, tweenPercentage, values, navigation);
-
- return {
- navigation,
- nearestKeyframe,
- animationAtPlayhead,
- animation,
- animationKeyframes,
- tweenPercentage,
- values,
- label,
- toggleTarget,
- };
-}
-
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
function PropertyGroupNavigation({
navigation,
@@ -484,6 +276,7 @@ export function TimelineTrackHeader({
trackLabel,
contentOrigin,
keyframeClip,
+ clipCount,
isExpanded,
animations,
currentTime,
@@ -528,6 +321,7 @@ export function TimelineTrackHeader({
+ {clipCount}
+
+ );
+}
diff --git a/packages/studio/src/player/components/trackHeaderLaneState.ts b/packages/studio/src/player/components/trackHeaderLaneState.ts
new file mode 100644
index 000000000..b86a4f94f
--- /dev/null
+++ b/packages/studio/src/player/components/trackHeaderLaneState.ts
@@ -0,0 +1,121 @@
+/**
+ * Resolves what a property lane's header row shows at the current playhead:
+ * which animation owns the lane right now, where the playhead sits inside it,
+ * the sampled values, and the add/remove keyframe target. Pure state, no JSX, so
+ * the header component only renders what this returns.
+ */
+import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
+import {
+ clipToTweenPercentage,
+ getKeyframeNavigationState,
+} from "../../components/editor/KeyframeNavigation";
+import {
+ absoluteToPercentageForAnimation,
+ isTimeWithinTween,
+ resolveTweenDuration,
+ resolveTweenStart,
+} from "../../utils/globalTimeCompiler";
+import type { TimelinePropertyGroupKeyframeToggle } from "./timelineCallbacks";
+import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
+import { groupLabel, valuesAt, type LaneValues } from "./trackHeaderLaneValues";
+
+export type TimelinePropertyLane = ReturnType[number];
+export type KeyframeNavigationState = ReturnType<
+ typeof getKeyframeNavigationState
+>;
+
+function findNearestLaneKeyframe(lane: TimelinePropertyLane, clipPercentage: number) {
+ return lane.keyframes.reduce<(typeof lane.keyframes)[number] | null>(
+ (nearest, keyframe) =>
+ !nearest ||
+ Math.abs(keyframe.percentage - clipPercentage) < Math.abs(nearest.percentage - clipPercentage)
+ ? keyframe
+ : nearest,
+ null,
+ );
+}
+
+function findAnimationAtTime(animations: TimelinePropertyLane["animations"], currentTime: number) {
+ return animations.find((candidate) => {
+ const start = resolveTweenStart(candidate);
+ return start !== null && isTimeWithinTween(currentTime, start, resolveTweenDuration(candidate));
+ });
+}
+
+function resolveLaneAnimation(
+ lane: TimelinePropertyLane,
+ navigation: KeyframeNavigationState,
+ nearestKeyframe: TimelinePropertyLane["keyframes"][number] | null,
+ animationAtPlayhead: GsapAnimation | undefined,
+) {
+ const animationId = navigation.currentKeyframe?.animationId ?? nearestKeyframe?.animationId;
+ return animationAtPlayhead ?? lane.animations.find((candidate) => candidate.id === animationId);
+}
+
+function resolveLaneTweenPercentage(
+ navigation: KeyframeNavigationState,
+ animation: GsapAnimation | undefined,
+ animationKeyframes: TimelinePropertyLane["keyframes"],
+ currentTime: number,
+ clipPercentage: number,
+) {
+ return (
+ navigation.currentKeyframe?.tweenPercentage ??
+ (animation ? absoluteToPercentageForAnimation(currentTime, animation) : null) ??
+ clipToTweenPercentage(animationKeyframes, clipPercentage)
+ );
+}
+
+function createLaneToggleTarget(
+ animation: GsapAnimation | undefined,
+ lane: TimelinePropertyLane,
+ tweenPercentage: number,
+ values: LaneValues,
+ navigation: KeyframeNavigationState,
+): TimelinePropertyGroupKeyframeToggle | null {
+ return animation
+ ? {
+ animationId: animation.id,
+ propertyGroup: lane.group,
+ tweenPercentage,
+ properties: values,
+ remove: navigation.currentKeyframe !== null,
+ }
+ : null;
+}
+
+export interface LaneHeaderState {
+ navigation: KeyframeNavigationState;
+ values: LaneValues;
+ label: string;
+ toggleTarget: TimelinePropertyGroupKeyframeToggle | null;
+}
+
+export function resolveLaneHeaderState(
+ lane: TimelinePropertyLane,
+ currentTime: number,
+ clipPercentage: number,
+): LaneHeaderState {
+ const navigation = getKeyframeNavigationState(lane.keyframes, clipPercentage);
+ const nearestKeyframe = findNearestLaneKeyframe(lane, clipPercentage);
+ const animationAtPlayhead = findAnimationAtTime(lane.animations, currentTime);
+ const animation = resolveLaneAnimation(lane, navigation, nearestKeyframe, animationAtPlayhead);
+ const animationKeyframes = lane.keyframes.filter(
+ (keyframe) => keyframe.animationId === animation?.id,
+ );
+ const tweenPercentage = resolveLaneTweenPercentage(
+ navigation,
+ animation,
+ animationKeyframes,
+ currentTime,
+ clipPercentage,
+ );
+ const values = animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
+
+ return {
+ navigation,
+ values,
+ label: groupLabel(lane.group, values),
+ toggleTarget: createLaneToggleTarget(animation, lane, tweenPercentage, values, navigation),
+ };
+}
diff --git a/packages/studio/src/player/components/trackHeaderLaneValues.ts b/packages/studio/src/player/components/trackHeaderLaneValues.ts
new file mode 100644
index 000000000..180a29b63
--- /dev/null
+++ b/packages/studio/src/player/components/trackHeaderLaneValues.ts
@@ -0,0 +1,110 @@
+/**
+ * Sampling and formatting for the property-lane readouts in the track header:
+ * what value a property holds at a given tween percentage, and how that value
+ * reads to a human. Kept apart from the header's JSX so a formatting change and
+ * a layout change never touch the same file.
+ */
+import {
+ classifyPropertyGroup,
+ type GsapAnimation,
+ type PropertyGroupName,
+} from "@hyperframes/core/gsap-parser";
+
+export type LaneValues = Record;
+
+function roundValue(value: number): string {
+ return String(Math.round(value * 100) / 100);
+}
+
+function propertyValueAt(
+ animation: GsapAnimation,
+ property: string,
+ tweenPercentage: number,
+): number | string | undefined {
+ const keyframes = animation.keyframes?.keyframes ?? [];
+ const values = keyframes
+ .filter((keyframe) => property in keyframe.properties)
+ .map((keyframe) => ({
+ percentage: keyframe.percentage,
+ value: keyframe.properties[property],
+ }));
+ const before = values.filter((value) => value.percentage <= tweenPercentage).at(-1);
+ const after = values.find((value) => value.percentage >= tweenPercentage);
+ if (!before) return after?.value;
+ if (!after) return before.value;
+ if (
+ typeof before.value !== "number" ||
+ typeof after.value !== "number" ||
+ before.percentage === after.percentage
+ ) {
+ return before.value;
+ }
+ const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
+ return before.value + (after.value - before.value) * progress;
+}
+
+/** Every property of `group` this animation touches, sampled at `tweenPercentage`. */
+export function valuesAt(
+ animation: GsapAnimation,
+ group: PropertyGroupName,
+ tweenPercentage: number,
+): LaneValues {
+ const propertyNames = new Set();
+ for (const keyframe of animation.keyframes?.keyframes ?? []) {
+ for (const property of Object.keys(keyframe.properties)) {
+ if (classifyPropertyGroup(property) === group) propertyNames.add(property);
+ }
+ }
+ const values: LaneValues = {};
+ for (const property of propertyNames) {
+ const value = propertyValueAt(animation, property, tweenPercentage);
+ if (value !== undefined) values[property] = value;
+ }
+ return values;
+}
+
+export function groupLabel(group: PropertyGroupName, properties: LaneValues): string {
+ if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
+ return "Opacity";
+ }
+ if (group !== "other") return `${group[0]?.toUpperCase() ?? ""}${group.slice(1)}`;
+ const property = Object.keys(properties)[0];
+ return property ? `${property[0]?.toUpperCase() ?? ""}${property.slice(1)}` : "Other";
+}
+
+function defaultValueReadout(values: LaneValues): string {
+ return Object.values(values)
+ .map((value) => (typeof value === "number" ? roundValue(value) : value))
+ .join(", ");
+}
+
+function positionValueReadout(values: LaneValues): string | null {
+ const x = values.x;
+ const y = values.y;
+ return typeof x === "number" && typeof y === "number"
+ ? `${roundValue(x)}, ${roundValue(y)}`
+ : null;
+}
+
+function rotationValueReadout(values: LaneValues): string | null {
+ return typeof values.rotation === "number" ? `${roundValue(values.rotation)}°` : null;
+}
+
+function visualValueReadout(values: LaneValues): string | null {
+ const opacity = values.opacity ?? values.autoAlpha;
+ return typeof opacity === "number"
+ ? `${roundValue(Math.abs(opacity) <= 1 ? opacity * 100 : opacity)}%`
+ : null;
+}
+
+const GROUP_VALUE_READOUTS: Partial<
+ Record string | null>
+> = {
+ position: positionValueReadout,
+ rotation: rotationValueReadout,
+ visual: visualValueReadout,
+};
+
+export function valueReadout(group: PropertyGroupName, values: LaneValues): string {
+ return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values);
+}