refactor(studio): tighten the expanded-lane cache and height helpers

Validate the parse response before reading `.animations` instead of casting
the JSON blind, and narrow the fetch's return type to the slice callers read.
Route the AST cache load through the shared clip-keyframe and cache-key
helpers so it can't drift from the other writer. Drop the unused numeric
track-count branches from `trackHeights`/`getTimelineCanvasHeight`, and pick
the widest keyframed clip with a reduce so there's no index assertion.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:44 +02:00
parent 629631a72e
commit 538264463c
6 changed files with 53 additions and 50 deletions
@@ -954,11 +954,13 @@ describe("getTimelinePlayheadLeft", () => {
describe("getTimelineCanvasHeight", () => {
it("includes bottom scroll buffer below the last track", () => {
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(
RULER_H + 3 * TRACK_H,
);
});
it("still keeps ruler space when there are no tracks", () => {
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
expect(getTimelineCanvasHeight([])).toBeGreaterThan(24);
});
});
@@ -16,6 +16,9 @@ import {
resolveTimelineAssetDrop,
} from "./timelineLayout";
/** N collapsed rows, the shape every caller passes when nothing is expanded. */
const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
describe("variable timeline row geometry", () => {
const tracks = [
[{ clipId: "a", laneCount: 0 }],
@@ -25,7 +28,7 @@ describe("variable timeline row geometry", () => {
it("resolves every row to the base height when no clip is expanded", () => {
expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
expect(trackHeights(3)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
expect(trackHeights([[], [], []])).toEqual([TRACK_H, TRACK_H, TRACK_H]);
});
it("adds one lane height per lane on an expanded clip", () => {
@@ -84,7 +87,7 @@ describe("collapsed timeline row geometry characterization", () => {
[3, 290],
[5, 386],
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
expect(getTimelineCanvasHeight(trackCount)).toBe(expectedHeight);
expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
});
});
@@ -129,20 +132,16 @@ describe("track-area breathing pad y-math", () => {
describe("getTimelineCanvasHeight", () => {
it("reserves ruler + top pad + lanes + bottom pad", () => {
expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
expect(getTimelineCanvasHeight(3)).toBe(
expect(getTimelineCanvasHeight([])).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
expect(getTimelineCanvasHeight(baseRows(3))).toBe(
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
);
});
it("clamps a negative track count to zero lanes", () => {
expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
});
it("leaves room below the last lane for a drag-into-void new track", () => {
// The gap below the final lane must be at least a full track height so a
// clip can be dropped there to create a new bottom track.
const oneLane = getTimelineCanvasHeight(1);
const oneLane = getTimelineCanvasHeight(baseRows(1));
const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
});
@@ -157,7 +156,7 @@ describe("track-area breathing pad y-math", () => {
contentOrigin: GUTTER,
pixelsPerSecond: 100,
duration: 60,
rowHeights: trackHeights(3),
rowHeights: baseRows(3),
trackOrder: [0, 1, 2],
};
@@ -55,12 +55,9 @@ type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
* the shared row height.
*/
export function trackHeights(
tracks: number | TimelineTrackHeightInput,
tracks: TimelineTrackHeightInput,
expandedClipIds?: ReadonlySet<string>,
): number[] {
if (typeof tracks === "number") {
return Array.from({ length: Math.max(0, Math.trunc(tracks)) }, () => TRACK_H);
}
return tracks.map((clips) => {
let laneCount = 0;
if (expandedClipIds) {
@@ -473,15 +470,11 @@ export function getTimelineScrubTime(input: {
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
}
export function getTimelineCanvasHeight(trackCountOrHeights: number | readonly number[]): number {
export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
// below the last lane is real scrollable surface, not a hidden buffer.
const heights =
typeof trackCountOrHeights === "number"
? trackHeights(trackCountOrHeights)
: trackCountOrHeights;
const rowsHeight = getTimelineRowOffsets(heights).at(-1) ?? 0;
const rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0;
return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
}
@@ -38,9 +38,10 @@ export function resolveTrackKeyframeClip(
return key === selectedElementId || selectedElementIds.has(key);
});
if (selected) return selected;
return [...keyframed].sort(
(a, b) => (laneCounts.get(b.key ?? b.id) ?? 0) - (laneCounts.get(a.key ?? a.id) ?? 0),
)[0]!;
// Most lanes wins, first one on a tie (same as the old stable sort), but as a
// reduce over the already non-empty list so there's no index to assert on.
const lanesOf = (element: TimelineElement) => laneCounts.get(element.key ?? element.id) ?? 0;
return keyframed.reduce((best, element) => (lanesOf(element) > lanesOf(best) ? element : best));
}
/** Lanes per clip: the count of distinct property groups whose tween contributes