s.trackHeight);
// Broader than a raw-attribute check — a clip mid-edit (see the stale-
// selection test) has lanes in its live binding before the attribute commits.
const clips = elements.filter(isAudioOrVideoTimelineElement);
@@ -186,7 +187,7 @@ export function TimelineAutomationLaneSlot({
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
}
});
- const top = topOffset ?? getTimelineLaneTop(laneCount);
+ const top = topOffset ?? getTimelineLaneTop(laneCount, trackHeight);
return (
<>
{clips.map((element) => (
diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx
index dd9739fc8f..1bdadbe453 100644
--- a/packages/studio/src/player/components/TimelineCanvas.tsx
+++ b/packages/studio/src/player/components/TimelineCanvas.tsx
@@ -2,7 +2,7 @@ import { memo } from "react";
import { TimelineRulerPart } from "./TimelineRulerPart";
import { PlayheadIndicator } from "./PlayheadIndicator";
import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
-import { RULER_H, CLIP_Y, TRACK_H, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
+import { RULER_H, CLIP_Y, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
import { TimelineLanes } from "./TimelineLanes";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
@@ -30,15 +30,16 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
(insertLineRow ?? -1) >= props.trackOrder.length ||
(props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
// A host bottom pad shorter than a track leaves no lane to draw.
- const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
+ const trackHeight = props.rowGeometry.trackHeight;
+ const showGhostLane = props.rowGeometry.padding.bottom >= trackHeight;
// Past the last committed track: a drag's new bottom-track preview row is the lane itself.
const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
- // A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
+ // A clip bar in an EXPANDED row still renders at one layer height (the property lanes
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
// ghost and drop placeholder must clamp to it or they stretch to the full
// expanded row height and stop matching the clip being dragged.
- const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
+ const draggedClipHeight = Math.min(draggedRowHeight, trackHeight) - CLIP_Y * 2;
const beatDragging = props.beatDragging;
const { draggedElement, snapGuide, multiDragPreview } = props;
return (
@@ -92,7 +93,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
left: props.contentOrigin,
width: props.trackContentWidth,
- height: TRACK_H - CLIP_Y * 2,
+ height: trackHeight - CLIP_Y * 2,
}}
>
{props.acceptsMediaDrop && "Drop media here"}
@@ -121,7 +122,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
left: props.contentOrigin + gap.start * props.pps,
width: Math.max((gap.end - gap.start) * props.pps, 2),
- height: TRACK_H - CLIP_Y * 2,
+ height: trackHeight - CLIP_Y * 2,
background: loud ? "var(--timeline-accent-soft)" : "var(--timeline-accent-faint)",
borderRadius: 4,
zIndex: 25,
@@ -159,7 +160,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
top: props.rowGeometry.getRowTop(dropRowIndex) + CLIP_Y,
left: props.contentOrigin + props.dropPreview.start * props.pps,
width: DROP_PREVIEW_SECONDS * props.pps,
- height: TRACK_H - CLIP_Y * 2,
+ height: trackHeight - CLIP_Y * 2,
border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
borderRadius: 4,
diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx
index 1fbd53f0e8..8f82b95c7c 100644
--- a/packages/studio/src/player/components/TimelineGroupHeader.tsx
+++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx
@@ -1,5 +1,5 @@
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
-import { TRACK_H } from "./timelineLayout";
+import { usePlayerStore } from "../store/playerStore";
import type { TimelineTheme } from "./timelineTheme";
import { TimelineFxButton } from "./TimelineFxButton";
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
@@ -102,6 +102,7 @@ export function TimelineGroupHeader({
theme,
showAudioEffects = true,
}: TimelineGroupHeaderProps) {
+ const trackHeight = usePlayerStore((s) => s.trackHeight);
return (
state.setRevealedAudioFxTarget);
+ const trackHeight = usePlayerStore((state) => state.trackHeight);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean, ended = false) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (!live) {
@@ -139,7 +140,7 @@ export function TimelineGroupRow({
rovingTargetId={rovingTargetId}
>
{/* Header and its lane labels in ONE sticky column — the shape
- `TimelineTrackHeader` already uses: a fixed TRACK_H line box with the
+ `TimelineTrackHeader` already uses: a fixed one-layer line box with the
lane rows absolutely positioned beneath it, the whole thing pinned.
The labels used to be SIBLINGS of the header, so `absolute left-0`
resolved against the ROW, and the row is what scrolls horizontally —
@@ -186,7 +187,7 @@ export function TimelineGroupRow({
= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
gutterBackground={theme.gutterBackground}
accentColor={GROUP_LANE_ACCENT}
@@ -212,7 +213,7 @@ export function TimelineGroupRow({
pps={pps}
// Below the strip, which sits directly under the header row.
laneCount={0}
- topOffset={TRACK_H}
+ topOffset={trackHeight}
accentColor={GROUP_LANE_ACCENT}
currentTime={currentTime}
beatTimes={beatTimes}
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index 6cf8af5a7f..69d060b13d 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -18,7 +18,7 @@ import {
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
import { clipTimingStart } from "../../hooks/gsapShared";
import { useTimelineClipCapabilities } from "./timelineReadOnly";
-import { CLIP_Y, TRACK_H } from "./timelineLayout";
+import { CLIP_Y } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
@@ -209,7 +209,7 @@ export function TimelineLanes({
top: rowsVirtualized ? rowGeometry.getRowTop(row) : undefined,
marginLeft: contentOrigin,
width: trackContentWidth,
- height: TRACK_H,
+ height: rowGeometry.trackHeight,
border: "1px dashed var(--timeline-accent)",
background: "color-mix(in srgb, var(--timeline-accent) 5%, transparent)",
pointerEvents: "none",
@@ -237,7 +237,7 @@ export function TimelineLanes({
// bar is capped for every clip on the row, not just the one whose
// property lanes are showing. Undefined means "fill the row", which is
// right only while it is collapsed and the row is nothing but bar.
- const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
+ const clipBarHeight = rowExpanded ? rowGeometry.trackHeight - 2 * CLIP_Y : undefined;
const automationElements = els.map(getPreviewElement);
// 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
@@ -306,6 +306,7 @@ export function TimelineLanes({
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMember={groupMemberTracks.has(trackNum)}
+ row={row}
theme={theme}
showAudioEffects={showAudioEffects}
onToggleClipExpanded={() => {
diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.tsx
index 31dd97962d..4a7fa2bc3f 100644
--- a/packages/studio/src/player/components/TimelinePropertyLanes.tsx
+++ b/packages/studio/src/player/components/TimelinePropertyLanes.tsx
@@ -8,6 +8,7 @@ import { toClipKeyframes } from "../../hooks/gsapShared";
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
+import { usePlayerStore } from "../store/playerStore";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
@@ -197,6 +198,7 @@ export function TimelinePropertyLanes({
onMoveKeyframe,
suppressClickRef,
}: TimelinePropertyLanesProps) {
+ const trackHeight = usePlayerStore((s) => s.trackHeight);
// 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.
@@ -232,11 +234,11 @@ export function TimelinePropertyLanes({
data-property-group={group}
data-timeline-element-id={elementId}
data-timeline-property-lane=""
- data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
+ data-timeline-lane-top={getTimelineLaneTop(laneIndex, trackHeight)}
className="absolute"
style={{
left: clipLeftPx,
- top: getTimelineLaneTop(laneIndex),
+ top: getTimelineLaneTop(laneIndex, trackHeight),
width: clipWidthPx,
height: LANE_H,
}}
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx
index 5b0da20c94..1a4cbac5e1 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx
@@ -18,7 +18,8 @@ import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationL
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
-import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
+import { LABEL_COL_W, getTimelineLaneTop } from "./timelineLayout";
+import { TrackHeightGrip } from "./TrackHeightGrip";
import type { TimelineTheme } from "./timelineTheme";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows";
@@ -71,6 +72,8 @@ interface TimelineTrackHeaderProps {
/** This track is a member of an audio group — indents the row under its header. */
isGroupMember?: boolean;
rovingTargetId?: string | null;
+ /** Display row; with it the header's bottom edge resizes every layer. */
+ row?: number;
theme: TimelineTheme;
showAudioEffects?: boolean;
onToggleClipExpanded: () => void;
@@ -106,6 +109,7 @@ export function TimelineTrackHeader({
onRemoveAutomationLane,
onSeek,
rovingTargetId = null,
+ row,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
@@ -164,6 +168,7 @@ export function TimelineTrackHeader({
[groupAutomationRaw, groupFxChainRaw, groupOwner],
);
const revealAudioFx = usePlayerStore((s) => s.setRevealedAudioFxTarget);
+ const trackHeight = usePlayerStore((s) => s.trackHeight);
/**
* Which element's rack a lane's reveal opens, in the PANEL's id space.
*
@@ -289,9 +294,10 @@ export function TimelineTrackHeader({
: {}),
}}
>
+ {row !== undefined && }
{!isKeyframeLayer ? (
<>
- {/* The two lines own exactly TRACK_H, not the whole header.
+ {/* The two lines own exactly one layer height, not the whole header.
`justify-center` on the header itself centred them in its FULL
height — which grows by AUTOMATION_LANE_H per open lane — so
opening one pushed the name and its controls down THROUGH the lane
@@ -299,10 +305,10 @@ export function TimelineTrackHeader({
{
+ usePlayerStore.setState({ trackHeight: TRACK_H });
+ document.body.innerHTML = "";
+});
+
+function renderGrip(row: number): HTMLElement {
+ const host = document.createElement("div");
+ document.body.append(host);
+ act(() => createRoot(host).render());
+ const grip = host.querySelector("[data-timeline-track-height-grip]")!;
+ grip.setPointerCapture = () => undefined;
+ return grip;
+}
+
+function pointer(target: HTMLElement, type: string, clientY: number) {
+ act(() => {
+ target.dispatchEvent(
+ new PointerEvent(type, { bubbles: true, button: 0, buttons: 1, pointerId: 1, clientY }),
+ );
+ });
+}
+
+describe("TrackHeightGrip", () => {
+ it("keeps the dragged edge under the pointer: the third layer's edge shares its travel three ways", () => {
+ const grip = renderGrip(2);
+ pointer(grip, "pointerdown", 100);
+ pointer(grip, "pointermove", 130);
+ expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H + 10);
+ pointer(grip, "pointerup", 130);
+ pointer(grip, "pointermove", 400);
+ expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H + 10);
+ });
+
+ it("puts every layer back to the default on a double click", () => {
+ usePlayerStore.setState({ trackHeight: 30 });
+ const grip = renderGrip(0);
+ act(() => grip.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })));
+ expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H);
+ });
+});
diff --git a/packages/studio/src/player/components/TrackHeightGrip.tsx b/packages/studio/src/player/components/TrackHeightGrip.tsx
new file mode 100644
index 0000000000..70110d4806
--- /dev/null
+++ b/packages/studio/src/player/components/TrackHeightGrip.tsx
@@ -0,0 +1,43 @@
+import { useRef, type PointerEvent } from "react";
+import { usePlayerStore } from "../store/playerStore";
+import { TRACK_H } from "./timelineLayout";
+
+/**
+ * A layer's bottom edge. Dragging it resizes every layer at once, so the `row + 1` layers down to this edge
+ * each take a share of the pointer's travel and the edge stays under the pointer. Double click: the default.
+ */
+export function TrackHeightGrip({ row }: { row: number }) {
+ const trackHeight = usePlayerStore((s) => s.trackHeight);
+ const setTrackHeight = usePlayerStore((s) => s.setTrackHeight);
+ const drag = useRef<{ pointerId: number; y: number; height: number } | null>(null);
+ const onPointerDown = (event: PointerEvent) => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ drag.current = { pointerId: event.pointerId, y: event.clientY, height: trackHeight };
+ };
+ const onPointerMove = (event: PointerEvent) => {
+ const start = drag.current;
+ if (!start || start.pointerId !== event.pointerId) return;
+ setTrackHeight(start.height + (event.clientY - start.y) / (row + 1));
+ };
+ const end = () => void (drag.current = null);
+ // The keyboard path is the toolbar's Layer height slider, so the grip stays out of the tab order.
+ return (
+ {
+ event.stopPropagation();
+ setTrackHeight(TRACK_H);
+ }}
+ />
+ );
+}
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts
index d6e6693e8d..885529d54b 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.ts
@@ -134,9 +134,10 @@ function dragRowAim(
let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
if (drag.started && drag.insertRow !== null) {
const top = geometry.getRowTop(drag.insertRow);
- if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
+ const trackHeight = geometry.trackHeight;
+ if (y >= top - INSERT_SEAM_PX && y <= top + trackHeight + INSERT_SEAM_PX)
return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
- if (y > top + TRACK_H) y -= TRACK_H;
+ if (y > top + trackHeight) y -= trackHeight;
}
const rowFloat = geometry.getRowFromY(y);
return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts
index 04c54ccd03..37a0b6c600 100644
--- a/packages/studio/src/player/components/timelineLayout.test.ts
+++ b/packages/studio/src/player/components/timelineLayout.test.ts
@@ -17,6 +17,7 @@ import {
trackHeights,
resolveTimelineAssetDrop,
getTimelineBeatEntries,
+ getTimelineLaneTop,
} from "./timelineLayout";
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
import { resolveInsertRow } from "./timelineCollision";
@@ -295,6 +296,26 @@ describe("host track padding", () => {
});
});
+describe("resized layers", () => {
+ const short = 30;
+ const geometry = createTimelineRowGeometry([0, 1], trackHeights([[], []], undefined, short), {}, short);
+
+ it("sizes every row, the ghost lane and the rows past the last from one layer height", () => {
+ expect(geometry.rowHeights).toEqual([short, short]);
+ expect(geometry.trackHeight).toBe(short);
+ expect(geometry.padding.bottom).toBe(short);
+ expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 3 * short);
+ expect(geometry.getRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * short);
+ expect(Math.floor(geometry.getRowFromY(RULER_H + TRACKS_TOP_PAD + 2 * short + 1))).toBe(2);
+ });
+
+ it("puts an expanded clip's lanes under the resized bar", () => {
+ const expanded = trackHeights([[{ clipId: "a", laneCount: 2 }]], new Set(["a"]), short);
+ expect(expanded).toEqual([short + 2 * LANE_H]);
+ expect(getTimelineLaneTop(1, short)).toBe(short + LANE_H);
+ });
+});
+
describe("default track pads", () => {
const rows = [TRACK_H, TRACK_H, TRACK_H];
const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts
index c680e79dad..416f107b7e 100644
--- a/packages/studio/src/player/components/timelineLayout.ts
+++ b/packages/studio/src/player/components/timelineLayout.ts
@@ -5,7 +5,17 @@ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
export const LABEL_COL_W = 232;
+/** The default layer height; the person can resize every layer at once between the MIN and MAX below. */
export const TRACK_H = 48;
+export const TRACK_H_MIN = 24;
+export const TRACK_H_MAX = 120;
+/** Below this layer height clips show only their name bar, no thumbnails. */
+export const TRACK_H_THUMBNAILS_MIN = 40;
+
+export function clampTrackHeight(height: number): number {
+ if (!Number.isFinite(height)) return TRACK_H;
+ return Math.round(Math.min(TRACK_H_MAX, Math.max(TRACK_H_MIN, height)));
+}
export const LANE_H = 28;
export const RULER_H = 24;
export const CLIP_Y = 3;
@@ -51,8 +61,8 @@ export function getTimelineBeatEntries(
.map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
}
-export function getTimelineLaneTop(laneIndex: number): number {
- return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
+export function getTimelineLaneTop(laneIndex: number, trackHeight = TRACK_H): number {
+ return trackHeight + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
}
/**
* Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
@@ -61,7 +71,8 @@ export function getTimelineLaneTop(laneIndex: number): number {
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
* just enough that the first clip isn't jammed under the ruler.
* - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
- * dropping a clip or file there creates a new bottom track.
+ * dropping a clip or file there creates a new bottom track. It follows the
+ * layer height when the person resizes layers.
*/
export const TRACKS_TOP_PAD = 8;
export const TRACKS_BOTTOM_PAD = TRACK_H;
@@ -98,6 +109,7 @@ type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
export function trackHeights(
tracks: TimelineTrackHeightInput,
expandedClipIds?: ReadonlySet
,
+ trackHeight = TRACK_H,
): number[] {
return tracks.map((clips) => {
let laneCount = 0;
@@ -108,13 +120,15 @@ export function trackHeights(
automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
}
return (
- TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
+ trackHeight +
+ Math.max(0, Math.trunc(laneCount)) * LANE_H +
+ automationLanes * AUTOMATION_LANE_H
);
});
}
-function validRowHeight(height: number | undefined): number {
- if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
+function validRowHeight(height: number | undefined, trackHeight = TRACK_H): number {
+ if (height === undefined || !Number.isFinite(height) || height <= 0) return trackHeight;
return height;
}
@@ -129,6 +143,8 @@ export interface TimelineRowGeometry {
readonly rowOffsets: readonly number[];
readonly rowsHeight: number;
readonly padding: Readonly>;
+ /** One collapsed layer: rows past the last, and every clip bar, take this height. */
+ readonly trackHeight: number;
readonly canvasHeight: number;
getRowIndex(rowKey: number): number;
getRowHeight(row: number): number;
@@ -150,10 +166,11 @@ export function createTimelineRowGeometry(
rowKeys: readonly number[],
rowHeights: readonly number[],
padding: TimelineTrackPadding = {},
+ trackHeight = TRACK_H,
): TimelineRowGeometry {
const topPad = validPad(padding.top, TRACKS_TOP_PAD);
- const bottomPad = validPad(padding.bottom, TRACKS_BOTTOM_PAD);
- const heights = Object.freeze(rowHeights.map(validRowHeight));
+ const bottomPad = validPad(padding.bottom, trackHeight);
+ const heights = Object.freeze(rowHeights.map((height) => validRowHeight(height, trackHeight)));
const keys = Object.freeze(
heights.map((_, row) => {
const key = rowKeys[row];
@@ -165,22 +182,22 @@ export function createTimelineRowGeometry(
Object.freeze(offsets);
const rowIndexByKey = new Map(keys.map((key, row) => [key, row]));
- const getRowHeight = (row: number) => validRowHeight(heights[row]);
+ const getRowHeight = (row: number) => validRowHeight(heights[row], trackHeight);
const getRowOffset = (row: number) => {
- if (heights.length === 0) return row * TRACK_H;
+ if (heights.length === 0) return row * trackHeight;
if (row <= 0) return row * getRowHeight(0);
if (row >= heights.length) {
- return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H;
+ return (offsets[heights.length] ?? 0) + (row - heights.length) * trackHeight;
}
const wholeRow = Math.floor(row);
return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
};
const getRowFromY = (contentY: number) => {
const y = contentY - RULER_H - topPad;
- if (heights.length === 0) return y / TRACK_H;
+ if (heights.length === 0) return y / trackHeight;
if (y < 0) return y / getRowHeight(0);
const rowsHeight = offsets[heights.length] ?? 0;
- if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H;
+ if (y >= rowsHeight) return heights.length + (y - rowsHeight) / trackHeight;
// First boundary strictly greater than y. Unlike the old linear scan this
// stays logarithmic for large timelines and uses the precomputed offsets.
@@ -200,6 +217,7 @@ export function createTimelineRowGeometry(
rowOffsets: offsets,
rowsHeight: offsets.at(-1) ?? 0,
padding: Object.freeze({ top: topPad, bottom: bottomPad }),
+ trackHeight,
canvasHeight: RULER_H + topPad + (offsets.at(-1) ?? 0) + bottomPad,
getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
getRowHeight,
diff --git a/packages/studio/src/player/components/timelineMarquee.ts b/packages/studio/src/player/components/timelineMarquee.ts
index 1b792a4211..778fa4429f 100644
--- a/packages/studio/src/player/components/timelineMarquee.ts
+++ b/packages/studio/src/player/components/timelineMarquee.ts
@@ -1,4 +1,4 @@
-import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
+import { RULER_H, CLIP_Y, type TimelineRowGeometry } from "./timelineLayout";
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex";
import type { TimelineElement } from "../store/playerStore";
@@ -79,7 +79,7 @@ export function getTimelineClipRect(
left: contentOrigin + clip.start * pps,
top: rowGeometry.getRowTop(row) + CLIP_Y,
width: Math.max(clip.duration * pps, MIN_CLIP_W),
- height: TRACK_H - CLIP_Y * 2,
+ height: rowGeometry.trackHeight - CLIP_Y * 2,
};
}
diff --git a/packages/studio/src/player/components/trackHeaderLabelRows.tsx b/packages/studio/src/player/components/trackHeaderLabelRows.tsx
index 93b76ba6ac..4f1033542a 100644
--- a/packages/studio/src/player/components/trackHeaderLabelRows.tsx
+++ b/packages/studio/src/player/components/trackHeaderLabelRows.tsx
@@ -8,7 +8,7 @@
* they are the seam.
*/
-import type { TimelineElement } from "../store/playerStore";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
@@ -105,6 +105,7 @@ export function PropertyGroupHeaderRow({
onSeek?: (time: number) => void;
rovingTargetId: string | null;
}) {
+ const trackHeight = usePlayerStore((s) => s.trackHeight);
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
@@ -119,10 +120,10 @@ export function PropertyGroupHeaderRow({
data-timeline-element-id={elementId}
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
data-property-group={lane.group}
- data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
+ data-timeline-lane-top={getTimelineLaneTop(laneIndex, trackHeight)}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-text-2"
style={{
- top: getTimelineLaneTop(laneIndex),
+ top: getTimelineLaneTop(laneIndex, trackHeight),
// The header column narrows to contentOrigin whenever that is under
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
// readout over the canvas, on top of the clips it is labelling.
diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.ts b/packages/studio/src/player/components/useTimelineKeyboardActor.ts
index 43d0524a5d..71a2978a94 100644
--- a/packages/studio/src/player/components/useTimelineKeyboardActor.ts
+++ b/packages/studio/src/player/components/useTimelineKeyboardActor.ts
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
-import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
+import { RULER_H, type TimelineRowGeometry } from "./timelineLayout";
import {
isTimelineNavigationKey,
locateTimelineLogicalTarget,
@@ -88,8 +88,8 @@ export function scrollKeyboardInsertRow(
): void {
const top = geometry.getRowTop(row);
if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
- else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
- viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
+ else if (top + geometry.trackHeight > viewport.scrollTop + viewport.clientHeight)
+ viewport.scrollTop = top + geometry.trackHeight - viewport.clientHeight;
}
export function timelineKeyboardEventTarget(
diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts
index ab70f1cd25..351ef7beec 100644
--- a/packages/studio/src/player/components/useTimelineTrackLayout.ts
+++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts
@@ -8,7 +8,6 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import {
- TRACK_H,
createTimelineRowGeometry,
type TimelineRowGeometry,
type TimelineTrackPadding,
@@ -140,23 +139,24 @@ function computeLaneCounts(
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
- * looks at a row's clips — always gives them TRACK_H. Override those
- * specific rows post-hoc: TRACK_H while collapsed, plus the group's own
+ * looks at a row's clips — always gives them one layer height. Override those
+ * specific rows post-hoc: one layer while collapsed, plus the group's own
* automation rows once its `∿` is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
groups: readonly TimelineTrackGroupInfo[],
expandedLaneOwnerIds: ReadonlySet,
+ trackHeight: number,
): number[] {
if (groups.length === 0) return rowHeights;
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
return tracks.map(([track], index) => {
const group = groupByAnchor.get(track);
- if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
+ if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? trackHeight;
// The group's own automation rows, which its `∿` discloses. A row sized
// without them clipped every lane it had just promised in the count.
- return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
+ return trackHeight + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
});
}
@@ -172,6 +172,7 @@ function useTimelineRowHeights(
const padBottom = trackPadding?.bottom;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
+ const trackHeight = usePlayerStore((s) => s.trackHeight);
const { laneCounts, rowGeometry } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Keyframe lanes follow only the active clip, so a track with several
@@ -203,9 +204,10 @@ function useTimelineRowHeights(
});
const rowHeights = applyGroupStripHeights(
tracks,
- trackHeights(heightTracks, expandedClipIds),
+ trackHeights(heightTracks, expandedClipIds, trackHeight),
groups,
expandedLaneOwnerIds,
+ trackHeight,
);
return {
laneCounts,
@@ -213,9 +215,11 @@ function useTimelineRowHeights(
tracks.map(([track]) => track),
rowHeights,
{ top: padTop, bottom: padBottom },
+ trackHeight,
),
};
}, [
+ trackHeight,
padTop,
padBottom,
expandedClipIds,
@@ -285,7 +289,7 @@ function useDisplayRowHeights(
() =>
displayTrackOrder.map((track) => {
const row = rowGeometry.getRowIndex(track);
- return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row);
+ return row < 0 ? rowGeometry.trackHeight : rowGeometry.getRowHeight(row);
}),
[displayTrackOrder, rowGeometry],
);
@@ -335,8 +339,14 @@ export function useTimelineDisplayLayout(
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry);
const displayRowGeometry = useMemo(
- () => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
- [displayTrackOrder, displayRowHeights, rowGeometry.padding],
+ () =>
+ createTimelineRowGeometry(
+ displayTrackOrder,
+ displayRowHeights,
+ rowGeometry.padding,
+ rowGeometry.trackHeight,
+ ),
+ [displayTrackOrder, displayRowHeights, rowGeometry.padding, rowGeometry.trackHeight],
);
let draggedRowKey: number | undefined;
if (draggedClip?.started) {
diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts
index 5649b17d73..c701150b74 100644
--- a/packages/studio/src/player/store/playerStore.test.ts
+++ b/packages/studio/src/player/store/playerStore.test.ts
@@ -2,6 +2,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { usePlayerStore, liveTime, whenPreviewBooted, type TimelineElement } from "./playerStore";
import { thumbnailRevisionOf } from "./thumbnailSlice";
+import { TRACK_H, TRACK_H_MAX, TRACK_H_MIN } from "../components/timelineLayout";
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
function expectResettableDefaults(state: ReturnType): void {
@@ -573,6 +574,21 @@ describe("usePlayerStore", () => {
});
});
+ describe("setTrackHeight", () => {
+ it("keeps every layer between the shortest and tallest height", () => {
+ const { setTrackHeight } = usePlayerStore.getState();
+ setTrackHeight(TRACK_H_MIN - 10);
+ expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H_MIN);
+ setTrackHeight(TRACK_H_MAX + 10);
+ expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H_MAX);
+ setTrackHeight(Number.NaN);
+ expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H);
+ setTrackHeight(33.4);
+ expect(usePlayerStore.getState().trackHeight).toBe(33);
+ setTrackHeight(TRACK_H);
+ });
+ });
+
describe("setManualZoomPercent", () => {
it("updates the manual zoom percent", () => {
usePlayerStore.getState().setManualZoomPercent(200);
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 70187afbe9..a501a5e03e 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -10,6 +10,7 @@ import {
type TimelineTimeDisplayMode,
} from "../../utils/studioUiPreferences";
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
+import { TRACK_H, clampTrackHeight } from "../components/timelineLayout";
import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
import {
createAutomationSelectionSlice,
@@ -84,6 +85,8 @@ interface PlayerState extends PlayerStoreSlices {
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
userZoomCount: number;
+ /** Height of every collapsed timeline layer, in px; one value for all layers. */
+ trackHeight: number;
/**
* Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
* rollback). Flashless z commits (skipReload) never reload the iframe or
@@ -147,6 +150,7 @@ interface PlayerState extends PlayerStoreSlices {
updateElement: (elementId: string, updates: TimelineElementPatch) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
+ setTrackHeight: (height: number) => void;
bumpZEditVersion: () => void;
setInPoint: (time: number | null) => void;
setOutPoint: (time: number | null) => void;
@@ -281,6 +285,7 @@ export const usePlayerStore = create((set, get) => ({
zoomMode: "fit",
manualZoomPercent: 100,
userZoomCount: 0,
+ trackHeight: TRACK_H,
zEditVersion: 0,
timelinePps: 100,
timelineFitPps: 100,
@@ -492,6 +497,7 @@ export const usePlayerStore = create((set, get) => ({
set((state) => ({
manualZoomPercent: clampTimelineZoomPercent(percent, state.timelineFitPps),
})),
+ setTrackHeight: (height) => set({ trackHeight: clampTrackHeight(height) }),
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),