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 && }