Files
hyperframes/packages/studio/src/player/components/timelineEditing.test.ts
T
Miguel Angel Simon Sierra ed8bf475d3 feat(studio): lane-model timeline (non-overlapping clips share a row)
Rework the timeline row model from one-row-per-stacking-layer to NLE
"lanes": a row holds a time-sequence of non-overlapping clips, ordered
top = front. Stacking only matters between clips that overlap in time.

- Lane packing: per stacking context, sort clips by effective z-index
  desc (DOM order tiebreak) and greedily pack each onto the first lane
  whose members don't overlap it in time; else open a new lane. Lane's
  representative z is the max member z. Audio stays one clip per lane.
- Lane-aware drag: dropping onto a lane joins it (clip takes the lane's
  z) when there's no time-conflict; a conflicting drop is rejected and
  the preview snaps to the nearest valid lane or a new-lane insertion.
  Between/above/below still create a new lane at that stacking level.
- Overlap checks use the drag-preview start/duration, so conflict is
  judged by where the clip lands, not where it started.

Only explicit drags write z-index; authored z is preserved and
data-track-index is never rewritten.
2026-07-08 23:46:26 -04:00

710 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
buildClipRangeSelection,
buildPromptCopyText,
buildTimelineElementAgentPrompt,
buildTimelineAgentPrompt,
getTimelineEditCapabilities,
hasPatchableTimelineTarget,
resolveBlockedTimelineEditIntent,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
snapKeyframePctToBeat,
type TimelinePromptElement,
} from "./timelineEditing";
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
describe("resolveTimelineMove", () => {
it("moves timing based on horizontal drag and snaps to centiseconds", () => {
expect(
resolveTimelineMove(
{
start: 1.25,
track: 2,
duration: 2,
originClientX: 100,
originClientY: 200,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 1, 2, 3, 4],
},
245,
200,
),
).toEqual({ start: 2.7, track: 2 });
});
it("moves layers based on vertical drag and clamps to the allowed range", () => {
expect(
resolveTimelineMove(
{
start: 2,
track: 1,
duration: 3,
originClientX: 200,
originClientY: 200,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 10,
trackOrder: [0, 1, 5, 9],
},
150,
390,
),
).toEqual({ start: 1.5, track: 9 });
});
it("prevents moving before zero or past the last valid start", () => {
expect(
resolveTimelineMove(
{
start: 0.2,
track: 0,
duration: 4,
originClientX: 300,
originClientY: 200,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 6,
trackOrder: [0, 10, 20],
},
-100,
-200,
),
).toEqual({ start: 0, track: -1 });
expect(
resolveTimelineMove(
{
start: 5.8,
track: 10,
duration: 4,
originClientX: 300,
originClientY: 200,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 6,
trackOrder: [0, 10, 20],
},
500,
200,
),
).toEqual({ start: 6, track: 10 });
});
it("creates a new top track when dragged past the first row threshold", () => {
expect(
resolveTimelineMove(
{
start: 1,
track: 0,
duration: 2,
originClientX: 100,
originClientY: 200,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 10, 20],
},
100,
150,
),
).toEqual({ start: 1, track: -1 });
});
it("creates a new bottom track when dragged past the last row threshold", () => {
expect(
resolveTimelineMove(
{
start: 1,
track: 20,
duration: 2,
originClientX: 100,
originClientY: 200,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 10, 20],
},
100,
250,
),
).toEqual({ start: 1, track: 21 });
});
it("accounts for scroll displacement while dragging", () => {
expect(
resolveTimelineMove(
{
start: 1,
track: 0,
duration: 2,
originClientX: 100,
originClientY: 200,
originScrollLeft: 0,
originScrollTop: 0,
currentScrollLeft: 100,
currentScrollTop: 144,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 1, 2, 3],
},
100,
200,
),
).toEqual({ start: 2, track: 2 });
});
it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
const stackingElements = [
{
id: "root-front",
tag: "div",
start: 0,
duration: 2,
track: 0,
zIndex: 2,
hasExplicitZIndex: true,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
{
id: "root-back",
tag: "div",
start: 0,
duration: 2,
track: 1,
zIndex: 1,
hasExplicitZIndex: true,
stackingContextId: "root",
parentCompositionId: null,
compositionAncestors: ["root"],
},
];
const layers = buildStackingTimelineLayers(stackingElements).rows;
const result = resolveTimelineMove(
{
start: 0,
track: 1,
duration: 2,
originClientX: 0,
originClientY: 0,
pixelsPerSecond: 100,
trackHeight: 72,
maxStart: 8,
trackOrder: [0, 1],
layerOrder: layers.map((layer) => layer.id),
timelineLayers: layers,
stackingElement: stackingElements[1],
stackingElements,
},
0,
-72,
);
expect(result).toEqual({
start: 0,
track: 1,
previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
previewLayerIndex: 0,
stackingReorder: {
contextKey: "root",
placement: { type: "above", layerId: layers[0]!.id },
zIndexChanges: [{ key: "root-back", zIndex: 3 }],
},
});
});
});
describe("hasPatchableTimelineTarget", () => {
it("returns true when the clip has a DOM id", () => {
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
});
it("returns true when the clip has a selector", () => {
expect(hasPatchableTimelineTarget({ selector: ".hero-card" })).toBe(true);
});
it("returns false when the clip has no stable patch target", () => {
expect(hasPatchableTimelineTarget({})).toBe(false);
});
});
describe("getTimelineEditCapabilities", () => {
it("does not disable editable audio just because it spans multiple scenes", () => {
expect(
getTimelineEditCapabilities({
tag: "audio",
duration: 8,
selector: "#voiceover",
sourceDuration: 8,
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("allows full editing of generic motion clips with authored timing", () => {
expect(
getTimelineEditCapabilities({
tag: "section",
duration: 2,
selector: ".feature-card",
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("keeps implicit layout layers selectable but not timeline-editable", () => {
expect(
getTimelineEditCapabilities({
duration: 8,
selector: ".scene-shell",
tag: "div",
timingSource: "implicit",
}),
).toEqual({
canMove: false,
canTrimStart: false,
canTrimEnd: false,
});
});
it("allows move and both trims for patchable media clips with offset support", () => {
expect(
getTimelineEditCapabilities({
tag: "video",
duration: 2,
selector: "#media-card",
playbackStartAttr: "media-start",
sourceDuration: 10,
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("treats wrapped media clips with media metadata as deterministic", () => {
expect(
getTimelineEditCapabilities({
tag: "div",
duration: 2,
selector: "#media-card",
playbackStartAttr: "media-start",
sourceDuration: 10,
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("allows full editing for patchable composition hosts", () => {
expect(
getTimelineEditCapabilities({
tag: "div",
duration: 3,
selector: '[data-composition-id="intro"]',
compositionSrc: "compositions/intro.html",
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("locks all timeline edits for clips with data-timeline-locked", () => {
expect(
getTimelineEditCapabilities({
tag: "div",
duration: 8,
selector: '[data-composition-id="caption-highlight"]',
compositionSrc: "compositions/components/caption-highlight.html",
timelineLocked: true,
}),
).toEqual({
canMove: false,
canTrimStart: false,
canTrimEnd: false,
});
});
it("allows full editing of explicitly authored generic elements", () => {
expect(
getTimelineEditCapabilities({
tag: "div",
duration: 4,
selector: "#hero-card",
timingSource: "authored",
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("disables all timeline edits for clips without a patchable target", () => {
expect(
getTimelineEditCapabilities({
tag: "video",
duration: 2,
sourceDuration: 10,
}),
).toEqual({
canMove: false,
canTrimStart: false,
canTrimEnd: false,
});
});
});
describe("resolveBlockedTimelineEditIntent", () => {
it("returns move when the clip body is blocked", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 80,
handleWidth: 18,
capabilities: {
canMove: false,
canTrimStart: false,
canTrimEnd: false,
},
}),
).toBe("move");
});
it("returns resize-start when the left edge is blocked", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 8,
handleWidth: 18,
capabilities: {
canMove: false,
canTrimStart: false,
canTrimEnd: true,
},
}),
).toBe("resize-start");
});
it("returns resize-end when the right edge is blocked", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 154,
handleWidth: 18,
capabilities: {
canMove: false,
canTrimStart: true,
canTrimEnd: false,
},
}),
).toBe("resize-end");
});
it("does not block the left edge when the clip can still be moved", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 8,
handleWidth: 18,
capabilities: {
canMove: true,
canTrimStart: false,
canTrimEnd: true,
},
}),
).toBe(null);
});
it("does not swallow the full surface of a narrow movable clip", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 12,
offsetX: 6,
handleWidth: 18,
capabilities: {
canMove: true,
canTrimStart: false,
canTrimEnd: false,
},
}),
).toBe(null);
});
it("returns null when the relevant edit is supported", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 8,
handleWidth: 18,
capabilities: {
canMove: true,
canTrimStart: true,
canTrimEnd: true,
},
}),
).toBe(null);
});
});
describe("buildClipRangeSelection", () => {
it("anchors the full clip range at the click position", () => {
expect(
buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
).toEqual({
start: 1.25,
end: 4.75,
anchorX: 320,
anchorY: 180,
});
});
});
describe("resolveTimelineAutoScroll", () => {
it("does not scroll when the pointer stays away from the edges", () => {
expect(
resolveTimelineAutoScroll(
{
left: 100,
top: 100,
right: 500,
bottom: 400,
},
300,
250,
),
).toEqual({ x: 0, y: 0 });
});
it("scrolls upward and leftward near the top-left edge", () => {
expect(
resolveTimelineAutoScroll(
{
left: 100,
top: 100,
right: 500,
bottom: 400,
},
110,
120,
),
).toEqual({ x: -9, y: -6 });
});
it("scrolls downward and rightward near the bottom-right edge", () => {
expect(
resolveTimelineAutoScroll(
{
left: 100,
top: 100,
right: 500,
bottom: 400,
},
490,
380,
),
).toEqual({ x: 9, y: 6 });
});
});
describe("buildTimelineAgentPrompt", () => {
it("includes the selected range, elements, and user request", () => {
const elements: TimelinePromptElement[] = [
{ id: "title", tag: "div", start: 1, duration: 3, track: 0 },
{ id: "music", tag: "audio", start: 0, duration: 8, track: 2 },
];
const text = buildTimelineAgentPrompt({
rangeStart: 1,
rangeEnd: 4,
elements,
prompt: "Move the title later and lower the music",
});
expect(text).toContain("Time range: 0:01 — 0:04");
expect(text).toContain("#title (div)");
expect(text).toContain("#music (audio)");
expect(text).toContain("Move the title later and lower the music");
});
});
describe("buildTimelineElementAgentPrompt", () => {
it("includes the clip context and guidance for agent-based edits", () => {
expect(
buildTimelineElementAgentPrompt({
id: "feature-card",
tag: "section",
start: 1.4,
duration: 1.6,
track: 1,
sourceFile: "index.html",
selector: "#feature-card",
}),
).toContain("If this clip is animated with GSAP");
});
});
describe("resolveTimelineResize", () => {
it("shrinks clip duration from the right edge", () => {
expect(
resolveTimelineResize(
{
start: 1,
duration: 3,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 10,
},
"end",
40,
),
).toEqual({ start: 1, duration: 2.4, playbackStart: undefined });
});
it("trims media from the left edge by advancing playback start and clip start", () => {
expect(
resolveTimelineResize(
{
start: 1,
duration: 3,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 10,
playbackStart: 0.5,
playbackRate: 1,
},
"start",
150,
),
).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
});
it("can seed front trim from an implicit zero playback start", () => {
expect(
resolveTimelineResize(
{
start: 0,
duration: 8,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 8,
playbackStart: 0,
playbackRate: 1,
},
"start",
200,
),
).toEqual({ start: 1, duration: 7, playbackStart: 1 });
});
it("prevents extending media left past available source before media-start", () => {
expect(
resolveTimelineResize(
{
start: 1,
duration: 3,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 10,
playbackStart: 0.2,
playbackRate: 1,
},
"start",
0,
),
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
});
it("trims generic element start without media offset", () => {
expect(
resolveTimelineResize(
{
start: 2,
duration: 4,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 10,
},
"start",
200,
),
).toEqual({ start: 3, duration: 3, playbackStart: undefined });
});
it("extends generic element start leftward to time zero", () => {
expect(
resolveTimelineResize(
{
start: 1,
duration: 3,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 10,
},
"start",
-200,
),
).toEqual({ start: 0, duration: 4, playbackStart: undefined });
});
});
describe("buildPromptCopyText", () => {
it("returns a trimmed prompt for the copy-prompt action", () => {
expect(buildPromptCopyText(" Tighten the headline timing ")).toBe(
"Tighten the headline timing",
);
});
});
describe("snapKeyframePctToBeat", () => {
// el spans 010s, so clip-% maps to composition time as pct * 0.1s.
// At pps=100 the snap window is 8 / 100 = 0.08s.
const el = { start: 0, duration: 10 };
const beats = [2, 5, 8];
it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
// pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
});
it("leaves a keyframe unchanged when no beat is within the window", () => {
// pct 55 → 5.5s, 0.5s from the nearest beat → free.
expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
});
it("is a no-op when there are no beats", () => {
expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
});
it("is a no-op for a zero-duration clip", () => {
expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
});
it("widens the snap window as zoom (pps) decreases", () => {
// pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
});
});